From 41015f93938d67f1463fe759d17c2dd2f9a11fda Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:27:58 -0700 Subject: [PATCH 01/93] refactor(renderer): split runtime and store modules (cherry picked from commit 207bef0198fbd0545241d0dfdfdb4ef2a85222da) --- ...browser-workspace-tab-close-census.test.ts | 15 +- .../host-session-mirror-settle-census.test.ts | 34 +- .../src/runtime/sync-runtime-graph.ts | 2259 +----- .../agent-status-projection.ts | 77 + .../sync-runtime-graph/graph-publication.ts | 246 + .../runtime/sync-runtime-graph/graph-state.ts | 152 + .../mobile-session-browser-tabs.ts | 38 + .../mobile-session-capture.ts | 190 + .../mobile-session-editor-tabs.ts | 77 + .../mobile-session-group-projection.ts | 242 + .../mobile-session-inputs.ts | 246 + .../mobile-session-snapshots.ts | 234 + .../mobile-session-tab-helpers.ts | 110 + .../mobile-session-terminal-tabs.ts | 126 + .../mobile-terminal-theme.ts | 74 + .../runtime/sync-runtime-graph/sync-key.ts | 182 + .../sync-runtime-graph/sync-projections.ts | 157 + .../src/runtime/sync-runtime-graph/types.ts | 142 + .../src/runtime/web-session-tabs-sync.ts | 6214 +---------------- .../active-session-subscription.ts | 288 + .../agent-status-patch.ts | 195 + .../agent-status-primitives.ts | 198 + .../apply-active-state.ts | 288 + .../apply-browser-records.ts | 134 + .../apply-final-patch.ts | 146 + .../apply-preparation-base.ts | 212 + .../apply-preparation-browser.ts | 227 + .../apply-preparation-groups.ts | 233 + .../apply-preparation-unified.ts | 278 + .../web-session-tabs-sync/apply-snapshot.ts | 52 + .../apply-terminal-records.ts | 116 + .../apply-worktree-records.ts | 92 + .../web-session-tabs-sync/browser-helpers.ts | 237 + .../global-session-events.ts | 165 + .../global-session-inventory-event.ts | 187 + .../global-session-subscription.ts | 227 + .../web-session-tabs-sync/layout-groups.ts | 217 + .../web-session-tabs-sync/layout-helpers.ts | 162 + .../web-session-tabs-sync/load-initial.ts | 191 + .../web-session-tabs-sync/mirror-settle.ts | 155 + .../publisher-identity-fences.ts | 139 + .../web-session-tabs-sync/snapshot-api.ts | 130 + .../state-equality-core.ts | 155 + .../state-equality-files.ts | 154 + .../state-equality-tabs.ts | 142 + .../runtime/web-session-tabs-sync/state.ts | 239 + .../web-session-tabs-sync/store-patch.ts | 173 + .../web-session-tabs-sync/tab-builders.ts | 144 + .../web-session-tabs-sync/terminal-build.ts | 191 + .../terminal-surfaces.ts | 197 + .../tracking-decisions.ts | 177 + .../tracking-lifecycle.ts | 218 + .../tracking-mappings.ts | 58 + .../runtime/web-session-tabs-sync/tracking.ts | 327 + .../use-web-session-tabs-sync.ts | 114 + .../visibility-resume-batch.ts | 64 + .../visibility-resume-coordinator.ts | 309 + .../visibility-resume-inventory.ts | 116 + .../visibility-resume-repair.ts | 36 + .../visibility-resume-types.ts | 42 + .../web-session-tabs-sync/visibility-types.ts | 6 + .../slices/agent-status-authority-actions.ts | 218 + .../slices/agent-status-cleanup-actions.ts | 197 + .../src/store/slices/agent-status-contract.ts | 149 + .../store/slices/agent-status-drop-actions.ts | 225 + .../store/slices/agent-status-drop-reducer.ts | 159 + .../slices/agent-status-launch-actions.ts | 110 + .../slices/agent-status-launch-config.ts | 142 + .../store/slices/agent-status-live-actions.ts | 120 + .../slices/agent-status-live-entry-builder.ts | 291 + .../store/slices/agent-status-live-facts.ts | 131 + .../store/slices/agent-status-live-reducer.ts | 104 + .../store/slices/agent-status-map-helpers.ts | 210 + .../agent-status-orchestration-actions.ts | 103 + .../store/slices/agent-status-pane-helpers.ts | 277 + .../agent-status-provider-session-actions.ts | 177 + .../slices/agent-status-recovery-actions.ts | 198 + .../agent-status-recovery-collection.ts | 174 + .../agent-status-recovery-equivalence.ts | 83 + .../slices/agent-status-recovery-helpers.ts | 152 + .../slices/agent-status-retention-actions.ts | 141 + .../src/store/slices/agent-status-runtime.ts | 224 + .../slices/agent-status-slice-contract.ts | 178 + .../agent-status-worktree-drop-actions.ts | 157 + src/renderer/src/store/slices/agent-status.ts | 3550 +--------- src/renderer/src/store/slices/browser.ts | 2692 +------ .../slices/browser/browser-close-actions.ts | 250 + .../browser/browser-cookie-import-actions.ts | 193 + .../slices/browser/browser-history-actions.ts | 99 + .../slices/browser/browser-host-actions.ts | 70 + .../slices/browser/browser-host-state.ts | 175 + .../browser/browser-hydration-actions.ts | 264 + .../slices/browser/browser-page-annotation.ts | 18 + .../browser-page-conversion-actions.ts | 212 + .../browser/browser-page-create-actions.ts | 202 + .../browser/browser-page-focus-actions.ts | 94 + .../browser/browser-page-metadata-actions.ts | 131 + .../browser/browser-page-state-actions.ts | 219 + .../browser/browser-profile-import-actions.ts | 172 + .../browser/browser-profile-list-actions.ts | 172 + .../slices/browser/browser-remote-close.ts | 33 + .../slices/browser/browser-slice-contract.ts | 262 + .../slices/browser/browser-tab-actions.ts | 253 + .../browser/browser-tab-focus-actions.ts | 141 + .../slices/browser/create-browser-slice.ts | 57 + src/renderer/src/store/slices/linear.ts | 2264 +----- .../src/store/slices/linear/linear-cache.ts | 234 + .../linear-custom-view-detail-actions.ts | 141 + .../linear-custom-view-issue-actions.ts | 153 + .../linear/linear-custom-view-list-actions.ts | 149 + .../linear-custom-view-project-actions.ts | 137 + .../linear/linear-invalidation-actions.ts | 155 + .../linear/linear-issue-cache-actions.ts | 101 + .../linear/linear-issue-detail-actions.ts | 157 + .../linear/linear-issue-list-actions.ts | 250 + .../linear/linear-project-detail-actions.ts | 133 + .../linear/linear-project-issue-actions.ts | 153 + .../linear/linear-project-list-actions.ts | 147 + .../linear/linear-slice-connection-actions.ts | 234 + .../slices/linear/linear-slice-contract.ts | 160 + .../linear/linear-slice-request-state.ts | 161 + .../store/slices/linear/linear-slice-scope.ts | 102 + .../linear/linear-slice-status-actions.ts | 115 + .../slices/linear/linear-team-actions.ts | 135 + src/renderer/src/store/slices/tabs.ts | 2129 +----- .../store/slices/tabs/create-tabs-slice.ts | 31 + .../slices/tabs/tabs-bulk-close-actions.ts | 87 + .../store/slices/tabs/tabs-close-actions.ts | 165 + .../store/slices/tabs/tabs-create-actions.ts | 221 + .../store/slices/tabs/tabs-drop-actions.ts | 194 + .../store/slices/tabs/tabs-focus-actions.ts | 113 + .../store/slices/tabs/tabs-group-actions.ts | 195 + .../store/slices/tabs/tabs-host-mirroring.ts | 62 + .../store/slices/tabs/tabs-label-actions.ts | 199 + .../src/store/slices/tabs/tabs-layout.ts | 125 + .../store/slices/tabs/tabs-move-actions.ts | 132 + .../store/slices/tabs/tabs-reconciliation.ts | 225 + .../slices/tabs/tabs-secondary-actions.ts | 94 + .../store/slices/tabs/tabs-session-actions.ts | 78 + .../store/slices/tabs/tabs-slice-contract.ts | 163 + .../src/store/slices/tabs/tabs-surface.ts | 197 + .../src/store/slices/tabs/tabs-tab-order.ts | 41 + src/renderer/src/store/slices/ui.ts | 2944 +------- .../store/slices/ui/ui-slice-agent-actions.ts | 279 + .../store/slices/ui/ui-slice-agent-helpers.ts | 62 + .../slices/ui/ui-slice-contract-contextual.ts | 117 + .../store/slices/ui/ui-slice-contract-core.ts | 211 + .../ui/ui-slice-contract-preferences.ts | 182 + .../src/store/slices/ui/ui-slice-contract.ts | 36 + .../slices/ui/ui-slice-feature-actions.ts | 84 + .../slices/ui/ui-slice-hydration-actions.ts | 343 + .../ui/ui-slice-hydration-sanitizers.ts | 221 + .../slices/ui/ui-slice-hydration-values.ts | 134 + .../store/slices/ui/ui-slice-modal-actions.ts | 25 + .../slices/ui/ui-slice-persistence-actions.ts | 52 + .../slices/ui/ui-slice-preference-actions.ts | 261 + .../slices/ui/ui-slice-settings-actions.ts | 65 + .../slices/ui/ui-slice-surface-actions.ts | 169 + .../store/slices/ui/ui-slice-task-actions.ts | 215 + .../store/slices/ui/ui-slice-tour-actions.ts | 239 + .../store/slices/ui/ui-slice-tour-helpers.ts | 38 + .../store/slices/ui/ui-slice-trust-actions.ts | 143 + .../slices/ui/ui-slice-update-actions.ts | 110 + .../store/slices/ui/ui-slice-view-actions.ts | 111 + src/shared/keybindings.ts | 2443 +------ src/shared/keybindings/definitions-core-1.ts | 298 + src/shared/keybindings/definitions-core-2.ts | 282 + src/shared/keybindings/definitions-core-3.ts | 292 + src/shared/keybindings/definitions-core-4.ts | 70 + src/shared/keybindings/definitions-support.ts | 9 + src/shared/keybindings/definitions.ts | 92 + src/shared/keybindings/effective.ts | 97 + src/shared/keybindings/formatting.ts | 206 + src/shared/keybindings/input.ts | 261 + src/shared/keybindings/matching-key.ts | 187 + src/shared/keybindings/matching.ts | 140 + src/shared/keybindings/normalization.ts | 158 + src/shared/keybindings/parser.ts | 261 + src/shared/keybindings/types.ts | 200 + 179 files changed, 27500 insertions(+), 24194 deletions(-) create mode 100644 src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/graph-state.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/sync-key.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/types.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/state.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/tracking.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts create mode 100644 src/renderer/src/store/slices/agent-status-authority-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-cleanup-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-contract.ts create mode 100644 src/renderer/src/store/slices/agent-status-drop-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-drop-reducer.ts create mode 100644 src/renderer/src/store/slices/agent-status-launch-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-launch-config.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-entry-builder.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-facts.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-reducer.ts create mode 100644 src/renderer/src/store/slices/agent-status-map-helpers.ts create mode 100644 src/renderer/src/store/slices/agent-status-orchestration-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-pane-helpers.ts create mode 100644 src/renderer/src/store/slices/agent-status-provider-session-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-recovery-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-recovery-collection.ts create mode 100644 src/renderer/src/store/slices/agent-status-recovery-equivalence.ts create mode 100644 src/renderer/src/store/slices/agent-status-recovery-helpers.ts create mode 100644 src/renderer/src/store/slices/agent-status-retention-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-runtime.ts create mode 100644 src/renderer/src/store/slices/agent-status-slice-contract.ts create mode 100644 src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-close-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-history-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-host-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-host-state.ts create mode 100644 src/renderer/src/store/slices/browser/browser-hydration-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-page-annotation.ts create mode 100644 src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-page-create-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-page-state-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-profile-import-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-profile-list-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-remote-close.ts create mode 100644 src/renderer/src/store/slices/browser/browser-slice-contract.ts create mode 100644 src/renderer/src/store/slices/browser/browser-tab-actions.ts create mode 100644 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts create mode 100644 src/renderer/src/store/slices/browser/create-browser-slice.ts create mode 100644 src/renderer/src/store/slices/linear/linear-cache.ts create mode 100644 src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-invalidation-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-issue-list-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-project-detail-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-project-issue-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-project-list-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-slice-contract.ts create mode 100644 src/renderer/src/store/slices/linear/linear-slice-request-state.ts create mode 100644 src/renderer/src/store/slices/linear/linear-slice-scope.ts create mode 100644 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts create mode 100644 src/renderer/src/store/slices/linear/linear-team-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/create-tabs-slice.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-close-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-create-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-drop-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-focus-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-group-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-label-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-layout.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-move-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-reconciliation.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-session-actions.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-slice-contract.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-surface.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-tab-order.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-contract-core.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-contract.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-task-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-update-actions.ts create mode 100644 src/renderer/src/store/slices/ui/ui-slice-view-actions.ts create mode 100644 src/shared/keybindings/definitions-core-1.ts create mode 100644 src/shared/keybindings/definitions-core-2.ts create mode 100644 src/shared/keybindings/definitions-core-3.ts create mode 100644 src/shared/keybindings/definitions-core-4.ts create mode 100644 src/shared/keybindings/definitions-support.ts create mode 100644 src/shared/keybindings/definitions.ts create mode 100644 src/shared/keybindings/effective.ts create mode 100644 src/shared/keybindings/formatting.ts create mode 100644 src/shared/keybindings/input.ts create mode 100644 src/shared/keybindings/matching-key.ts create mode 100644 src/shared/keybindings/matching.ts create mode 100644 src/shared/keybindings/normalization.ts create mode 100644 src/shared/keybindings/parser.ts create mode 100644 src/shared/keybindings/types.ts diff --git a/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts b/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts index 05a0dd7123b..b2e53eab08e 100644 --- a/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts +++ b/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts @@ -129,15 +129,20 @@ const BROWSER_WORKSPACE_CLOSE_SITES: { why: 'Rolls back a client-staged row for a create that never reached a host page.' }, { - path: 'src/renderer/src/store/slices/browser.ts', + path: 'src/renderer/src/store/slices/browser/browser-close-actions.ts', closeBrowserTabMentions: 3, reasonCarryingCloseCalls: 0, planReasonForwardings: 0, routesThroughPlan: false, - why: - 'shutdownWorktreeBrowsers tears the whole worktree down; the slice is the seam itself. Same ' + - 'carve-out for parked chrome as the staging rollback, and page ids are unique so a later ' + - "page cannot pick up a dead one's entry." + why: 'Slice-owned shutdown tears down every browser row directly.' + }, + { + path: 'src/renderer/src/store/slices/browser/browser-slice-contract.ts', + closeBrowserTabMentions: 1, + reasonCarryingCloseCalls: 0, + planReasonForwardings: 0, + routesThroughPlan: false, + why: 'The extracted slice contract declares the close action consumed by browser controllers.' } ] diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index b50ed2ae795..b1480259599 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -108,7 +108,7 @@ describe('host-session-mirror settle census', () => { // empty inventory, a non-empty inventory, and an empty inventory that // settles only after `terminal.list` says the host has no live PTY — // `0 === 0` is not host evidence (STA-5377). - 'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 } + 'runtime/web-session-tabs-sync/mirror-settle.ts': { hydrated: 3, worktreeHydrated: 2 } }) }) @@ -140,9 +140,14 @@ describe('host-session-mirror settle census', () => { } } expect(callSites, SETTLE_RULE).toEqual({ - // initial listAll, visibility-resume repair, full inventory, global - // singular frame, scoped active frame. - 'runtime/web-session-tabs-sync.ts': 5, + // Initial listAll, visibility-resume repair, full inventory, global + // singular frame, and scoped active frame are owned by these extracted + // recovery/event modules. + 'runtime/web-session-tabs-sync/active-session-subscription.ts': 1, + 'runtime/web-session-tabs-sync/global-session-events.ts': 1, + 'runtime/web-session-tabs-sync/global-session-inventory-event.ts': 1, + 'runtime/web-session-tabs-sync/load-initial.ts': 1, + 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': 1, // The eager post-create session.tabs.list refresh. 'runtime/web-runtime-session-snapshot.ts': 1, // The local structured-session inventory/subscription frame. @@ -188,10 +193,15 @@ describe('host-session-mirror settle census', () => { // Pinning the resolved names keeps the rule from going vacuous: a binding // this census misreads settles on some other identifier's invocation. expect(receiptBindings, SETTLE_RULE).toEqual({ - // Four hydration receipts (initial listAll, full inventory, scoped active - // frame patch, and its patchless twin) and three mirror ones - // (visibility-resume repair, global singular frame patch and patchless). - 'runtime/web-session-tabs-sync.ts': { settleHydration: 4, settleMirror: 3 }, + // Hydration and mirror receipts remain pinned by their extracted owners: + // the global singular frame owns two hydration completions and the global + // inventory frame one, initial loading owns one, active subscription owns + // two mirror settles, and visibility resume repair owns one. + 'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 }, + 'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 2 }, + 'runtime/web-session-tabs-sync/global-session-inventory-event.ts': { settleHydration: 1 }, + 'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 }, + 'runtime/web-session-tabs-sync/visibility-resume-repair.ts': { settle: 1 }, 'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 }, 'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 } }) @@ -207,7 +217,9 @@ describe('host-session-mirror settle census', () => { } expect(patchlessCounts, SETTLE_RULE).toEqual({ // The definition, the global singular frame, and the scoped active frame. - 'runtime/web-session-tabs-sync.ts': 3 + 'runtime/web-session-tabs-sync/active-session-subscription.ts': 1, + 'runtime/web-session-tabs-sync/global-session-events.ts': 1, + 'runtime/web-session-tabs-sync/mirror-settle.ts': 1 }) }) @@ -228,11 +240,11 @@ describe('host-session-mirror settle census', () => { // to say whether the frame is host evidence — the whole point of the pair. expect(settlingCounts, SETTLE_RULE).toEqual({ // The applied frame (its own patch) and the outranked one (the accepted view). - 'runtime/web-session-tabs-sync.ts': 2 + 'runtime/web-session-tabs-sync/tracking-decisions.ts': 2 }) expect(silentCounts, SETTLE_RULE).toEqual({ // The unmirrored frame: no accepted view of it ever reached the store. - 'runtime/web-session-tabs-sync.ts': 1 + 'runtime/web-session-tabs-sync/tracking-decisions.ts': 1 }) }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 2c84eb85667..dd1bdb58d5f 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1,301 +1,41 @@ -/* eslint-disable max-lines -- Why: runtime graph sync and mobile session-tab publication share the same injected renderer state and terminal registry. Keeping them together prevents a second store/registry reader from drifting. */ -import { - collectLeafIdsInOrder, - serializePaneTree, - normalizeTerminalLayoutSnapshot -} from '@/components/terminal-pane/layout-serialization' -import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' -import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher' -import { - collectParkedTerminalWatcherPtyIds, - getParkedTerminalWatcherPaneIdsByPtyId -} from '@/components/terminal-pane/terminal-parked-watcher-registry' -import { createBrowserUuid } from '@/lib/browser-uuid' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' -import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import type { AppState } from '@/store/types' -import type { - RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionFileTab, - RuntimeMobileSessionMarkdownTab, - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionSnapshotTab, - RuntimeMobileTerminalTheme, - RuntimeMobileSessionTabsSnapshot, - RuntimeRendererSyncWindowGraph -} from '../../../shared/runtime-types' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' -import { isClaudeManagementTitle } from '../../../shared/agent-detection' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../../../shared/terminal-tab-types' -import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' +import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' import { - isNativeChatTabWideFallbackSafe, - nativeChatLaunchAgentForLeaf, - resolveNativeChatActiveLayoutLeafId -} from '../components/native-chat/native-chat-leaf-routing' + syncRuntimeGraph, + setTrailingGraphSyncScheduler +} from './sync-runtime-graph/graph-publication' import { - getActiveTabNavOrder, - getGroupVisibleTabOrder, - type VisibleTabRef -} from '../components/tab-bar/group-tab-order' -import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' -import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' -import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' + findRegisteredTerminalTab, + graphState, + registeredTerminalTabKey, + RUNTIME_GRAPH_SYNC_COALESCE_MS +} from './sync-runtime-graph/graph-state' +import { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests +} from './sync-runtime-graph/agent-status-projection' +import { + canSkipRuntimeMobileSessionSyncKeyBuild, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual +} from './sync-runtime-graph/sync-key' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import type { RegisteredTerminalTab } from './sync-runtime-graph/types' -type RegisteredTerminalTab = { - tabId: string - worktreeId: string - getManager: () => PaneManager | null - getContainer: () => HTMLDivElement | null - getPtyIdForPane: (paneId: number) => string | null - getTabWideAgentHintLeafId: () => string | null +export type { RegisteredTerminalTab, RuntimeMobileSessionSyncKey } from './sync-runtime-graph/types' +export { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests, + canSkipRuntimeMobileSessionSyncKeyBuild, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual, + buildMobileSessionTabSnapshots } -type OpenFileByWorktreeAndId = Map> -type OpenFileIndexes = { - byWorktreeAndId: OpenFileByWorktreeAndId - idsByWorktree: Map -} -type FallbackEditorTabTarget = { - tabId: string - groupId: string | null -} -type TabsProjectionCacheEntry = { - tabs: NonNullable - worktreeIdJson: string - projection: string -} -type TabsProjectionCache = { - source: AppState['tabsByWorktree'] - entries: Map - projection: string -} -type AgentStatusProjectionCacheEntry = { - entry: AppState['agentStatusByPaneKey'][string] - projection: string -} -type AgentStatusProjectionCache = { - source: AppState['agentStatusByPaneKey'] - entries: Map - projection: string -} -type MobileSessionAgentStatusByWorktree = ReadonlyMap< - string, - ReadonlyMap -> -/** Slices shared by every worktree in one publication; derived from `AppState` exactly once. */ -type MobileSessionPublicationInputs = { - browserTabsByWorktree: AppState['browserTabsByWorktree'] - openFileIndexes: OpenFileIndexes - editorDraftVersionByFileId: ReadonlyMap - agentStatusByWorktreeId: MobileSessionAgentStatusByWorktree - generatedTitlesEnabled: boolean - terminalTheme: RuntimeMobileTerminalTheme | undefined -} -/** - * Live PaneManager/DOM reads for one mounted terminal tab, captured once per - * publication. - * - * Why: builders read this instead of the registry, so live state the memo - * cannot witness is unrepresentable — mounted worktrees memoize like the rest - * instead of rebuilding on every publication. - */ -type MountedTerminalSurfaceCapture = { - paneLeafIds: readonly string[] - hasLiveActivePane: boolean - liveActiveLeafId: string | null - liveLayoutRoot: TerminalPaneLayoutNode | null - numericPaneIdByLeafId: ReadonlyMap - ptyIdByNumericPaneId: ReadonlyMap - tabWideAgentHintLeafId: string | null -} -/** - * One worktree's complete mobile-snapshot input set. - * - * Why: every builder below takes this instead of `AppState`, so the compiler — - * not a reviewer — proves what a worktree's snapshot actually depends on. - */ -type MobileSessionWorktreeInputs = { - worktreeId: string - terminalTabs: AppState['tabsByWorktree'][string] - browserWorkspaces: AppState['browserTabsByWorktree'][string] - unifiedTabs: AppState['unifiedTabsByWorktree'][string] - groups: AppState['groupsByWorktree'][string] - tabBarOrder: AppState['tabBarOrderByWorktree'][string] | undefined - activeGroupId: string | null - tabGroupLayout: TabGroupLayoutNode | undefined - openFilesById: ReadonlyMap | undefined - openFileIds: readonly string[] - terminalLayoutByTabId: ReadonlyMap - paneTitlesByTabId: ReadonlyMap - launchDraftByPaneKey: ReadonlyMap< - string, - NonNullable[string] - > - agentStatusByPaneKey: ReadonlyMap - editorDraftVersionByFileId: ReadonlyMap - pagesByBrowserWorkspaceId: ReadonlyMap< - string, - NonNullable[string] - > - certificateFailureByBrowserPageId: ReadonlyMap< - string, - NonNullable[string] - > - activeEditorFileId: string | null - activeEditorTabType: AppState['activeTabType'] | null - activeTerminalTabId: string | null - activeBrowserWorkspaceId: string | null - generatedTitlesEnabled: boolean - terminalTheme: RuntimeMobileTerminalTheme | undefined - mountedSurfaceCaptureByTabId: ReadonlyMap -} - -type RegisteredTerminalTabKey = string - -const registeredTabs = new Map() -// Why: registration time suppresses the "no live transport" warning during the async PTY-connect window; after the grace period it's a real stuck state. -const tabRegisteredAt = new Map() - -function registeredTerminalTabKey(worktreeId: string, tabId: string): RegisteredTerminalTabKey { - return `${worktreeId}\0${tabId}` -} - -function findRegisteredTerminalTab( - tabId: string, - worktreeId?: string -): { key: RegisteredTerminalTabKey; tab: RegisteredTerminalTab } | null { - if (worktreeId !== undefined) { - const key = registeredTerminalTabKey(worktreeId, tabId) - const tab = registeredTabs.get(key) - return tab ? { key, tab } : null - } - - let match: { key: RegisteredTerminalTabKey; tab: RegisteredTerminalTab } | null = null - for (const [key, tab] of registeredTabs) { - if (tab.tabId !== tabId) { - continue - } - // A tab id without its worktree is ambiguous; callers must fail closed. - if (match) { - return null - } - match = { key, tab } - } - return match -} - -/** IDs occurring more than once cannot address the legacy tab-keyed runtime maps safely. */ -function collectAmbiguousTerminalTabIds( - tabsByWorktree: AppState['tabsByWorktree'] -): ReadonlySet { - const seen = new Set() - const ambiguous = new Set() - for (const tabs of Object.values(tabsByWorktree)) { - for (const tab of tabs) { - if (seen.has(tab.id)) { - ambiguous.add(tab.id) - } else { - seen.add(tab.id) - } - } - } - return ambiguous -} -const NO_TRANSPORT_GRACE_MS = 10_000 -const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {} -const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} -const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {} -const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {} -const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} -const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = 30_000 -const RUNTIME_GRAPH_SYNC_COALESCE_MS = 16 -let syncScheduled = false -let syncInFlight = false -let syncPendingAfterFlight = false -let syncEnabled = false -let syncTimer: ReturnType | null = null -let getStoreState: (() => AppState) | null = null -let mobileSessionSnapshotVersion = 0 -// Why: main gates per-worktree mobile fanout on (publicationEpoch, -// snapshotVersion), so that pair must be a semantic revision: reuse the cached -// snapshot (same version) whenever a worktree's mobile-visible content is -// unchanged, and bump the version only for worktrees that actually changed. -const mobileSessionSnapshotCacheByWorktree = new Map< - string, - { - inputs: MobileSessionWorktreeInputs - content: unknown - snapshot: RuntimeMobileSessionTabsSnapshot - } ->() - -// Structural equality under JSON-serialization semantics (undefined-valued -// keys are absent), so version reuse matches a JSON fingerprint exactly -// without allocating a serialized copy of the payload on every graph sync. -// Any value strict-equality can't prove equal (e.g. NaN) reads as changed, -// which only costs a redundant fanout — never a suppressed one. -function jsonContentEquals(a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { - return false - } - return a.every((item, index) => jsonContentEquals(item, b[index])) - } - if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { - return false - } - const aRecord = a as Record - const bRecord = b as Record - for (const key of Object.keys(aRecord)) { - if (!jsonContentEquals(aRecord[key], bRecord[key])) { - return false - } - } - for (const key of Object.keys(bRecord)) { - if (bRecord[key] !== undefined && aRecord[key] === undefined) { - return false - } - } - return true -} -let cachedTabsProjection: TabsProjectionCache | null = null -let cachedAgentStatusProjection: AgentStatusProjectionCache | null = null -let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null -let cachedOpenFileIndexes: OpenFileIndexes | null = null -let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null -let cachedEditorDraftVersionByFileId: Map | null = null -let cachedMobileTerminalThemeSettings: AppState['settings'] | null = null -let cachedMobileTerminalThemeSystemPrefersDark: boolean | null = null -let cachedMobileTerminalTheme: RuntimeMobileTerminalTheme | undefined -let hasCachedMobileTerminalTheme = false -const EMPTY_NARROWED_BY_KEY: ReadonlyMap = new Map() -// Why: absent per-worktree slices must resolve to one shared value, or every empty -// worktree would present a fresh `[]` and never compare equal to its last publication. -const EMPTY_WORKTREE_TERMINAL_TABS: AppState['tabsByWorktree'][string] = [] -const EMPTY_WORKTREE_BROWSER_WORKSPACES: AppState['browserTabsByWorktree'][string] = [] -const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [] -const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] -const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] -const mobileSessionPublicationEpoch = `renderer:${createBrowserUuid()}` -// Why: the snapshot object main last acknowledged per worktree; anything still -// identical is withheld from the graph payload instead of re-cloned across IPC. -const publishedMobileSessionSnapshotByWorktree = new Map() - export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { - getStoreState = getter + graphState.getStoreState = getter } /** True while the target TerminalPane is mounted (lifecycle effect ran). */ @@ -305,16 +45,16 @@ export function hasRegisteredRuntimeTerminalTab(tabId: string, worktreeId?: stri export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void { const key = registeredTerminalTabKey(tab.worktreeId, tab.tabId) - registeredTabs.set(key, tab) - tabRegisteredAt.set(key, Date.now()) + graphState.registeredTabs.set(key, tab) + graphState.tabRegisteredAt.set(key, Date.now()) scheduleRuntimeGraphSync() return () => { - // Why: React can mount a replacement surface before the prior effect cleans up; stale cleanup must not erase the successor's registry. - if (registeredTabs.get(key) !== tab) { + // React can mount a replacement before the old effect cleans up. + if (graphState.registeredTabs.get(key) !== tab) { return } - registeredTabs.delete(key) - tabRegisteredAt.delete(key) + graphState.registeredTabs.delete(key) + graphState.tabRegisteredAt.delete(key) scheduleRuntimeGraphSync() } } @@ -343,9 +83,9 @@ export function focusRuntimeTerminalSurface( } export function setRuntimeGraphSyncEnabled(enabled: boolean): void { - syncEnabled = enabled + graphState.syncEnabled = enabled if (!enabled) { - syncPendingAfterFlight = false + graphState.syncPendingAfterFlight = false clearScheduledRuntimeGraphSync() return } @@ -353,1928 +93,45 @@ export function setRuntimeGraphSyncEnabled(enabled: boolean): void { } function clearScheduledRuntimeGraphSync(): void { - if (syncTimer !== null) { - clearTimeout(syncTimer) - syncTimer = null + if (graphState.syncTimer !== null) { + clearTimeout(graphState.syncTimer) + graphState.syncTimer = null } - syncScheduled = false + graphState.syncScheduled = false } export function scheduleRuntimeGraphSync(): void { - if (!syncEnabled || syncScheduled) { + if (!graphState.syncEnabled || graphState.syncScheduled) { return } - if (syncInFlight) { - syncPendingAfterFlight = true + if (graphState.syncInFlight) { + graphState.syncPendingAfterFlight = true return } - syncScheduled = true - // Why: a frame-sized timer collapses separate title/status IPC tasks into one graph publish without tying publication to paint frames. - syncTimer = setTimeout(() => { - syncTimer = null - syncScheduled = false + graphState.syncScheduled = true + // Collapse separate title/status tasks into one frame-sized graph publication. + graphState.syncTimer = setTimeout(() => { + graphState.syncTimer = null + graphState.syncScheduled = false void runRuntimeGraphSync() }, RUNTIME_GRAPH_SYNC_COALESCE_MS) } async function runRuntimeGraphSync(): Promise { - if (syncInFlight) { - syncPendingAfterFlight = true + if (graphState.syncInFlight) { + graphState.syncPendingAfterFlight = true return } - syncInFlight = true + graphState.syncInFlight = true try { await syncRuntimeGraph() } finally { - syncInFlight = false - if (syncPendingAfterFlight) { - syncPendingAfterFlight = false - // Why: coalesce updates that arrived during one in-flight sync into a single trailing graph instead of stacking concurrent IPC calls. + graphState.syncInFlight = false + if (graphState.syncPendingAfterFlight) { + graphState.syncPendingAfterFlight = false scheduleRuntimeGraphSync() } } } -export type RuntimeMobileSessionSyncKey = { - // Why: reference changes signal layout/title updates without stringifying thousands of tabs. - terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] - runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] - nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] - folderWorkspaces: AppState['folderWorkspaces'] - groupsByWorktree: AppState['groupsByWorktree'] - activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] - layoutByWorktree: AppState['layoutByWorktree'] - unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] - tabBarOrderByWorktree: AppState['tabBarOrderByWorktree'] - activeFileId: AppState['activeFileId'] - activeFileIdByWorktree: AppState['activeFileIdByWorktree'] - activeTabType: AppState['activeTabType'] - activeTabTypeByWorktree: AppState['activeTabTypeByWorktree'] - activeTabId: AppState['activeTabId'] - activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree'] - agentStatusEpoch: number - agentStatusProjection: string - generatedTabTitlesEnabled: boolean - systemPrefersDark: boolean | null - terminalThemeProjection: string - // Why: underlying refs churn even when the mobile shape is unchanged (tabsByWorktree reallocates per OSC title frame); pre-serialize. - tabsProjection: string - openFilesProjection: string - browserProjection: string - editorDraftsProjection: string -} - -export function canSkipRuntimeMobileSessionSyncKeyBuild( - state: AppState, - previousState: AppState, - systemPrefersDark?: boolean, - previousSystemPrefersDark: boolean | null | undefined = systemPrefersDark -): boolean { - const terminalThemeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) - const previousTerminalThemeSystemPrefersDark = getTerminalThemeSystemPrefersDark( - previousState, - previousSystemPrefersDark - ) - return ( - terminalThemeSystemPrefersDark === previousTerminalThemeSystemPrefersDark && - state.tabsByWorktree === previousState.tabsByWorktree && - state.groupsByWorktree === previousState.groupsByWorktree && - state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree && - state.layoutByWorktree === previousState.layoutByWorktree && - state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree && - state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree && - state.activeFileId === previousState.activeFileId && - state.activeFileIdByWorktree === previousState.activeFileIdByWorktree && - state.activeTabType === previousState.activeTabType && - state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree && - state.browserTabsByWorktree === previousState.browserTabsByWorktree && - state.browserPagesByWorkspace === previousState.browserPagesByWorkspace && - state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree && - state.openFiles === previousState.openFiles && - state.editorDrafts === previousState.editorDrafts && - state.settings === previousState.settings && - state.activeTabId === previousState.activeTabId && - state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && - state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && - state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && - state.folderWorkspaces === previousState.folderWorkspaces && - state.agentStatusEpoch === previousState.agentStatusEpoch && - state.agentStatusByPaneKey === previousState.agentStatusByPaneKey - ) -} - -function getTerminalThemeSystemPrefersDark( - state: Pick, - systemPrefersDark: boolean | null | undefined -): boolean | null { - return state.settings?.theme === 'system' ? (systemPrefersDark ?? null) : null -} - -export function getRuntimeMobileSessionSyncKey( - state: AppState, - previousState?: AppState, - previousKey?: RuntimeMobileSessionSyncKey, - systemPrefersDark = getSystemPrefersDark() -): RuntimeMobileSessionSyncKey { - const canReusePrevious = previousState !== undefined && previousKey !== undefined - const terminalThemeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) - const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY - const previousBrowserTabsByWorktree = previousState - ? getBrowserTabsByWorktree(previousState) - : EMPTY_BROWSER_TABS_BY_WORKTREE - const previousBrowserPagesByWorkspace = previousState - ? getBrowserPagesByWorkspace(previousState) - : EMPTY_BROWSER_PAGES_BY_WORKSPACE - const previousAgentStatusByPaneKey = previousState - ? (previousState.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY) - : EMPTY_AGENT_STATUS_BY_PANE_KEY - - return { - terminalLayoutsByTabId: state.terminalLayoutsByTabId, - runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, - nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, - folderWorkspaces: state.folderWorkspaces, - groupsByWorktree: state.groupsByWorktree, - activeGroupIdByWorktree: state.activeGroupIdByWorktree, - layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, - unifiedTabsByWorktree: state.unifiedTabsByWorktree, - tabBarOrderByWorktree: state.tabBarOrderByWorktree, - activeFileId: state.activeFileId, - activeFileIdByWorktree: state.activeFileIdByWorktree, - activeTabType: state.activeTabType, - activeTabTypeByWorktree: state.activeTabTypeByWorktree, - activeTabId: state.activeTabId, - activeBrowserTabIdByWorktree: - state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, - // Why: epoch covers sort/retention/freshness changes; projection covers prompt/tool details, skipping timestamp-only heartbeats. - agentStatusEpoch: state.agentStatusEpoch ?? 0, - agentStatusProjection: - canReusePrevious && agentStatusByPaneKey === previousAgentStatusByPaneKey - ? previousKey.agentStatusProjection - : buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey), - generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, - systemPrefersDark: terminalThemeSystemPrefersDark, - terminalThemeProjection: - canReusePrevious && - state.settings === previousState.settings && - previousKey.systemPrefersDark === terminalThemeSystemPrefersDark - ? previousKey.terminalThemeProjection - : JSON.stringify(resolveMobileTerminalTheme(state, systemPrefersDark) ?? null), - // Why: background title ticks churn many times/sec; reuse unchanged projections so they don't rescan all tabs, files, and drafts. - tabsProjection: - canReusePrevious && state.tabsByWorktree === previousState.tabsByWorktree - ? previousKey.tabsProjection - : buildRuntimeMobileTabsProjection(state.tabsByWorktree), - openFilesProjection: - canReusePrevious && state.openFiles === previousState.openFiles - ? previousKey.openFilesProjection - : buildRuntimeMobileOpenFilesProjection(state.openFiles), - browserProjection: - canReusePrevious && - browserTabsByWorktree === previousBrowserTabsByWorktree && - browserPagesByWorkspace === previousBrowserPagesByWorkspace - ? previousKey.browserProjection - : buildRuntimeMobileBrowserProjection(state), - editorDraftsProjection: - canReusePrevious && state.editorDrafts === previousState.editorDrafts - ? previousKey.editorDraftsProjection - : buildRuntimeMobileEditorDraftsProjection(state.editorDrafts) - } -} - -function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { - // Why: some callers/tests build partial pre-browser states; treat missing browser slices as no tabs. - return state.browserTabsByWorktree ?? EMPTY_BROWSER_TABS_BY_WORKTREE -} - -function getBrowserPagesByWorkspace(state: AppState): AppState['browserPagesByWorkspace'] { - return state.browserPagesByWorkspace ?? EMPTY_BROWSER_PAGES_BY_WORKSPACE -} - -function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktree']): string { - if (cachedTabsProjection?.source === tabsByWorktree) { - return cachedTabsProjection.projection - } - - const previousEntries = cachedTabsProjection?.entries - const entries = new Map() - const parts: string[] = [] - - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const previous = previousEntries?.get(worktreeId) - const entry = - previous?.tabs === tabs - ? previous - : { - tabs, - worktreeIdJson: previous?.worktreeIdJson ?? JSON.stringify(worktreeId), - projection: JSON.stringify( - tabs.map((tab) => ({ - id: tab.id, - title: tab.title, - quickCommandLabel: tab.quickCommandLabel, - aiVaultTitle: tab.aiVaultTitle, - generatedTitle: tab.generatedTitle, - customTitle: tab.customTitle, - launchAgent: tab.launchAgent - })) - ) - } - entries.set(worktreeId, entry) - parts.push(`${entry.worktreeIdJson}:${entry.projection}`) - } - - cachedTabsProjection = { - source: tabsByWorktree, - entries, - projection: `{${parts.join(',')}}` - } - return cachedTabsProjection.projection -} - -function resolveRuntimeTerminalTitle( - tab: Pick< - TerminalTab, - 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' - >, - generatedTitlesEnabled: boolean, - liveTitle = tab.title -): string { - return resolveTerminalTabTitle({ ...tab, title: liveTitle }, generatedTitlesEnabled, liveTitle) -} - -function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { - return JSON.stringify( - openFiles.map((file) => ({ - id: file.id, - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - language: file.language, - mode: file.mode, - diffSource: file.diffSource, - isDirty: file.isDirty, - isUntitled: file.isUntitled, - deleteUntouchedOnClose: file.deleteUntouchedOnClose, - markdownPreviewSourceFileId: file.markdownPreviewSourceFileId - })) - ) -} - -function buildRuntimeMobileBrowserProjection(state: AppState): string { - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) - return JSON.stringify({ - workspacesByWorktree: Object.fromEntries( - Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [ - worktreeId, - workspaces.map((workspace) => ({ - id: workspace.id, - activePageId: workspace.activePageId, - title: workspace.title, - url: workspace.url, - loading: workspace.loading, - canGoBack: workspace.canGoBack, - canGoForward: workspace.canGoForward - })) - ]) - ), - pagesByWorkspace: Object.fromEntries( - Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ - workspaceId, - pages.map((page) => ({ - id: page.id, - title: page.title, - url: page.url, - loading: page.loading, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward - })) - ]) - ) - }) -} - -function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string { - return JSON.stringify( - Object.fromEntries( - Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) - ) - ) -} - -function serializeRuntimeMobileAgentStatusEntry( - paneKey: string, - entry: AppState['agentStatusByPaneKey'][string] -): string { - return JSON.stringify({ - paneKey, - entryPaneKey: entry.paneKey, - state: entry.state, - workingMode: entry.workingMode ?? null, - prompt: entry.prompt, - updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), - stateStartedAt: entry.stateStartedAt, - agentType: entry.agentType ?? null, - terminalTitle: entry.terminalTitle ?? null, - stateHistory: entry.stateHistory.map((history) => ({ - state: history.state, - prompt: history.prompt, - startedAt: history.startedAt, - interrupted: history.interrupted ?? null - })), - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. - interactivePrompt: entry.interactivePrompt ?? null, - lastAssistantMessage: entry.lastAssistantMessage ?? null, - lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, - interrupted: entry.interrupted ?? null - }) -} - -function buildRuntimeMobileAgentStatusProjection( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] -): string { - if (cachedAgentStatusProjection?.source === agentStatusByPaneKey) { - return cachedAgentStatusProjection.projection - } - - // Why per-entry: a status ping replaces one entry and re-spreads the map, so - // without this every other live agent — each carrying a 20-entry history and an - // 8 KB message — is re-serialized to discover it did not change. - const previousEntries = cachedAgentStatusProjection?.entries - const entries = new Map() - const parts: string[] = [] - - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => - a.localeCompare(b) - )) { - const previous = previousEntries?.get(paneKey) - const cached = - previous?.entry === entry - ? previous - : { entry, projection: serializeRuntimeMobileAgentStatusEntry(paneKey, entry) } - entries.set(paneKey, cached) - parts.push(cached.projection) - } - - const projection = `[${parts.join(',')}]` - cachedAgentStatusProjection = { source: agentStatusByPaneKey, entries, projection } - return projection -} - -export function buildRuntimeMobileAgentStatusProjectionForTests( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] -): string { - return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey) -} - -export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS = - AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS - -export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void { - cachedAgentStatusProjection = null -} - -export function runtimeMobileSessionSyncKeysEqual( - a: RuntimeMobileSessionSyncKey, - b: RuntimeMobileSessionSyncKey -): boolean { - return ( - a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && - a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && - a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && - a.folderWorkspaces === b.folderWorkspaces && - a.groupsByWorktree === b.groupsByWorktree && - a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && - a.layoutByWorktree === b.layoutByWorktree && - a.unifiedTabsByWorktree === b.unifiedTabsByWorktree && - a.tabBarOrderByWorktree === b.tabBarOrderByWorktree && - a.activeFileId === b.activeFileId && - a.activeFileIdByWorktree === b.activeFileIdByWorktree && - a.activeTabType === b.activeTabType && - a.activeTabTypeByWorktree === b.activeTabTypeByWorktree && - a.activeTabId === b.activeTabId && - a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree && - a.agentStatusEpoch === b.agentStatusEpoch && - a.agentStatusProjection === b.agentStatusProjection && - a.generatedTabTitlesEnabled === b.generatedTabTitlesEnabled && - a.systemPrefersDark === b.systemPrefersDark && - a.terminalThemeProjection === b.terminalThemeProjection && - a.tabsProjection === b.tabsProjection && - a.openFilesProjection === b.openFilesProjection && - a.browserProjection === b.browserProjection && - a.editorDraftsProjection === b.editorDraftsProjection - ) -} - -async function syncRuntimeGraph(): Promise { - if (!syncEnabled || !getStoreState) { - return - } - // Why: can't import the store directly (terminal slice imports this module); inject the getter to break the construction cycle. - const state = getStoreState() - const systemPrefersDark = getSystemPrefersDark() - const ambiguousTerminalTabIds = collectAmbiguousTerminalTabIds(state.tabsByWorktree) - // Why: build lookup maps once per sync instead of re-flattening every worktree's tabs for each registered terminal. - const terminalTabsByWorktree = new Map>() - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - const tabsById = new Map() - for (const tab of tabs) { - // Duplicate ids in one worktree are malformed persisted state; don't - // guess which PTY a mounted surface owns. - if (tabsById.has(tab.id)) { - tabsById.delete(tab.id) - continue - } - tabsById.set(tab.id, tab) - } - terminalTabsByWorktree.set(worktreeId, tabsById) - } - const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true - const mobileSessionTabs = buildMobileSessionTabSnapshots( - state, - systemPrefersDark, - ambiguousTerminalTabIds - ) - const publication = partitionMobileSessionPublication(mobileSessionTabs) - const graph: RuntimeRendererSyncWindowGraph = { - tabs: [], - leaves: [], - rendererGeneration: mobileSessionPublicationEpoch, - mobileSessionTabs: publication.changed, - unchangedMobileSessionWorktrees: publication.unchangedWorktrees - } - - for (const [registrationKey, registeredTab] of registeredTabs) { - if (ambiguousTerminalTabIds.has(registeredTab.tabId)) { - continue - } - const tab = terminalTabsByWorktree.get(registeredTab.worktreeId)?.get(registeredTab.tabId) - if (!tab) { - continue - } - if (isWebOnlyMirroredTerminalTab(tab, state.terminalLayoutsByTabId[registeredTab.tabId])) { - continue - } - - const manager = registeredTab.getManager() - const container = registeredTab.getContainer() - const activePaneId = manager?.getActivePane()?.id ?? null - const root = - container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null - - graph.tabs.push({ - tabId: registeredTab.tabId, - worktreeId: registeredTab.worktreeId, - title: resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled), - activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null), - layout: serializePaneTree(root) - }) - - const savedPtyIdsByLeafId = - state.terminalLayoutsByTabId[registeredTab.tabId]?.ptyIdsByLeafId ?? {} - for (const pane of manager?.getPanes() ?? []) { - const leafId = pane.leafId - const ptyId = registeredTab.getPtyIdForPane(pane.id) - const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null - const registeredTime = tabRegisteredAt.get(registrationKey) ?? 0 - if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) { - warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', { - tabId: registeredTab.tabId, - worktreeId: registeredTab.worktreeId, - leafId, - paneId: pane.id, - ptyId: savedPtyId - }) - } - const paneTitles = state.runtimePaneTitlesByTabId[registeredTab.tabId] ?? {} - graph.leaves.push({ - tabId: registeredTab.tabId, - worktreeId: registeredTab.worktreeId, - leafId, - paneRuntimeId: pane.id, - ptyId, - paneTitle: paneTitles[pane.id] ?? null, - title: resolveRuntimeTerminalTitle( - tab, - generatedTitlesEnabled, - state.runtimePaneTitlesByTabId[registeredTab.tabId]?.[pane.id] ?? tab.title - ) - }) - } - } - - // Why: inactive automation tabs never mount a TerminalPane; publish their leaf+ptyId from persisted layout (gated on a live buffer) or the live PTY looks orphaned. - // Cold-parked tabs are the same shape with a different liveness proof: their - // pane is unmounted but a parked watcher still owns the PTY. Dropping their - // leaf invalidates the terminal handle every paired subscriber is bound to, - // stalling a remotely-driven terminal the host merely stopped displaying. - // Built once for the whole publication; a per-leaf registry scan would be - // quadratic across a many-worktree workspace. - const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds() - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - for (const tab of tabs) { - if (ambiguousTerminalTabIds.has(tab.id)) { - continue - } - const layout = state.terminalLayoutsByTabId[tab.id] - if ( - hasRegisteredRuntimeTerminalTab(tab.id, worktreeId) || - isWebOnlyMirroredTerminalTab(tab, layout) - ) { - continue - } - const savedPtyIdsByLeafId = layout?.ptyIdsByLeafId - if (!savedPtyIdsByLeafId) { - continue - } - const liveLeaves = Object.entries(savedPtyIdsByLeafId).filter( - ([leafId, ptyId]) => - typeof ptyId === 'string' && - ptyId.length > 0 && - isTerminalLeafId(leafId) && - (Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId)) - ) - if (liveLeaves.length === 0) { - continue - } - const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled) - // Why: partial coverage is legitimate — one leaf's watcher can be disposed - // while its siblings stay parked — so a saved activeLeafId that did not - // survive the filter would name a leaf this publication never sends. - const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId)) - const savedActiveLeafId = layout?.activeLeafId - const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id) - const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {} - graph.tabs.push({ - tabId: tab.id, - worktreeId, - title, - activeLeafId: - savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId) - ? savedActiveLeafId - : liveLeaves[0][0], - layout: resolveTerminalLayoutRoot({ - authoritativeRoot: layout?.root, - leafIds: liveLeaves.map(([leafId]) => leafId), - onSynthesize: (leafCount) => - console.warn( - `[sync-runtime-graph] synthesized layout for ${leafCount} unmounted leaves with no saved tree` - ) - }) - }) - liveLeaves.forEach(([leafId, ptyId], index) => { - // Why the watcher's id wins: PaneManager ids are allocated monotonically - // and closing a pane retires its id without renumbering, so an ordinal - // can name a pane that no longer exists — and main routes split/close and - // the paneKey fallback through this value. - const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId) - graph.leaves.push({ - tabId: tab.id, - worktreeId, - leafId, - paneRuntimeId: parkedPaneId ?? index + 1, - ptyId, - // Why not null: the parked byte watcher keeps writing this pane's - // runtime title, and main prefers leaf.paneTitle over its own older - // lastOscTitle. Dropping it would pin a parked agent pane to a stale - // title for as long as it stays parked. - paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null, - title - }) - }) - } - } - - try { - const result = await window.api.runtime.syncWindowGraph(graph) - // Why: only an acknowledged publication may be treated as delivered. A throw - // leaves the memo behind, so the retry resends every worktree in full. - commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) - const currentState = getStoreState() - currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) - for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { - if (currentState) { - applyNativeChatLaunchDraftResolved(currentState, { - type: 'nativeChatLaunchDraftResolved', - ...resolution - }) - } - } - if (result?.mobileSessionResyncWorktrees?.length) { - scheduleRuntimeGraphSync() - } - } catch (error) { - console.error('[runtime] Failed to sync renderer graph:', error) - } -} - -function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { - changed: RuntimeMobileSessionTabsSnapshot[] - unchangedWorktrees: string[] -} { - const changed: RuntimeMobileSessionTabsSnapshot[] = [] - const unchangedWorktrees: string[] = [] - for (const snapshot of snapshots) { - // Why: buildMobileSessionTabSnapshots returns the cached object for a worktree - // it did not rebuild, so identity — not a deep compare — settles this. - if (publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { - unchangedWorktrees.push(snapshot.worktree) - } else { - changed.push(snapshot) - } - } - return { changed, unchangedWorktrees } -} - -function commitMobileSessionPublication( - snapshots: RuntimeMobileSessionTabsSnapshot[], - resyncWorktrees: string[] | undefined -): void { - const published = new Set() - for (const snapshot of snapshots) { - published.add(snapshot.worktree) - publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) - } - for (const worktreeId of publishedMobileSessionSnapshotByWorktree.keys()) { - if (!published.has(worktreeId)) { - publishedMobileSessionSnapshotByWorktree.delete(worktreeId) - } - } - // Why: main dropped these after acknowledging them, so forget the delivery and - // let the scheduled resync republish them in full. - for (const worktreeId of resyncWorktrees ?? []) { - publishedMobileSessionSnapshotByWorktree.delete(worktreeId) - } -} - -function narrowRecordByKeys( - source: Record | undefined, - keys: readonly string[] -): ReadonlyMap { - if (!source || keys.length === 0) { - return EMPTY_NARROWED_BY_KEY - } - let narrowed: Map | null = null - for (const key of keys) { - const value = source[key] - if (value === undefined) { - continue - } - narrowed ??= new Map() - narrowed.set(key, value) - } - return narrowed ?? EMPTY_NARROWED_BY_KEY -} - -function narrowMapByKeys( - source: ReadonlyMap, - keys: readonly string[] -): ReadonlyMap { - if (source.size === 0 || keys.length === 0) { - return EMPTY_NARROWED_BY_KEY - } - let narrowed: Map | null = null - for (const key of keys) { - if (!source.has(key)) { - continue - } - narrowed ??= new Map() - narrowed.set(key, source.get(key) as T) - } - return narrowed ?? EMPTY_NARROWED_BY_KEY -} - -function getMobileTerminalTheme( - state: AppState, - systemPrefersDark: boolean -): RuntimeMobileTerminalTheme | undefined { - // Why: resolving per terminal tab allocated a fresh theme per surface; one instance per publication is byte-identical downstream. - if ( - hasCachedMobileTerminalTheme && - cachedMobileTerminalThemeSettings === state.settings && - cachedMobileTerminalThemeSystemPrefersDark === systemPrefersDark - ) { - return cachedMobileTerminalTheme - } - cachedMobileTerminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) - cachedMobileTerminalThemeSettings = state.settings - cachedMobileTerminalThemeSystemPrefersDark = systemPrefersDark - hasCachedMobileTerminalTheme = true - return cachedMobileTerminalTheme -} - -function buildMobileSessionAgentStatusByWorktree( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'], - tabsByWorktree: AppState['tabsByWorktree'] -): MobileSessionAgentStatusByWorktree { - const byWorktreeId = new Map>() - const paneKeys = Object.keys(agentStatusByPaneKey) - if (paneKeys.length === 0) { - return byWorktreeId - } - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - for (const paneKey of paneKeys) { - // Why: every key a builder can look up is makePaneKey output, so an unparseable key is unreachable state, not a missed input. - const tabId = parsePaneKey(paneKey)?.tabId - const worktreeId = tabId === undefined ? undefined : worktreeIdByTabId.get(tabId) - if (worktreeId === undefined) { - continue - } - let bucket = byWorktreeId.get(worktreeId) - if (!bucket) { - bucket = new Map() - byWorktreeId.set(worktreeId, bucket) - } - bucket.set(paneKey, agentStatusByPaneKey[paneKey]) - } - return byWorktreeId -} - -function buildMobileSessionWorktreeInputs( - state: AppState, - worktreeId: string, - publication: MobileSessionPublicationInputs, - ambiguousTerminalTabIds: ReadonlySet -): MobileSessionWorktreeInputs { - // Legacy layout/title maps are keyed only by tab id. Omit ambiguous ids until - // hydration repairs ownership instead of publishing one worktree's metadata for another. - const sourceTerminalTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TERMINAL_TABS - // Preserve the source reference when there is nothing to filter; the mobile - // snapshot cache uses this identity to avoid rebuilding on title ticks. - const terminalTabs = sourceTerminalTabs.some((tab) => ambiguousTerminalTabIds.has(tab.id)) - ? sourceTerminalTabs.filter((tab) => !ambiguousTerminalTabIds.has(tab.id)) - : sourceTerminalTabs - const terminalTabIds = terminalTabs.map((tab) => tab.id) - const terminalLayoutByTabId = narrowRecordByKeys(state.terminalLayoutsByTabId, terminalTabIds) - const mountedSurfaceCaptureByTabId = captureMountedTerminalSurfaces( - terminalTabs, - state.terminalLayoutsByTabId, - worktreeId - ) - const browserWorkspaces = - publication.browserTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_BROWSER_WORKSPACES - const pagesByBrowserWorkspaceId = narrowRecordByKeys( - state.browserPagesByWorkspace, - browserWorkspaces.map((workspace) => workspace.id) - ) - const browserPageIds: string[] = [] - for (const pages of pagesByBrowserWorkspaceId.values()) { - for (const page of pages) { - browserPageIds.push(page.id) - } - } - const openFilesById = publication.openFileIndexes.byWorktreeAndId.get(worktreeId) - const openFileIds = - publication.openFileIndexes.idsByWorktree.get(worktreeId) ?? EMPTY_WORKTREE_OPEN_FILE_IDS - // Why: the global activeFileId/activeTabType fallbacks only matter when the active file lives here, so resolve them per worktree. - const resolvedActiveFileId = state.activeFileIdByWorktree?.[worktreeId] ?? state.activeFileId - const activeEditorFileId = - resolvedActiveFileId && openFilesById?.has(resolvedActiveFileId) ? resolvedActiveFileId : null - const activeTabId = state.activeTabId - return { - worktreeId, - terminalTabs, - browserWorkspaces, - unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_UNIFIED_TABS, - groups: state.groupsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TAB_GROUPS, - tabBarOrder: state.tabBarOrderByWorktree[worktreeId], - activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, - tabGroupLayout: (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], - openFilesById, - openFileIds, - terminalLayoutByTabId, - paneTitlesByTabId: narrowRecordByKeys(state.runtimePaneTitlesByTabId, terminalTabIds), - launchDraftByPaneKey: buildMobileLaunchDraftsByPaneKey({ - terminalTabs, - terminalLayoutByTabId, - mountedSurfaceCaptureByTabId, - launchDraftByTabId: narrowRecordByKeys(state.nativeChatLaunchDraftByTabId, terminalTabIds) - }), - agentStatusByPaneKey: - publication.agentStatusByWorktreeId.get(worktreeId) ?? EMPTY_NARROWED_BY_KEY, - editorDraftVersionByFileId: narrowMapByKeys( - publication.editorDraftVersionByFileId, - openFileIds - ), - pagesByBrowserWorkspaceId, - certificateFailureByBrowserPageId: narrowRecordByKeys( - state.browserCertificateFailuresByPageId, - browserPageIds - ), - activeEditorFileId, - activeEditorTabType: activeEditorFileId - ? (state.activeTabTypeByWorktree?.[worktreeId] ?? state.activeTabType) - : null, - activeTerminalTabId: - activeTabId !== null && terminalTabIds.includes(activeTabId) ? activeTabId : null, - activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, - generatedTitlesEnabled: publication.generatedTitlesEnabled, - terminalTheme: publication.terminalTheme, - mountedSurfaceCaptureByTabId - } -} - -function captureMountedTerminalSurfaces( - terminalTabs: AppState['tabsByWorktree'][string], - terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'], - worktreeId: string -): ReadonlyMap { - let captures: Map | null = null - for (const tab of terminalTabs) { - const registered = findRegisteredTerminalTab(tab.id, worktreeId)?.tab - if (!registered) { - continue - } - captures ??= new Map() - captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) - } - return captures ?? EMPTY_NARROWED_BY_KEY -} - -function captureMountedTerminalSurface( - registered: RegisteredTerminalTab, - savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): MountedTerminalSurfaceCapture { - const manager = registered.getManager() - const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] - const activePane = manager?.getActivePane() ?? null - const firstChild = registered.getContainer()?.firstElementChild - // Why: mirrors getRuntimeLeafIdsForTerminal so every leaf the builder resolves has a captured pane id. - const effectiveLeafIds = - paneLeafIds.length > 0 - ? paneLeafIds - : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) - const numericPaneIdByLeafId = new Map() - const ptyIdByNumericPaneId = new Map() - for (const leafId of effectiveLeafIds) { - const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null - numericPaneIdByLeafId.set(leafId, numericPaneId) - if (numericPaneId !== null) { - ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) - } - } - return { - paneLeafIds, - hasLiveActivePane: activePane !== null, - liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, - liveLayoutRoot: serializePaneTree( - typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null - ), - numericPaneIdByLeafId, - ptyIdByNumericPaneId, - tabWideAgentHintLeafId: registered.getTabWideAgentHintLeafId() - } -} - -function mountedTerminalSurfaceCaptureEquals( - a: MountedTerminalSurfaceCapture, - b: MountedTerminalSurfaceCapture -): boolean { - return ( - a.hasLiveActivePane === b.hasLiveActivePane && - a.liveActiveLeafId === b.liveActiveLeafId && - a.paneLeafIds.length === b.paneLeafIds.length && - a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && - narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && - narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && - a.tabWideAgentHintLeafId === b.tabWideAgentHintLeafId && - // Why: serialization allocates a fresh tree per capture, so it compares by content. - jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) - ) -} - -function mountedSurfaceCapturesEqual( - a: ReadonlyMap, - b: ReadonlyMap -): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [tabId, capture] of a) { - const other = b.get(tabId) - if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { - return false - } - } - return true -} - -function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [key, value] of a) { - if (b.get(key) !== value) { - return false - } - } - return true -} - -/** - * True when a worktree's snapshot can be reused without rebuilding its content. - * - * Every field of `MobileSessionWorktreeInputs` is compared, so a missed input - * is a compile error rather than a stale publication to paired clients. - */ -function canReuseMobileSessionSnapshot( - previous: MobileSessionWorktreeInputs, - next: MobileSessionWorktreeInputs -): boolean { - return ( - previous.worktreeId === next.worktreeId && - previous.terminalTabs === next.terminalTabs && - previous.browserWorkspaces === next.browserWorkspaces && - previous.unifiedTabs === next.unifiedTabs && - previous.groups === next.groups && - previous.tabBarOrder === next.tabBarOrder && - previous.activeGroupId === next.activeGroupId && - previous.tabGroupLayout === next.tabGroupLayout && - previous.openFilesById === next.openFilesById && - previous.openFileIds === next.openFileIds && - previous.activeEditorFileId === next.activeEditorFileId && - previous.activeEditorTabType === next.activeEditorTabType && - previous.activeTerminalTabId === next.activeTerminalTabId && - previous.activeBrowserWorkspaceId === next.activeBrowserWorkspaceId && - previous.generatedTitlesEnabled === next.generatedTitlesEnabled && - previous.terminalTheme === next.terminalTheme && - narrowedEntriesEqual(previous.terminalLayoutByTabId, next.terminalLayoutByTabId) && - narrowedEntriesEqual(previous.paneTitlesByTabId, next.paneTitlesByTabId) && - narrowedEntriesEqual(previous.launchDraftByPaneKey, next.launchDraftByPaneKey) && - narrowedEntriesEqual(previous.agentStatusByPaneKey, next.agentStatusByPaneKey) && - narrowedEntriesEqual(previous.editorDraftVersionByFileId, next.editorDraftVersionByFileId) && - narrowedEntriesEqual(previous.pagesByBrowserWorkspaceId, next.pagesByBrowserWorkspaceId) && - narrowedEntriesEqual( - previous.certificateFailureByBrowserPageId, - next.certificateFailureByBrowserPageId - ) && - // Why: live DOM/PaneManager state is invisible to store references; the - // capture makes it comparable instead of forcing mounted worktrees to rebuild. - mountedSurfaceCapturesEqual( - previous.mountedSurfaceCaptureByTabId, - next.mountedSurfaceCaptureByTabId - ) - ) -} - -export function buildMobileSessionTabSnapshots( - state: AppState, - systemPrefersDark = getSystemPrefersDark(), - ambiguousTerminalTabIds: ReadonlySet = collectAmbiguousTerminalTabIds( - state.tabsByWorktree - ) -): RuntimeMobileSessionTabsSnapshot[] { - // Why: high-frequency title ticks fire mobile sync; cache indexes/hashes by store-slice ref to skip rescanning editor state. - const openFileIndexes = getOpenFileIndexes(state.openFiles) - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const publicationInputs: MobileSessionPublicationInputs = { - browserTabsByWorktree, - openFileIndexes, - editorDraftVersionByFileId: getEditorDraftVersionByFileId(state.editorDrafts), - agentStatusByWorktreeId: buildMobileSessionAgentStatusByWorktree( - state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY, - state.tabsByWorktree - ), - generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, - terminalTheme: getMobileTerminalTheme(state, systemPrefersDark) - } - const liveFolderWorkspaceIds = new Set( - (state.folderWorkspaces ?? []).map((workspace) => workspace.id) - ) - const worktreeIds = new Set([ - ...Object.keys(state.tabsByWorktree), - ...Object.keys(state.groupsByWorktree), - ...Object.keys(state.unifiedTabsByWorktree), - ...Object.keys(browserTabsByWorktree), - ...state.openFiles.map((file) => file.worktreeId) - ]) - - const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] - for (const worktreeId of worktreeIds) { - const workspaceScope = parseWorkspaceKey(worktreeId) - if ( - workspaceScope?.type === 'folder' && - !liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId) - ) { - mobileSessionSnapshotCacheByWorktree.delete(worktreeId) - continue - } - const inputs = buildMobileSessionWorktreeInputs( - state, - worktreeId, - publicationInputs, - ambiguousTerminalTabIds - ) - const cached = mobileSessionSnapshotCacheByWorktree.get(worktreeId) - // Why: invalidate before computing — building the maps, projection, and tab - // array first made the cache save the fanout but none of the per-worktree work. - if (cached && canReuseMobileSessionSnapshot(cached.inputs, inputs)) { - snapshots.push(cached.snapshot) - continue - } - const activeGroupId = inputs.activeGroupId - const terminalTabByIdForWorktree = new Map(inputs.terminalTabs.map((tab) => [tab.id, tab])) - const browserWorkspaceByIdForWorktree = new Map( - inputs.browserWorkspaces.map((workspace) => [workspace.id, workspace]) - ) - const unifiedTabByIdForWorktree = new Map(inputs.unifiedTabs.map((tab) => [tab.id, tab])) - const openFilesForWorktree = inputs.openFilesById - const editorIds = inputs.openFileIds.filter((fileId) => { - const file = openFilesForWorktree?.get(fileId) - return file ? isMobilePublishableOpenFile(file) : false - }) - const publishableTerminalIds = [...terminalTabByIdForWorktree.values()] - .filter( - (terminal) => - !isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) - ) - .map((terminal) => terminal.id) - const groupProjection = buildMobileSessionGroupProjection(inputs, { - terminalIds: publishableTerminalIds, - editorIds, - browserIds: [...browserWorkspaceByIdForWorktree.values()] - .filter((workspace) => isMobilePublishableBrowserWorkspace(workspace)) - .map((workspace) => workspace.id) - }) - const tabs: RuntimeMobileSessionSnapshotTab[] = [] - const emittedEditorFileIds = new Set() - const emittedEditorTabIds = new Set() - - for (const item of groupProjection.order) { - if (item.type === 'terminal') { - const terminal = terminalTabByIdForWorktree.get(item.id) - if (!terminal) { - continue - } - if (isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id))) { - continue - } - tabs.push(...buildMobileTerminalSurfaceTabs(inputs, terminal, item.tabId)) - } else if (item.type === 'editor') { - const file = openFilesForWorktree?.get(item.id) - if (!file || !isMobilePublishableOpenFile(file)) { - continue - } - const markdown = buildMobileMarkdownTab( - inputs, - file, - item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined - ) - if (markdown) { - tabs.push(markdown) - } else { - tabs.push( - buildMobileFileTab( - inputs, - file, - item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined - ) - ) - } - emittedEditorFileIds.add(file.id) - emittedEditorTabIds.add(item.tabId ?? item.id) - } else if (item.type === 'browser') { - const workspace = browserWorkspaceByIdForWorktree.get(item.id) - if (!workspace || !isMobilePublishableBrowserWorkspace(workspace)) { - continue - } - tabs.push( - buildMobileBrowserTab( - inputs, - workspace, - item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined - ) - ) - } - } - - // Why: split-group projection can miss plain editor files during hydration; publish them so mobile/web still mirror. - const fallbackEditorTabs: FallbackEditorTabTarget[] = [] - if (openFilesForWorktree) { - const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) - const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId)) - for (const unifiedTab of unifiedEditorTabs) { - if (emittedEditorTabIds.has(unifiedTab.id)) { - continue - } - const file = openFilesForWorktree.get(unifiedTab.entityId) - if (!file || !isMobilePublishableOpenFile(file)) { - continue - } - const markdown = buildMobileMarkdownTab(inputs, file, unifiedTab) - const fallbackTab = markdown ?? buildMobileFileTab(inputs, file, unifiedTab) - tabs.push(fallbackTab) - fallbackEditorTabs.push({ - tabId: fallbackTab.id, - groupId: unifiedTab.groupId - }) - emittedEditorTabIds.add(unifiedTab.id) - } - for (const file of openFilesForWorktree.values()) { - if (!isMobilePublishableOpenFile(file)) { - continue - } - if (emittedEditorFileIds.has(file.id)) { - continue - } - if (unifiedEditorFileIds.has(file.id)) { - emittedEditorFileIds.add(file.id) - continue - } - const markdown = buildMobileMarkdownTab(inputs, file) - const fallbackTab = markdown ?? buildMobileFileTab(inputs, file) - tabs.push(fallbackTab) - fallbackEditorTabs.push({ - tabId: fallbackTab.id, - groupId: null - }) - emittedEditorFileIds.add(file.id) - } - } - - const active = tabs.find((tab) => tab.isActive) ?? null - const tabGroups = appendFallbackEditorTabsToGroups( - groupProjection.tabGroups, - inputs.groups, - activeGroupId, - fallbackEditorTabs, - active?.id ?? null - ) - const tabGroupLayout = - tabGroups && tabGroups.length > 0 - ? pruneTabGroupLayout(inputs.tabGroupLayout, new Set(tabGroups.map((group) => group.id))) - : groupProjection.tabGroupLayout - const content = { - activeGroupId, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - ...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}), - ...(tabGroupLayout ? { tabGroupLayout } : {}), - tabs - } - // Why: main suppresses per-worktree fanout on an unchanged (epoch, version) - // pair, so reuse the cached version for structurally-identical content — - // the backstop for inputs that churn by reference without changing output. - // The counter only ever advances, so a later real change still outranks it. - const candidateVersion = ++mobileSessionSnapshotVersion - if (cached && jsonContentEquals(cached.content, content)) { - mobileSessionSnapshotCacheByWorktree.set(worktreeId, { - inputs, - content, - snapshot: cached.snapshot - }) - snapshots.push(cached.snapshot) - continue - } - const snapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: worktreeId, - publicationEpoch: mobileSessionPublicationEpoch, - snapshotVersion: candidateVersion, - ...content - } - mobileSessionSnapshotCacheByWorktree.set(worktreeId, { inputs, content, snapshot }) - snapshots.push(snapshot) - } - - for (const worktreeId of mobileSessionSnapshotCacheByWorktree.keys()) { - if (!worktreeIds.has(worktreeId)) { - mobileSessionSnapshotCacheByWorktree.delete(worktreeId) - } - } - - return snapshots -} - -function isEditorSurfaceTab(tab: Pick): boolean { - // Why: mobile can mirror ordinary edit/diff files; conflict-review and check-details tabs need metadata this contract lacks. - return tab.contentType === 'editor' || tab.contentType === 'diff' -} - -function getEditorUnifiedTabsForWorktree( - inputs: Pick -): Tab[] { - return inputs.unifiedTabs.filter(isEditorSurfaceTab) -} - -function applyUnifiedEditorTabIdsToLegacyOrder( - order: readonly VisibleTabRef[], - inputs: Pick -): VisibleTabRef[] { - const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) - if (unifiedEditorTabs.length === 0) { - return [...order] - } - const firstUnifiedTabByFileId = new Map() - for (const tab of unifiedEditorTabs) { - if (!firstUnifiedTabByFileId.has(tab.entityId)) { - firstUnifiedTabByFileId.set(tab.entityId, tab.id) - } - } - return order.map((item) => { - if (item.type !== 'editor' || item.tabId) { - return item - } - const tabId = firstUnifiedTabByFileId.get(item.id) - return tabId ? { ...item, tabId } : item - }) -} - -function appendFallbackEditorTabsToGroups( - tabGroups: RuntimeMobileSessionTabGroup[] | undefined, - sourceGroups: readonly TabGroup[], - activeGroupId: string | null, - fallbackTabs: readonly FallbackEditorTabTarget[], - activeTabId: string | null -): RuntimeMobileSessionTabGroup[] | undefined { - if (fallbackTabs.length === 0) { - return tabGroups - } - const result = [...(tabGroups ?? [])] - const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group])) - const groupIndexById = new Map(result.map((group, index) => [group.id, index])) - const firstTargetGroupId = - result[0]?.id ?? - (activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ?? - sourceGroups[0]?.id ?? - null - const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId)) - - for (const fallback of fallbackTabs) { - const targetGroupId = - fallback.groupId ?? - (activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId)) - ? activeGroupId - : firstTargetGroupId) - if (!targetGroupId) { - continue - } - let targetIndex = groupIndexById.get(targetGroupId) - if (targetIndex === undefined) { - const sourceGroup = sourceGroupsById.get(targetGroupId) - const group: RuntimeMobileSessionTabGroup = { - id: targetGroupId, - activeTabId: sourceGroup?.activeTabId ?? null, - tabOrder: [], - recentTabIds: sourceGroup?.recentTabIds ?? [] - } - targetIndex = result.length - groupIndexById.set(targetGroupId, targetIndex) - result.push(group) - } - const group = result[targetIndex]! - if (!group.tabOrder.includes(fallback.tabId)) { - result[targetIndex] = { - ...group, - tabOrder: [...group.tabOrder, fallback.tabId] - } - } - } - - if (result.length === 0) { - return tabGroups - } - - const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null - - return result.map((group) => { - const tabOrder = [...group.tabOrder] - const tabOrderSet = new Set(tabOrder) - const activeFallbackTabIdForGroup = - activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null - const activeTabIdForGroup = - activeFallbackTabIdForGroup ?? - (group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null) - const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId)) - if ( - activeFallbackTabId && - tabOrderSet.has(activeFallbackTabId) && - !recentTabIds.includes(activeFallbackTabId) - ) { - recentTabIds.push(activeFallbackTabId) - } - return { - ...group, - activeTabId: activeTabIdForGroup, - tabOrder, - recentTabIds - } - }) -} - -function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null -} - -function isWebOnlyMirroredTerminalTab( - tab: Pick[number], 'id' | 'ptyId'>, - layout: AppState['terminalLayoutsByTabId'][string] | undefined -): boolean { - if (!isWebTerminalSurfaceTabId(tab.id)) { - return false - } - const layoutPtyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) - const ptyIds = [tab.ptyId, ...layoutPtyIds].filter( - (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 - ) - // Why: only-remote/no-PTY tabs are web mirrors, not host state; legacy local-PTY tabs still publish for desktop/web parity. - return ptyIds.every(isRemoteRuntimePtyId) -} - -function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { - if (cachedOpenFileIndexesSource === openFiles && cachedOpenFileIndexes) { - return cachedOpenFileIndexes - } - - const byWorktreeAndId: OpenFileByWorktreeAndId = new Map() - const idsByWorktree = new Map() - for (const file of openFiles) { - let filesById = byWorktreeAndId.get(file.worktreeId) - if (!filesById) { - filesById = new Map() - byWorktreeAndId.set(file.worktreeId, filesById) - } - let ids = idsByWorktree.get(file.worktreeId) - if (!ids) { - ids = [] - idsByWorktree.set(file.worktreeId, ids) - } - if (!filesById.has(file.id)) { - filesById.set(file.id, file) - ids.push(file.id) - } - } - - cachedOpenFileIndexesSource = openFiles - cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree } - return cachedOpenFileIndexes -} - -function collectTabGroupLayoutIds(layout: TabGroupLayoutNode | undefined): string[] { - const result: string[] = [] - const visit = (node: TabGroupLayoutNode | undefined): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - result.push(node.groupId) - return - } - visit(node.first) - visit(node.second) - } - visit(layout) - return result -} - -function pruneTabGroupLayout( - layout: TabGroupLayoutNode | undefined, - validGroupIds: ReadonlySet -): TabGroupLayoutNode | null { - if (!layout) { - return null - } - if (layout.type === 'leaf') { - return validGroupIds.has(layout.groupId) ? layout : null - } - const first = pruneTabGroupLayout(layout.first, validGroupIds) - const second = pruneTabGroupLayout(layout.second, validGroupIds) - if (first && second) { - return { ...layout, first, second } - } - return first ?? second -} - -function getOrderedTabGroups( - groups: readonly TabGroup[], - layout: TabGroupLayoutNode | undefined -): TabGroup[] { - const byId = new Map(groups.map((group) => [group.id, group])) - const seen = new Set() - const ordered: TabGroup[] = [] - for (const groupId of collectTabGroupLayoutIds(layout)) { - const group = byId.get(groupId) - if (!group || seen.has(group.id)) { - continue - } - seen.add(group.id) - ordered.push(group) - } - for (const group of groups) { - if (!seen.has(group.id)) { - ordered.push(group) - } - } - return ordered -} - -// Why: getActiveTabNavOrder only reads the [worktreeId] entry of each slice, so a single-key view keeps this path off AppState. -function buildLegacyNavOrderView( - inputs: MobileSessionWorktreeInputs -): Parameters[0] { - const { worktreeId } = inputs - return { - activeGroupIdByWorktree: inputs.activeGroupId ? { [worktreeId]: inputs.activeGroupId } : {}, - groupsByWorktree: { [worktreeId]: inputs.groups }, - unifiedTabsByWorktree: { [worktreeId]: inputs.unifiedTabs }, - tabBarOrderByWorktree: inputs.tabBarOrder ? { [worktreeId]: inputs.tabBarOrder } : {}, - tabsByWorktree: { [worktreeId]: inputs.terminalTabs }, - openFiles: inputs.openFilesById ? [...inputs.openFilesById.values()] : [], - browserTabsByWorktree: { [worktreeId]: inputs.browserWorkspaces } - } -} - -function buildMobileSessionGroupProjection( - inputs: MobileSessionWorktreeInputs, - ids: { - terminalIds: string[] - editorIds: string[] - browserIds: string[] - } -): { - order: VisibleTabRef[] - tabGroups?: RuntimeMobileSessionTabGroup[] - tabGroupLayout?: TabGroupLayoutNode | null -} { - const groups = inputs.groups - if (groups.length === 0) { - return { - order: applyUnifiedEditorTabIdsToLegacyOrder( - getActiveTabNavOrder(buildLegacyNavOrderView(inputs), inputs.worktreeId, { - editorIds: ids.editorIds - }), - inputs - ) - } - } - - const terminalIds = new Set(ids.terminalIds) - const editorIds = new Set(ids.editorIds) - const browserIds = new Set(ids.browserIds) - const tabs = inputs.unifiedTabs - const order: VisibleTabRef[] = [] - const tabGroups: RuntimeMobileSessionTabGroup[] = [] - - for (const group of getOrderedTabGroups(groups, inputs.tabGroupLayout)) { - const groupTabs = tabs.filter((tab) => tab.groupId === group.id) - const visibleOrder = getGroupVisibleTabOrder( - group, - groupTabs, - terminalIds, - editorIds, - browserIds, - new Set(), - true - ) - if (visibleOrder.length === 0) { - continue - } - const tabOrder = visibleOrder.map((item) => item.tabId ?? item.id) - const tabOrderSet = new Set(tabOrder) - // Why: persisted split groups can have very large tab orders; append iteratively to avoid V8's argument-list limit. - for (const item of visibleOrder) { - order.push(item) - } - tabGroups.push({ - id: group.id, - activeTabId: - group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null, - tabOrder, - recentTabIds: group.recentTabIds?.filter((tabId) => tabOrderSet.has(tabId)) ?? [] - }) - } - - const validGroupIds = new Set(tabGroups.map((group) => group.id)) - return { - order, - tabGroups, - tabGroupLayout: pruneTabGroupLayout(inputs.tabGroupLayout, validGroupIds) - } -} - -function getEditorDraftVersionByFileId( - editorDrafts: AppState['editorDrafts'] -): Map { - if (cachedEditorDraftsSource === editorDrafts && cachedEditorDraftVersionByFileId) { - return cachedEditorDraftVersionByFileId - } - - const versions = new Map() - for (const [fileId, content] of Object.entries(editorDrafts)) { - versions.set(fileId, stableHashString(content)) - } - cachedEditorDraftsSource = editorDrafts - cachedEditorDraftVersionByFileId = versions - return versions -} - -function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { - return `${parentTabId}::${leafId}` -} - -function hexToRgba(hex: string, alpha: number): string { - let clean = hex.replace('#', '') - if (clean.length === 3) { - clean = clean - .split('') - .map((c) => c + c) - .join('') - } - const r = Number.parseInt(clean.slice(0, 2), 16) - const g = Number.parseInt(clean.slice(2, 4), 16) - const b = Number.parseInt(clean.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - -function isHexColor(value: string | undefined): value is string { - return typeof value === 'string' && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) -} - -function resolveMobileTerminalTheme( - state: AppState, - systemPrefersDark: boolean -): RuntimeMobileTerminalTheme | undefined { - const settings = state.settings - if (!settings) { - return undefined - } - const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) - const resolvedTheme = appearance.theme - ? { ...appearance.theme, ...settings.terminalColorOverrides } - : undefined - if (!resolvedTheme) { - return undefined - } - if (settings.terminalBackgroundOpacity !== undefined && isHexColor(resolvedTheme.background)) { - resolvedTheme.background = hexToRgba( - resolvedTheme.background, - settings.terminalBackgroundOpacity - ) - } - if (settings.terminalCursorOpacity !== undefined && isHexColor(resolvedTheme.cursor)) { - resolvedTheme.cursor = hexToRgba(resolvedTheme.cursor, settings.terminalCursorOpacity) - } - - const theme: Record = {} - for (const [key, value] of Object.entries(resolvedTheme)) { - if (typeof value === 'string') { - theme[key] = value - } - } - return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } -} - -function getRuntimeLeafIdsForTerminal( - capture: MountedTerminalSurfaceCapture | undefined, - savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): readonly string[] { - const liveLeafIds = capture?.paneLeafIds ?? [] - if (liveLeafIds.length > 0) { - return liveLeafIds - } - - const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) - if (persistedLeafIds.length > 0) { - return persistedLeafIds - } - - // Why: a new tab can predate TerminalPane mount; fabricating pane:1 with no live/persisted leaf would go stale after mount. - return [] -} - -function resolveMobileTabWideAgentHintLeafId( - capture: MountedTerminalSurfaceCapture | undefined, - savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): string | null { - if (capture) { - return capture.tabWideAgentHintLeafId - } - return isNativeChatTabWideFallbackSafe(savedLayout) - ? resolveNativeChatActiveLayoutLeafId(savedLayout) - : null -} - -function buildMobileLaunchDraftsByPaneKey(args: { - terminalTabs: AppState['tabsByWorktree'][string] - terminalLayoutByTabId: MobileSessionWorktreeInputs['terminalLayoutByTabId'] - mountedSurfaceCaptureByTabId: MobileSessionWorktreeInputs['mountedSurfaceCaptureByTabId'] - launchDraftByTabId: ReadonlyMap< - string, - NonNullable[string] - > -}): MobileSessionWorktreeInputs['launchDraftByPaneKey'] { - if (args.launchDraftByTabId.size === 0) { - return EMPTY_NARROWED_BY_KEY - } - let draftsByPaneKey: Map< - string, - NonNullable[string] - > | null = null - for (const terminal of args.terminalTabs) { - const draft = args.launchDraftByTabId.get(terminal.id) - if (!draft || draft.resolved) { - continue - } - const capture = args.mountedSurfaceCaptureByTabId.get(terminal.id) - const savedLayout = args.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) - const ownerLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) - const launchAgent = nativeChatLaunchAgentForLeaf({ - launchAgent: terminal.launchAgent, - launchAgentLeafId: ownerLeafId, - leafId: ownerLeafId, - leafIds - }) - if (!launchAgent || launchAgent !== draft.agent || !ownerLeafId) { - continue - } - draftsByPaneKey ??= new Map() - draftsByPaneKey.set(makePaneKey(terminal.id, ownerLeafId), draft) - } - return draftsByPaneKey ?? EMPTY_NARROWED_BY_KEY -} - -function buildMobileTerminalSurfaceTabs( - inputs: MobileSessionWorktreeInputs, - terminal: NonNullable[number], - unifiedTabId?: string -): RuntimeMobileSessionSnapshotTab[] { - const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) - const isDesktopTabActive = unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : inputs.activeTerminalTabId === terminal.id - const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) - const launchAgentLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) - const activeLeafId = capture?.hasLiveActivePane - ? capture.liveActiveLeafId - : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) - const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} - const generatedTitlesEnabled = inputs.generatedTitlesEnabled - const sanitizedSavedLayout = savedLayout - ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal) - : undefined - const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} - const terminalTheme = inputs.terminalTheme - const liveLayoutRoot = capture?.liveLayoutRoot ?? null - const parentLayout = normalizeTerminalLayoutSnapshot({ - // Why: live DOM tree is authoritative when mounted, else the saved tree; synthesize only as a last resort, never re-guess. - root: resolveTerminalLayoutRoot({ - authoritativeRoot: liveLayoutRoot, - existingRoot: sanitizedSavedLayout?.root, - leafIds, - onSynthesize: (leafCount) => - console.warn( - `[sync-runtime-graph] synthesized parentLayout for ${leafCount} leaves with no live or saved tree` - ) - }), - activeLeafId, - expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null, - ...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}), - ...(sanitizedSavedLayout?.titlesByLeafId - ? { titlesByLeafId: sanitizedSavedLayout.titlesByLeafId } - : {}) - } satisfies TerminalLayoutSnapshot).snapshot - return leafIds.map((leafId) => { - const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null - const ptyId = - numericPaneId === null - ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) - : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) - const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null - const paneTitle = - numericPaneId !== null - ? paneTitles[numericPaneId] - : legacyPaneId - ? paneTitles[Number(legacyPaneId)] - : undefined - const leafTitle = paneTitle?.trim() || sanitizedSavedLayout?.titlesByLeafId?.[leafId]?.trim() - const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null - const tabWideFallbackSafe = - isNativeChatTabWideFallbackSafe(parentLayout) && launchAgentLeafId === leafId - const title = tabWideFallbackSafe - ? resolveRuntimeTerminalTitle( - terminal, - generatedTitlesEnabled, - leafTitle ?? terminal.title ?? 'Terminal' - ) - : (leafTitle ?? 'Terminal') - const agentStatusTitle = leafTitle ?? (tabWideFallbackSafe ? terminal.title : '') ?? '' - const agentStatus = - paneKey && !isClaudeManagementTitle(agentStatusTitle) - ? inputs.agentStatusByPaneKey.get(paneKey) - : undefined - const launchAgent = nativeChatLaunchAgentForLeaf({ - launchAgent: terminal.launchAgent, - launchAgentLeafId, - leafId, - leafIds - }) - const launchDraft = paneKey ? inputs.launchDraftByPaneKey.get(paneKey) : undefined - const publishedLaunchDraft = - launchDraft && launchDraft.agent === launchAgent && launchDraft.text.trim() - ? launchDraft - : null - return { - type: 'terminal' as const, - id: mobileTerminalSurfaceId(terminal.id, leafId), - title, - ...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim() - ? { quickCommandLabel: terminal.quickCommandLabel.trim() } - : {}), - parentTabId: terminal.id, - leafId, - ptyId, - ...(terminalTheme ? { terminalTheme } : {}), - ...(agentStatus ? { agentStatus } : {}), - ...(launchAgent ? { launchAgent } : {}), - // Launch context that exists only as an unsent TUI-input draft; mobile - // prefills its chat composer from it (desktop keeps its own seed store). - ...(publishedLaunchDraft - ? { - launchDraft: publishedLaunchDraft.text, - launchDraftCreatedAt: publishedLaunchDraft.createdAt - } - : {}), - parentLayout, - isActive: isDesktopTabActive && leafId === activeLeafId - } - }) -} - -function buildMobileMarkdownTab( - inputs: MobileSessionWorktreeInputs, - file: AppState['openFiles'][number], - unifiedTab?: Tab -): RuntimeMobileSessionMarkdownTab | null { - if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { - return null - } - if (file.language !== 'markdown' && file.mode !== 'markdown-preview') { - return null - } - - const sourceFile = - file.mode === 'markdown-preview' && file.markdownPreviewSourceFileId - ? (inputs.openFilesById?.get(file.markdownPreviewSourceFileId) ?? file) - : file - const draftVersion = inputs.editorDraftVersionByFileId.get(sourceFile.id) - const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' - const unifiedTabId = unifiedTab?.id - - return { - type: 'markdown', - id: unifiedTabId ?? file.id, - title, - filePath: file.filePath, - relativePath: file.relativePath, - language: 'markdown', - mode: file.mode, - isDirty: file.isDirty || sourceFile.isDirty, - isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : isFileActiveEditorSurface(inputs, file), - sourceFileId: sourceFile.id, - sourceFilePath: sourceFile.filePath, - sourceRelativePath: sourceFile.relativePath, - documentVersion: draftVersion ?? `file:${sourceFile.id}`, - color: unifiedTab?.color ?? null, - isPinned: unifiedTab?.isPinned === true - } -} - -function buildMobileFileTab( - inputs: MobileSessionWorktreeInputs, - file: AppState['openFiles'][number], - unifiedTab?: Tab -): RuntimeMobileSessionFileTab { - const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File' - const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined - const unifiedTabId = unifiedTab?.id - - return { - type: 'file', - id: unifiedTabId ?? file.id, - title, - filePath: file.filePath, - relativePath: file.relativePath, - language: file.language, - mode: file.mode === 'diff' ? 'diff' : 'edit', - ...(diffSource ? { diffSource } : {}), - isDirty: file.isDirty, - color: unifiedTab?.color ?? null, - isPinned: unifiedTab?.isPinned === true, - isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : isFileActiveEditorSurface(inputs, file) - } -} - -function isFileActiveEditorSurface( - inputs: Pick, - file: Pick -): boolean { - return inputs.activeEditorTabType === 'editor' && inputs.activeEditorFileId === file.id -} - -function isMobileFileDiffSource( - diffSource: AppState['openFiles'][number]['diffSource'] -): diffSource is 'staged' | 'unstaged' { - return diffSource === 'staged' || diffSource === 'unstaged' -} - -function isMobileUnsupportedCombinedDiffSource( - diffSource: AppState['openFiles'][number]['diffSource'] -): boolean { - return ( - diffSource === 'combined-all' || - diffSource === 'combined-uncommitted' || - diffSource === 'combined-branch' || - diffSource === 'combined-commit' - ) -} - -function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean { - // Why: combined diff tabs use display labels as paths and need the desktop renderer; mobile would mis-call files.read. - return !isMobileUnsupportedCombinedDiffSource(file.diffSource) -} - -/** - * Why a workspace document is held back: it is served to one desktop guest through a grant no - * mobile client holds, so there is nothing on the other side that could render it — and the wire - * has no tab kind for it, so an old client would take it for an ordinary browser tab and offer - * navigation for a page that has no URL. Host and phone parity ships behind capability negotiation. - */ -function isMobilePublishableBrowserWorkspace( - workspace: NonNullable[number] -): boolean { - return !workspace.docLocation -} - -// Why: the store buckets a workspace under its own worktreeId, so this worktree's scoped inputs are the workspace's own scope. -function buildMobileBrowserTab( - inputs: MobileSessionWorktreeInputs, - workspace: NonNullable[number], - unifiedTab?: Tab -): RuntimeMobileSessionBrowserTab { - const pages = inputs.pagesByBrowserWorkspaceId.get(workspace.id) ?? [] - const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null - const title = - activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser' - const unifiedTabId = unifiedTab?.id - - return { - type: 'browser', - id: unifiedTabId ?? workspace.id, - title, - browserWorkspaceId: workspace.id, - browserPageId: activePage?.id ?? workspace.activePageId ?? null, - url: activePage?.url ?? workspace.url ?? 'about:blank', - loading: activePage?.loading ?? workspace.loading, - canGoBack: activePage?.canGoBack ?? workspace.canGoBack, - canGoForward: activePage?.canGoForward ?? workspace.canGoForward, - // Why: null means the active page cleared its failure; ?? would resurrect a stale workspace-level error. - loadError: activePage ? activePage.loadError : workspace.loadError, - certificateFailure: activePage - ? (inputs.certificateFailureByBrowserPageId.get(activePage.id) ?? null) - : null, - color: unifiedTab?.color ?? null, - isPinned: unifiedTab?.isPinned === true, - isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : inputs.activeBrowserWorkspaceId === workspace.id - } -} - -function isUnifiedTabActiveInActiveGroup( - inputs: Pick, - unifiedTabId: string -): boolean { - return inputs.groups.some( - (group) => group.id === inputs.activeGroupId && group.activeTabId === unifiedTabId - ) -} - -function stableHashString(value: string): string { - let hash = 2166136261 - for (let i = 0; i < value.length; i += 1) { - hash ^= value.charCodeAt(i) - hash = Math.imul(hash, 16777619) - } - return `draft:${value.length}:${(hash >>> 0).toString(16)}` -} +setTrailingGraphSyncScheduler(scheduleRuntimeGraphSync) diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts new file mode 100644 index 00000000000..f11e58a388f --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -0,0 +1,77 @@ +import type { AppState } from '@/store/types' +import { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS, graphState } from './graph-state' +import type { AgentStatusProjectionCacheEntry } from './types' + +function serializeAgentStatusEntry( + paneKey: string, + entry: AppState['agentStatusByPaneKey'][string] +): string { + return JSON.stringify({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + workingMode: entry.workingMode ?? null, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + // Include the prompt so a newly captured question re-fires mobile publication. + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, + interrupted: entry.interrupted ?? null + }) +} + +export function buildRuntimeMobileAgentStatusProjection( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] +): string { + const cached = graphState.cachedAgentStatusProjection + if (cached?.source === agentStatusByPaneKey) { + return cached.projection + } + + // A status ping replaces one entry and re-spreads the map; reuse every other entry. + const entries = new Map() + const parts: string[] = [] + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => + a.localeCompare(b) + )) { + const previous = cached?.entries.get(paneKey) + const entryCache = + previous?.entry === entry + ? previous + : { entry, projection: serializeAgentStatusEntry(paneKey, entry) } + entries.set(paneKey, entryCache) + parts.push(entryCache.projection) + } + const projection = `[${parts.join(',')}]` + graphState.cachedAgentStatusProjection = { + source: agentStatusByPaneKey, + entries, + projection + } + return projection +} + +export function buildRuntimeMobileAgentStatusProjectionForTests( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] +): string { + return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey) +} + +export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS = + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS + +export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void { + graphState.cachedAgentStatusProjection = null +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts new file mode 100644 index 00000000000..c2a22267344 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -0,0 +1,246 @@ +import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher' +import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' +import { + collectParkedTerminalWatcherPtyIds, + getParkedTerminalWatcherPaneIdsByPtyId +} from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { serializePaneTree } from '@/components/terminal-pane/layout-serialization' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import type { + RuntimeMobileSessionTabsSnapshot, + RuntimeRendererSyncWindowGraph +} from '../../../../shared/runtime-types' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { applyNativeChatLaunchDraftResolved } from '../native-chat-launch-draft-runtime-resolution' +import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' +import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' +import { isWebOnlyMirroredTerminalTab } from './mobile-session-tab-helpers' +import { resolveRuntimeTerminalTitle } from './sync-projections' +import { + collectAmbiguousTerminalTabIds, + findRegisteredTerminalTab, + graphState, + mobilePublicationEpoch, + NO_TRANSPORT_GRACE_MS +} from './graph-state' + +export async function syncRuntimeGraph(): Promise { + if (!graphState.syncEnabled || !graphState.getStoreState) { + return + } + // The store getter is injected to break the terminal-slice construction cycle. + const state = graphState.getStoreState() + const systemPrefersDark = getSystemPrefersDark() + const ambiguousTerminalTabIds = collectAmbiguousTerminalTabIds(state.tabsByWorktree) + const terminalTabsByWorktree = new Map>() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + const tabsById = new Map() + for (const tab of tabs) { + // Duplicate ids in one worktree are malformed persisted state; don't + // guess which PTY a mounted surface owns. + if (tabsById.has(tab.id)) { + tabsById.delete(tab.id) + continue + } + tabsById.set(tab.id, tab) + } + terminalTabsByWorktree.set(worktreeId, tabsById) + } + const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true + const mobileSessionTabs = buildMobileSessionTabSnapshots( + state, + systemPrefersDark, + ambiguousTerminalTabIds + ) + const publication = partitionMobileSessionPublication(mobileSessionTabs) + const graph: RuntimeRendererSyncWindowGraph = { + tabs: [], + leaves: [], + rendererGeneration: mobilePublicationEpoch, + mobileSessionTabs: publication.changed, + unchangedMobileSessionWorktrees: publication.unchangedWorktrees + } + + for (const [registrationKey, registeredTab] of graphState.registeredTabs) { + if (ambiguousTerminalTabIds.has(registeredTab.tabId)) { + continue + } + const tab = terminalTabsByWorktree.get(registeredTab.worktreeId)?.get(registeredTab.tabId) + if (!tab) { + continue + } + if (isWebOnlyMirroredTerminalTab(tab, state.terminalLayoutsByTabId[registeredTab.tabId])) { + continue + } + const manager = registeredTab.getManager() + const container = registeredTab.getContainer() + const activePaneId = manager?.getActivePane()?.id ?? null + const root = + container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null + graph.tabs.push({ + tabId: registeredTab.tabId, + worktreeId: registeredTab.worktreeId, + title: resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled), + activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null), + layout: serializePaneTree(root) + }) + const savedPtyIdsByLeafId = + state.terminalLayoutsByTabId[registeredTab.tabId]?.ptyIdsByLeafId ?? {} + for (const pane of manager?.getPanes() ?? []) { + const leafId = pane.leafId + const ptyId = registeredTab.getPtyIdForPane(pane.id) + const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null + const registeredTime = graphState.tabRegisteredAt.get(registrationKey) ?? 0 + if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) { + warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', { + tabId: registeredTab.tabId, + worktreeId: registeredTab.worktreeId, + leafId, + paneId: pane.id, + ptyId: savedPtyId + }) + } + const paneTitles = state.runtimePaneTitlesByTabId[registeredTab.tabId] ?? {} + graph.leaves.push({ + tabId: registeredTab.tabId, + worktreeId: registeredTab.worktreeId, + leafId, + paneRuntimeId: pane.id, + ptyId, + paneTitle: paneTitles[pane.id] ?? null, + title: resolveRuntimeTerminalTitle( + tab, + generatedTitlesEnabled, + state.runtimePaneTitlesByTabId[registeredTab.tabId]?.[pane.id] ?? tab.title + ) + }) + } + } + + // Inactive automation/cold-parked tabs do not mount a TerminalPane; publish persisted leaves + // only when a live eager buffer or parked watcher proves the PTY is still owned. + const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + for (const tab of tabs) { + if (ambiguousTerminalTabIds.has(tab.id)) { + continue + } + const layout = state.terminalLayoutsByTabId[tab.id] + if ( + findRegisteredTerminalTab(tab.id, worktreeId) !== null || + isWebOnlyMirroredTerminalTab(tab, layout) + ) { + continue + } + const savedPtyIdsByLeafId = layout?.ptyIdsByLeafId + if (!savedPtyIdsByLeafId) { + continue + } + const liveLeaves = Object.entries(savedPtyIdsByLeafId).filter( + ([leafId, ptyId]) => + typeof ptyId === 'string' && + ptyId.length > 0 && + isTerminalLeafId(leafId) && + (Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId)) + ) + if (liveLeaves.length === 0) { + continue + } + const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled) + const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId)) + const savedActiveLeafId = layout?.activeLeafId + const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id) + const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {} + graph.tabs.push({ + tabId: tab.id, + worktreeId, + title, + activeLeafId: + savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId) + ? savedActiveLeafId + : liveLeaves[0][0], + layout: resolveTerminalLayoutRoot({ + authoritativeRoot: layout?.root, + leafIds: liveLeaves.map(([leafId]) => leafId), + onSynthesize: (leafCount) => + console.warn( + `[sync-runtime-graph] synthesized layout for ${leafCount} unmounted leaves with no saved tree` + ) + }) + }) + liveLeaves.forEach(([leafId, ptyId], index) => { + const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId) + graph.leaves.push({ + tabId: tab.id, + worktreeId, + leafId, + paneRuntimeId: parkedPaneId ?? index + 1, + ptyId, + paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null, + title + }) + }) + } + } + + try { + const result = await window.api.runtime.syncWindowGraph(graph) + commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) + const currentState = graphState.getStoreState() + currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) + for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { + if (currentState) { + applyNativeChatLaunchDraftResolved(currentState, { + type: 'nativeChatLaunchDraftResolved', + ...resolution + }) + } + } + if (result?.mobileSessionResyncWorktrees?.length) { + scheduleTrailingGraphSync() + } + } catch (error) { + console.error('[runtime] Failed to sync renderer graph:', error) + } +} + +let scheduleTrailingGraphSync: () => void = () => undefined +export function setTrailingGraphSyncScheduler(scheduler: () => void): void { + scheduleTrailingGraphSync = scheduler +} + +function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { + changed: RuntimeMobileSessionTabsSnapshot[] + unchangedWorktrees: string[] +} { + const changed: RuntimeMobileSessionTabsSnapshot[] = [] + const unchangedWorktrees: string[] = [] + for (const snapshot of snapshots) { + if (graphState.publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { + unchangedWorktrees.push(snapshot.worktree) + } else { + changed.push(snapshot) + } + } + return { changed, unchangedWorktrees } +} + +function commitMobileSessionPublication( + snapshots: RuntimeMobileSessionTabsSnapshot[], + resyncWorktrees: string[] | undefined +): void { + const published = new Set() + for (const snapshot of snapshots) { + published.add(snapshot.worktree) + graphState.publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) + } + for (const worktreeId of graphState.publishedMobileSessionSnapshotByWorktree.keys()) { + if (!published.has(worktreeId)) { + graphState.publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } + } + for (const worktreeId of resyncWorktrees ?? []) { + graphState.publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts new file mode 100644 index 00000000000..8aa0d87064a --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -0,0 +1,152 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { AppState } from '@/store/types' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileTerminalTheme +} from '../../../../shared/runtime-types' +import type { + AgentStatusProjectionCache, + MobileSessionWorktreeInputs, + OpenFileIndexes, + RegisteredTerminalTab, + TabsProjectionCache +} from './types' + +export const NO_TRANSPORT_GRACE_MS = 10_000 +export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = 30_000 +export const RUNTIME_GRAPH_SYNC_COALESCE_MS = 16 + +export const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {} +export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} +export const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {} +export const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {} +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_NARROWED_BY_KEY: ReadonlyMap = new Map() +export const EMPTY_WORKTREE_TERMINAL_TABS: AppState['tabsByWorktree'][string] = [] +export const EMPTY_WORKTREE_BROWSER_WORKSPACES: AppState['browserTabsByWorktree'][string] = [] +export const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [] +export const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] +export const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] + +export const mobilePublicationEpoch = `renderer:${createBrowserUuid()}` + +export type RegisteredTerminalTabKey = string + +export const graphState = { + registeredTabs: new Map(), + tabRegisteredAt: new Map(), + syncScheduled: false, + syncInFlight: false, + syncPendingAfterFlight: false, + syncEnabled: false, + syncTimer: null as ReturnType | null, + getStoreState: null as (() => AppState) | null, + mobileSessionSnapshotVersion: 0, + mobileSessionSnapshotCacheByWorktree: new Map< + string, + { + inputs: MobileSessionWorktreeInputs + content: unknown + snapshot: RuntimeMobileSessionTabsSnapshot + } + >(), + publishedMobileSessionSnapshotByWorktree: new Map(), + cachedTabsProjection: null as TabsProjectionCache | null, + cachedAgentStatusProjection: null as AgentStatusProjectionCache | null, + cachedOpenFileIndexesSource: null as AppState['openFiles'] | null, + cachedOpenFileIndexes: null as OpenFileIndexes | null, + cachedEditorDraftsSource: null as AppState['editorDrafts'] | null, + cachedEditorDraftVersionByFileId: null as Map | null, + cachedMobileTerminalThemeSettings: null as AppState['settings'] | null, + cachedMobileTerminalThemeSystemPrefersDark: null as boolean | null, + cachedMobileTerminalTheme: undefined as RuntimeMobileTerminalTheme | undefined, + hasCachedMobileTerminalTheme: false +} + +export function registeredTerminalTabKey( + worktreeId: string, + tabId: string +): RegisteredTerminalTabKey { + return `${worktreeId}\0${tabId}` +} + +export function findRegisteredTerminalTab( + tabId: string, + worktreeId?: string +): { key: RegisteredTerminalTabKey; tab: RegisteredTerminalTab } | null { + if (worktreeId !== undefined) { + const key = registeredTerminalTabKey(worktreeId, tabId) + const tab = graphState.registeredTabs.get(key) + return tab ? { key, tab } : null + } + + let match: { key: RegisteredTerminalTabKey; tab: RegisteredTerminalTab } | null = null + for (const [key, tab] of graphState.registeredTabs) { + if (tab.tabId !== tabId) { + continue + } + // A tab id without its worktree is ambiguous; callers must fail closed. + if (match) { + return null + } + match = { key, tab } + } + return match +} + +/** IDs occurring more than once cannot address the legacy tab-keyed runtime maps safely. */ +export function collectAmbiguousTerminalTabIds( + tabsByWorktree: AppState['tabsByWorktree'] +): ReadonlySet { + const seen = new Set() + const ambiguous = new Set() + for (const tabs of Object.values(tabsByWorktree)) { + for (const tab of tabs) { + if (seen.has(tab.id)) { + ambiguous.add(tab.id) + } else { + seen.add(tab.id) + } + } + } + return ambiguous +} + +// Structural equality under JSON-serialization semantics (undefined-valued keys are absent). +export function jsonContentEquals(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false + } + return a.every((item, index) => jsonContentEquals(item, b[index])) + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + const aRecord = a as Record + const bRecord = b as Record + for (const key of Object.keys(aRecord)) { + if (!jsonContentEquals(aRecord[key], bRecord[key])) { + return false + } + } + for (const key of Object.keys(bRecord)) { + if (bRecord[key] !== undefined && aRecord[key] === undefined) { + return false + } + } + return true +} + +export type MobileSessionContent = { + activeGroupId: string | null + activeTabId: string | null + activeTabType: RuntimeMobileSessionSnapshotTab['type'] | null + tabGroups?: NonNullable + tabGroupLayout?: RuntimeMobileSessionTabsSnapshot['tabGroupLayout'] + tabs: RuntimeMobileSessionSnapshotTab[] +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts new file mode 100644 index 00000000000..e1621493832 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts @@ -0,0 +1,38 @@ +import type { AppState } from '@/store/types' +import type { RuntimeMobileSessionBrowserTab } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import type { MobileSessionWorktreeInputs } from './types' +import { isUnifiedTabActiveInActiveGroup } from './mobile-session-tab-helpers' + +export function buildMobileBrowserTab( + inputs: MobileSessionWorktreeInputs, + workspace: NonNullable[number], + unifiedTab?: Tab +): RuntimeMobileSessionBrowserTab { + const pages = inputs.pagesByBrowserWorkspaceId.get(workspace.id) ?? [] + const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null + const title = + activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser' + const unifiedTabId = unifiedTab?.id + return { + type: 'browser', + id: unifiedTabId ?? workspace.id, + title, + browserWorkspaceId: workspace.id, + browserPageId: activePage?.id ?? workspace.activePageId ?? null, + url: activePage?.url ?? workspace.url ?? 'about:blank', + loading: activePage?.loading ?? workspace.loading, + canGoBack: activePage?.canGoBack ?? workspace.canGoBack, + canGoForward: activePage?.canGoForward ?? workspace.canGoForward, + // Null means the active page cleared its failure; do not resurrect a stale workspace error. + loadError: activePage ? activePage.loadError : workspace.loadError, + certificateFailure: activePage + ? (inputs.certificateFailureByBrowserPageId.get(activePage.id) ?? null) + : null, + color: unifiedTab?.color ?? null, + isPinned: unifiedTab?.isPinned === true, + isActive: unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : inputs.activeBrowserWorkspaceId === workspace.id + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts new file mode 100644 index 00000000000..921249fb82d --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts @@ -0,0 +1,190 @@ +import { + collectLeafIdsInOrder, + serializePaneTree +} from '@/components/terminal-pane/layout-serialization' +import type { AppState } from '@/store/types' +import type { + RegisteredTerminalTab, + MobileSessionWorktreeInputs, + MountedTerminalSurfaceCapture +} from './types' +import { EMPTY_NARROWED_BY_KEY, findRegisteredTerminalTab, jsonContentEquals } from './graph-state' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' + +export function narrowRecordByKeys( + source: Record | undefined, + keys: readonly string[] +): ReadonlyMap { + if (!source || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + const value = source[key] + if (value === undefined) { + continue + } + narrowed ??= new Map() + narrowed.set(key, value) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +export function narrowMapByKeys( + source: ReadonlyMap, + keys: readonly string[] +): ReadonlyMap { + if (source.size === 0 || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + if (!source.has(key)) { + continue + } + narrowed ??= new Map() + narrowed.set(key, source.get(key) as T) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +export function captureMountedTerminalSurfaces( + terminalTabs: AppState['tabsByWorktree'][string], + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'], + worktreeId: string +): ReadonlyMap { + let captures: Map | null = null + for (const tab of terminalTabs) { + const registered = findRegisteredTerminalTab(tab.id, worktreeId)?.tab + if (!registered) { + continue + } + captures ??= new Map() + captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) + } + return captures ?? EMPTY_NARROWED_BY_KEY +} + +function captureMountedTerminalSurface( + registered: RegisteredTerminalTab, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): MountedTerminalSurfaceCapture { + const manager = registered.getManager() + const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] + const activePane = manager?.getActivePane() ?? null + const firstChild = registered.getContainer()?.firstElementChild + // Mirrors getRuntimeLeafIdsForTerminal so captured pane ids cover every resolved leaf. + const effectiveLeafIds = + paneLeafIds.length > 0 + ? paneLeafIds + : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + const numericPaneIdByLeafId = new Map() + const ptyIdByNumericPaneId = new Map() + for (const leafId of effectiveLeafIds) { + const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + numericPaneIdByLeafId.set(leafId, numericPaneId) + if (numericPaneId !== null) { + ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) + } + } + return { + paneLeafIds, + hasLiveActivePane: activePane !== null, + liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, + liveLayoutRoot: serializePaneTree( + typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null + ), + numericPaneIdByLeafId, + ptyIdByNumericPaneId, + tabWideAgentHintLeafId: registered.getTabWideAgentHintLeafId() + } +} + +function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false + } + } + return true +} + +function mountedTerminalSurfaceCaptureEquals( + a: MountedTerminalSurfaceCapture, + b: MountedTerminalSurfaceCapture +): boolean { + return ( + a.hasLiveActivePane === b.hasLiveActivePane && + a.liveActiveLeafId === b.liveActiveLeafId && + a.paneLeafIds.length === b.paneLeafIds.length && + a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && + narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && + narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && + a.tabWideAgentHintLeafId === b.tabWideAgentHintLeafId && + // Serialization allocates a fresh tree per capture, so compare its content. + jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) + ) +} + +export function mountedSurfaceCapturesEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [tabId, capture] of a) { + const other = b.get(tabId) + if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { + return false + } + } + return true +} + +export function canReuseMobileSessionSnapshot( + previous: MobileSessionWorktreeInputs, + next: MobileSessionWorktreeInputs +): boolean { + return ( + previous.worktreeId === next.worktreeId && + previous.terminalTabs === next.terminalTabs && + previous.browserWorkspaces === next.browserWorkspaces && + previous.unifiedTabs === next.unifiedTabs && + previous.groups === next.groups && + previous.tabBarOrder === next.tabBarOrder && + previous.activeGroupId === next.activeGroupId && + previous.tabGroupLayout === next.tabGroupLayout && + previous.openFilesById === next.openFilesById && + previous.openFileIds === next.openFileIds && + previous.activeEditorFileId === next.activeEditorFileId && + previous.activeEditorTabType === next.activeEditorTabType && + previous.activeTerminalTabId === next.activeTerminalTabId && + previous.activeBrowserWorkspaceId === next.activeBrowserWorkspaceId && + previous.generatedTitlesEnabled === next.generatedTitlesEnabled && + previous.terminalTheme === next.terminalTheme && + narrowedEntriesEqual(previous.terminalLayoutByTabId, next.terminalLayoutByTabId) && + narrowedEntriesEqual(previous.paneTitlesByTabId, next.paneTitlesByTabId) && + narrowedEntriesEqual(previous.launchDraftByPaneKey, next.launchDraftByPaneKey) && + narrowedEntriesEqual(previous.agentStatusByPaneKey, next.agentStatusByPaneKey) && + narrowedEntriesEqual(previous.editorDraftVersionByFileId, next.editorDraftVersionByFileId) && + narrowedEntriesEqual(previous.pagesByBrowserWorkspaceId, next.pagesByBrowserWorkspaceId) && + narrowedEntriesEqual( + previous.certificateFailureByBrowserPageId, + next.certificateFailureByBrowserPageId + ) && + mountedSurfaceCapturesEqual( + previous.mountedSurfaceCaptureByTabId, + next.mountedSurfaceCaptureByTabId + ) + ) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts new file mode 100644 index 00000000000..3d10aa48dc4 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts @@ -0,0 +1,77 @@ +import type { AppState } from '@/store/types' +import type { + RuntimeMobileSessionFileTab, + RuntimeMobileSessionMarkdownTab +} from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import type { MobileSessionWorktreeInputs } from './types' +import { + isFileActiveEditorSurface, + isMobileFileDiffSource, + isUnifiedTabActiveInActiveGroup +} from './mobile-session-tab-helpers' + +export function buildMobileMarkdownTab( + inputs: MobileSessionWorktreeInputs, + file: AppState['openFiles'][number], + unifiedTab?: Tab +): RuntimeMobileSessionMarkdownTab | null { + if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { + return null + } + if (file.language !== 'markdown' && file.mode !== 'markdown-preview') { + return null + } + const sourceFile = + file.mode === 'markdown-preview' && file.markdownPreviewSourceFileId + ? (inputs.openFilesById?.get(file.markdownPreviewSourceFileId) ?? file) + : file + const draftVersion = inputs.editorDraftVersionByFileId.get(sourceFile.id) + const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' + const unifiedTabId = unifiedTab?.id + return { + type: 'markdown', + id: unifiedTabId ?? file.id, + title, + filePath: file.filePath, + relativePath: file.relativePath, + language: 'markdown', + mode: file.mode, + isDirty: file.isDirty || sourceFile.isDirty, + isActive: unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : isFileActiveEditorSurface(inputs, file), + sourceFileId: sourceFile.id, + sourceFilePath: sourceFile.filePath, + sourceRelativePath: sourceFile.relativePath, + documentVersion: draftVersion ?? `file:${sourceFile.id}`, + color: unifiedTab?.color ?? null, + isPinned: unifiedTab?.isPinned === true + } +} + +export function buildMobileFileTab( + inputs: MobileSessionWorktreeInputs, + file: AppState['openFiles'][number], + unifiedTab?: Tab +): RuntimeMobileSessionFileTab { + const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File' + const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined + const unifiedTabId = unifiedTab?.id + return { + type: 'file', + id: unifiedTabId ?? file.id, + title, + filePath: file.filePath, + relativePath: file.relativePath, + language: file.language, + mode: file.mode === 'diff' ? 'diff' : 'edit', + ...(diffSource ? { diffSource } : {}), + isDirty: file.isDirty, + color: unifiedTab?.color ?? null, + isPinned: unifiedTab?.isPinned === true, + isActive: unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : isFileActiveEditorSurface(inputs, file) + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts new file mode 100644 index 00000000000..f590bc6f226 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts @@ -0,0 +1,242 @@ +import type { RuntimeMobileSessionTabGroup } from '../../../../shared/runtime-types' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' +import { + getActiveTabNavOrder, + getGroupVisibleTabOrder, + type VisibleTabRef +} from '../../components/tab-bar/group-tab-order' +import type { MobileSessionWorktreeInputs } from './types' +import { isEditorSurfaceTab } from './mobile-session-tab-helpers' + +export function getEditorUnifiedTabsForWorktree( + inputs: Pick +): Tab[] { + return inputs.unifiedTabs.filter(isEditorSurfaceTab) +} + +export function applyUnifiedEditorTabIdsToLegacyOrder( + order: readonly VisibleTabRef[], + inputs: Pick +): VisibleTabRef[] { + const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) + if (unifiedEditorTabs.length === 0) { + return [...order] + } + const firstUnifiedTabByFileId = new Map() + for (const tab of unifiedEditorTabs) { + if (!firstUnifiedTabByFileId.has(tab.entityId)) { + firstUnifiedTabByFileId.set(tab.entityId, tab.id) + } + } + return order.map((item) => { + if (item.type !== 'editor' || item.tabId) { + return item + } + const tabId = firstUnifiedTabByFileId.get(item.id) + return tabId ? { ...item, tabId } : item + }) +} + +export function appendFallbackEditorTabsToGroups( + tabGroups: RuntimeMobileSessionTabGroup[] | undefined, + sourceGroups: readonly TabGroup[], + activeGroupId: string | null, + fallbackTabs: readonly { tabId: string; groupId: string | null }[], + activeTabId: string | null +): RuntimeMobileSessionTabGroup[] | undefined { + if (fallbackTabs.length === 0) { + return tabGroups + } + const result = [...(tabGroups ?? [])] + const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group])) + const groupIndexById = new Map(result.map((group, index) => [group.id, index])) + const firstTargetGroupId = + result[0]?.id ?? + (activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ?? + sourceGroups[0]?.id ?? + null + const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId)) + + for (const fallback of fallbackTabs) { + const targetGroupId = + fallback.groupId ?? + (activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId)) + ? activeGroupId + : firstTargetGroupId) + if (!targetGroupId) { + continue + } + let targetIndex = groupIndexById.get(targetGroupId) + if (targetIndex === undefined) { + const sourceGroup = sourceGroupsById.get(targetGroupId) + const group: RuntimeMobileSessionTabGroup = { + id: targetGroupId, + activeTabId: sourceGroup?.activeTabId ?? null, + tabOrder: [], + recentTabIds: sourceGroup?.recentTabIds ?? [] + } + targetIndex = result.length + groupIndexById.set(targetGroupId, targetIndex) + result.push(group) + } + const group = result[targetIndex]! + if (!group.tabOrder.includes(fallback.tabId)) { + result[targetIndex] = { ...group, tabOrder: [...group.tabOrder, fallback.tabId] } + } + } + if (result.length === 0) { + return tabGroups + } + const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null + return result.map((group) => { + const tabOrder = [...group.tabOrder] + const tabOrderSet = new Set(tabOrder) + const activeFallbackTabIdForGroup = + activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null + const activeTabIdForGroup = + activeFallbackTabIdForGroup ?? + (group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null) + const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId)) + if ( + activeFallbackTabId && + tabOrderSet.has(activeFallbackTabId) && + !recentTabIds.includes(activeFallbackTabId) + ) { + recentTabIds.push(activeFallbackTabId) + } + return { ...group, activeTabId: activeTabIdForGroup, tabOrder, recentTabIds } + }) +} + +function getOrderedTabGroups( + groups: readonly TabGroup[], + layout: TabGroupLayoutNode | undefined +): TabGroup[] { + const byId = new Map(groups.map((group) => [group.id, group])) + const seen = new Set() + const ordered: TabGroup[] = [] + for (const groupId of collectTabGroupLayoutIds(layout)) { + const group = byId.get(groupId) + if (!group || seen.has(group.id)) { + continue + } + seen.add(group.id) + ordered.push(group) + } + for (const group of groups) { + if (!seen.has(group.id)) { + ordered.push(group) + } + } + return ordered +} + +function buildLegacyNavOrderView( + inputs: MobileSessionWorktreeInputs +): Parameters[0] { + const { worktreeId } = inputs + return { + activeGroupIdByWorktree: inputs.activeGroupId ? { [worktreeId]: inputs.activeGroupId } : {}, + groupsByWorktree: { [worktreeId]: inputs.groups }, + unifiedTabsByWorktree: { [worktreeId]: inputs.unifiedTabs }, + tabBarOrderByWorktree: inputs.tabBarOrder ? { [worktreeId]: inputs.tabBarOrder } : {}, + tabsByWorktree: { [worktreeId]: inputs.terminalTabs }, + openFiles: inputs.openFilesById ? [...inputs.openFilesById.values()] : [], + browserTabsByWorktree: { [worktreeId]: inputs.browserWorkspaces } + } +} + +export function buildMobileSessionGroupProjection( + inputs: MobileSessionWorktreeInputs, + ids: { terminalIds: string[]; editorIds: string[]; browserIds: string[] } +): { + order: VisibleTabRef[] + tabGroups?: RuntimeMobileSessionTabGroup[] + tabGroupLayout?: TabGroupLayoutNode | null +} { + const groups = inputs.groups + if (groups.length === 0) { + return { + order: applyUnifiedEditorTabIdsToLegacyOrder( + getActiveTabNavOrder(buildLegacyNavOrderView(inputs), inputs.worktreeId, { + editorIds: ids.editorIds + }), + inputs + ) + } + } + + const terminalIds = new Set(ids.terminalIds) + const editorIds = new Set(ids.editorIds) + const browserIds = new Set(ids.browserIds) + const order: VisibleTabRef[] = [] + const tabGroups: RuntimeMobileSessionTabGroup[] = [] + for (const group of getOrderedTabGroups(groups, inputs.tabGroupLayout)) { + const groupTabs = inputs.unifiedTabs.filter((tab) => tab.groupId === group.id) + const visibleOrder = getGroupVisibleTabOrder( + group, + groupTabs, + terminalIds, + editorIds, + browserIds, + new Set(), + true + ) + if (visibleOrder.length === 0) { + continue + } + const tabOrder = visibleOrder.map((item) => item.tabId ?? item.id) + const tabOrderSet = new Set(tabOrder) + for (const item of visibleOrder) { + order.push(item) + } + tabGroups.push({ + id: group.id, + activeTabId: + group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null, + tabOrder, + recentTabIds: group.recentTabIds?.filter((tabId) => tabOrderSet.has(tabId)) ?? [] + }) + } + const validGroupIds = new Set(tabGroups.map((group) => group.id)) + return { + order, + tabGroups, + tabGroupLayout: pruneTabGroupLayout(inputs.tabGroupLayout, validGroupIds) + } +} + +export function collectTabGroupLayoutIds(layout: TabGroupLayoutNode | undefined): string[] { + const result: string[] = [] + const visit = (node: TabGroupLayoutNode | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + result.push(node.groupId) + return + } + visit(node.first) + visit(node.second) + } + visit(layout) + return result +} + +export function pruneTabGroupLayout( + layout: TabGroupLayoutNode | undefined, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return validGroupIds.has(layout.groupId) ? layout : null + } + const first = pruneTabGroupLayout(layout.first, validGroupIds) + const second = pruneTabGroupLayout(layout.second, validGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts new file mode 100644 index 00000000000..16c2f4d0da3 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -0,0 +1,246 @@ +import type { AppState } from '@/store/types' +import { parsePaneKey, makePaneKey } from '../../../../shared/stable-pane-id' +import { nativeChatLaunchAgentForLeaf } from '../../components/native-chat/native-chat-leaf-routing' +import { + EMPTY_NARROWED_BY_KEY, + EMPTY_WORKTREE_BROWSER_WORKSPACES, + EMPTY_WORKTREE_OPEN_FILE_IDS, + EMPTY_WORKTREE_TAB_GROUPS, + EMPTY_WORKTREE_TERMINAL_TABS, + EMPTY_WORKTREE_UNIFIED_TABS, + EMPTY_LAYOUT_BY_WORKTREE, + graphState +} from './graph-state' +import type { + MobileSessionAgentStatusByWorktree, + MobileSessionPublicationInputs, + MobileSessionWorktreeInputs, + OpenFileIndexes +} from './types' +import { captureMountedTerminalSurfaces, narrowRecordByKeys } from './mobile-session-capture' +import { + getRuntimeLeafIdsForTerminal, + resolveMobileTabWideAgentHintLeafId +} from './mobile-session-tab-helpers' + +export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { + if (graphState.cachedOpenFileIndexesSource === openFiles && graphState.cachedOpenFileIndexes) { + return graphState.cachedOpenFileIndexes + } + const byWorktreeAndId = new Map>() + const idsByWorktree = new Map() + for (const file of openFiles) { + let filesById = byWorktreeAndId.get(file.worktreeId) + if (!filesById) { + filesById = new Map() + byWorktreeAndId.set(file.worktreeId, filesById) + } + let ids = idsByWorktree.get(file.worktreeId) + if (!ids) { + ids = [] + idsByWorktree.set(file.worktreeId, ids) + } + if (!filesById.has(file.id)) { + filesById.set(file.id, file) + ids.push(file.id) + } + } + graphState.cachedOpenFileIndexesSource = openFiles + graphState.cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree } + return graphState.cachedOpenFileIndexes +} + +export function getEditorDraftVersionByFileId( + editorDrafts: AppState['editorDrafts'] +): Map { + if ( + graphState.cachedEditorDraftsSource === editorDrafts && + graphState.cachedEditorDraftVersionByFileId + ) { + return graphState.cachedEditorDraftVersionByFileId + } + const versions = new Map() + for (const [fileId, content] of Object.entries(editorDrafts)) { + let hash = 2166136261 + for (let index = 0; index < content.length; index += 1) { + hash ^= content.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + versions.set(fileId, `draft:${content.length}:${(hash >>> 0).toString(16)}`) + } + graphState.cachedEditorDraftsSource = editorDrafts + graphState.cachedEditorDraftVersionByFileId = versions + return versions +} + +export function buildMobileSessionAgentStatusByWorktree( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'], + tabsByWorktree: AppState['tabsByWorktree'] +): MobileSessionAgentStatusByWorktree { + const byWorktreeId = new Map>() + const paneKeys = Object.keys(agentStatusByPaneKey) + if (paneKeys.length === 0) { + return byWorktreeId + } + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const paneKey of paneKeys) { + const tabId = parsePaneKey(paneKey)?.tabId + const worktreeId = tabId === undefined ? undefined : worktreeIdByTabId.get(tabId) + if (worktreeId === undefined) { + continue + } + let bucket = byWorktreeId.get(worktreeId) + if (!bucket) { + bucket = new Map() + byWorktreeId.set(worktreeId, bucket) + } + bucket.set(paneKey, agentStatusByPaneKey[paneKey]) + } + return byWorktreeId +} + +export function buildMobileSessionWorktreeInputs( + state: AppState, + worktreeId: string, + publication: MobileSessionPublicationInputs, + ambiguousTerminalTabIds: ReadonlySet +): MobileSessionWorktreeInputs { + // Legacy layout/title maps are keyed only by tab id. Omit ambiguous ids until + // hydration repairs ownership instead of publishing one worktree's metadata for another. + const sourceTerminalTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TERMINAL_TABS + // Preserve the source reference when there is nothing to filter; the mobile + // snapshot cache uses this identity to avoid rebuilding on title ticks. + const terminalTabs = sourceTerminalTabs.some((tab) => ambiguousTerminalTabIds.has(tab.id)) + ? sourceTerminalTabs.filter((tab) => !ambiguousTerminalTabIds.has(tab.id)) + : sourceTerminalTabs + const terminalTabIds = terminalTabs.map((tab) => tab.id) + const terminalLayoutByTabId = narrowRecordByKeys(state.terminalLayoutsByTabId, terminalTabIds) + const mountedSurfaceCaptureByTabId = captureMountedTerminalSurfaces( + terminalTabs, + state.terminalLayoutsByTabId, + worktreeId + ) + const browserWorkspaces = + publication.browserTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_BROWSER_WORKSPACES + const pagesByBrowserWorkspaceId = narrowRecordByKeys( + state.browserPagesByWorkspace, + browserWorkspaces.map((workspace) => workspace.id) + ) + const browserPageIds: string[] = [] + for (const pages of pagesByBrowserWorkspaceId.values()) { + for (const page of pages) { + browserPageIds.push(page.id) + } + } + const openFilesById = publication.openFileIndexes.byWorktreeAndId.get(worktreeId) + const openFileIds = + publication.openFileIndexes.idsByWorktree.get(worktreeId) ?? EMPTY_WORKTREE_OPEN_FILE_IDS + const resolvedActiveFileId = state.activeFileIdByWorktree?.[worktreeId] ?? state.activeFileId + const activeEditorFileId = + resolvedActiveFileId && openFilesById?.has(resolvedActiveFileId) ? resolvedActiveFileId : null + const activeTabId = state.activeTabId + return { + worktreeId, + terminalTabs, + browserWorkspaces, + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TAB_GROUPS, + tabBarOrder: state.tabBarOrderByWorktree[worktreeId], + activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, + tabGroupLayout: (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], + openFilesById, + openFileIds, + terminalLayoutByTabId, + paneTitlesByTabId: narrowRecordByKeys(state.runtimePaneTitlesByTabId, terminalTabIds), + launchDraftByPaneKey: buildMobileLaunchDraftsByPaneKey({ + terminalTabs, + terminalLayoutByTabId, + mountedSurfaceCaptureByTabId, + launchDraftByTabId: narrowRecordByKeys(state.nativeChatLaunchDraftByTabId, terminalTabIds) + }), + agentStatusByPaneKey: + publication.agentStatusByWorktreeId.get(worktreeId) ?? EMPTY_NARROWED_BY_KEY, + editorDraftVersionByFileId: narrowMapByKeys( + publication.editorDraftVersionByFileId, + openFileIds + ), + pagesByBrowserWorkspaceId, + certificateFailureByBrowserPageId: narrowRecordByKeys( + state.browserCertificateFailuresByPageId, + browserPageIds + ), + activeEditorFileId, + activeEditorTabType: activeEditorFileId + ? (state.activeTabTypeByWorktree?.[worktreeId] ?? state.activeTabType) + : null, + activeTerminalTabId: + activeTabId !== null && terminalTabIds.includes(activeTabId) ? activeTabId : null, + activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, + generatedTitlesEnabled: publication.generatedTitlesEnabled, + terminalTheme: publication.terminalTheme, + mountedSurfaceCaptureByTabId + } +} + +function narrowMapByKeys( + source: ReadonlyMap, + keys: readonly string[] +): ReadonlyMap { + if (source.size === 0 || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + if (!source.has(key)) { + continue + } + narrowed ??= new Map() + narrowed.set(key, source.get(key) as T) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +export function buildMobileLaunchDraftsByPaneKey(args: { + terminalTabs: AppState['tabsByWorktree'][string] + terminalLayoutByTabId: MobileSessionWorktreeInputs['terminalLayoutByTabId'] + mountedSurfaceCaptureByTabId: MobileSessionWorktreeInputs['mountedSurfaceCaptureByTabId'] + launchDraftByTabId: ReadonlyMap< + string, + NonNullable[string] + > +}): MobileSessionWorktreeInputs['launchDraftByPaneKey'] { + if (args.launchDraftByTabId.size === 0) { + return EMPTY_NARROWED_BY_KEY + } + let draftsByPaneKey: Map< + string, + NonNullable[string] + > | null = null + for (const terminal of args.terminalTabs) { + const draft = args.launchDraftByTabId.get(terminal.id) + if (!draft || draft.resolved) { + continue + } + const capture = args.mountedSurfaceCaptureByTabId.get(terminal.id) + const savedLayout = args.terminalLayoutByTabId.get(terminal.id) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const ownerLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminal.launchAgent, + launchAgentLeafId: ownerLeafId, + leafId: ownerLeafId, + leafIds + }) + if (!launchAgent || launchAgent !== draft.agent || !ownerLeafId) { + continue + } + draftsByPaneKey ??= new Map() + draftsByPaneKey.set(makePaneKey(terminal.id, ownerLeafId), draft) + } + return draftsByPaneKey ?? EMPTY_NARROWED_BY_KEY +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts new file mode 100644 index 00000000000..91e89a9cb73 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -0,0 +1,234 @@ +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import type { AppState } from '@/store/types' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot +} from '../../../../shared/runtime-types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + collectAmbiguousTerminalTabIds, + graphState, + jsonContentEquals, + mobilePublicationEpoch +} from './graph-state' +import { + buildMobileSessionAgentStatusByWorktree, + buildMobileSessionWorktreeInputs, + getEditorDraftVersionByFileId, + getOpenFileIndexes +} from './mobile-session-inputs' +import { getMobileTerminalTheme } from './mobile-terminal-theme' +import { + isMobilePublishableBrowserWorkspace, + isMobilePublishableOpenFile, + isWebOnlyMirroredTerminalTab +} from './mobile-session-tab-helpers' +import { + appendFallbackEditorTabsToGroups, + buildMobileSessionGroupProjection, + pruneTabGroupLayout +} from './mobile-session-group-projection' +import { buildMobileTerminalSurfaceTabs } from './mobile-session-terminal-tabs' +import { buildMobileMarkdownTab, buildMobileFileTab } from './mobile-session-editor-tabs' +import { buildMobileBrowserTab } from './mobile-session-browser-tabs' +import type { MobileSessionPublicationInputs } from './types' +import { canReuseMobileSessionSnapshot } from './mobile-session-capture' + +export function buildMobileSessionTabSnapshots( + state: AppState, + systemPrefersDark = getSystemPrefersDark(), + ambiguousTerminalTabIds: ReadonlySet = collectAmbiguousTerminalTabIds( + state.tabsByWorktree + ) +): RuntimeMobileSessionTabsSnapshot[] { + const openFileIndexes = getOpenFileIndexes(state.openFiles) + const browserTabsByWorktree = state.browserTabsByWorktree ?? {} + const publicationInputs: MobileSessionPublicationInputs = { + browserTabsByWorktree, + openFileIndexes, + editorDraftVersionByFileId: getEditorDraftVersionByFileId(state.editorDrafts), + agentStatusByWorktreeId: buildMobileSessionAgentStatusByWorktree( + state.agentStatusByPaneKey ?? {}, + state.tabsByWorktree + ), + generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, + terminalTheme: getMobileTerminalTheme(state, systemPrefersDark) + } + const liveFolderWorkspaceIds = new Set( + (state.folderWorkspaces ?? []).map((workspace) => workspace.id) + ) + const worktreeIds = new Set([ + ...Object.keys(state.tabsByWorktree), + ...Object.keys(state.groupsByWorktree), + ...Object.keys(state.unifiedTabsByWorktree), + ...Object.keys(browserTabsByWorktree), + ...state.openFiles.map((file) => file.worktreeId) + ]) + const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] + + for (const worktreeId of worktreeIds) { + const workspaceScope = parseWorkspaceKey(worktreeId) + if ( + workspaceScope?.type === 'folder' && + !liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId) + ) { + graphState.mobileSessionSnapshotCacheByWorktree.delete(worktreeId) + continue + } + const inputs = buildMobileSessionWorktreeInputs( + state, + worktreeId, + publicationInputs, + ambiguousTerminalTabIds + ) + const cached = graphState.mobileSessionSnapshotCacheByWorktree.get(worktreeId) + if (cached && canReuseMobileSessionSnapshot(cached.inputs, inputs)) { + snapshots.push(cached.snapshot) + continue + } + const terminalTabById = new Map(inputs.terminalTabs.map((tab) => [tab.id, tab])) + const browserWorkspaceById = new Map( + inputs.browserWorkspaces.map((workspace) => [workspace.id, workspace]) + ) + const unifiedTabById = new Map(inputs.unifiedTabs.map((tab) => [tab.id, tab])) + const openFilesForWorktree = inputs.openFilesById + const editorIds = inputs.openFileIds.filter((fileId) => { + const file = openFilesForWorktree?.get(fileId) + return file ? isMobilePublishableOpenFile(file) : false + }) + const terminalIds = [...terminalTabById.values()] + .filter( + (terminal) => + !isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) + ) + .map((terminal) => terminal.id) + const groupProjection = buildMobileSessionGroupProjection(inputs, { + terminalIds, + editorIds, + browserIds: [...browserWorkspaceById.values()] + .filter(isMobilePublishableBrowserWorkspace) + .map((workspace) => workspace.id) + }) + const tabs: RuntimeMobileSessionSnapshotTab[] = [] + const emittedEditorFileIds = new Set() + const emittedEditorTabIds = new Set() + + for (const item of groupProjection.order) { + if (item.type === 'terminal') { + const terminal = terminalTabById.get(item.id) + if ( + !terminal || + isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) + ) { + continue + } + tabs.push(...buildMobileTerminalSurfaceTabs(inputs, terminal, item.tabId)) + } else if (item.type === 'editor') { + const file = openFilesForWorktree?.get(item.id) + if (!file || !isMobilePublishableOpenFile(file)) { + continue + } + const unifiedTab = item.tabId ? unifiedTabById.get(item.tabId) : undefined + tabs.push( + buildMobileMarkdownTab(inputs, file, unifiedTab) ?? + buildMobileFileTab(inputs, file, unifiedTab) + ) + emittedEditorFileIds.add(file.id) + emittedEditorTabIds.add(item.tabId ?? item.id) + } else if (item.type === 'browser') { + const workspace = browserWorkspaceById.get(item.id) + if (!workspace || !isMobilePublishableBrowserWorkspace(workspace)) { + continue + } + tabs.push( + buildMobileBrowserTab( + inputs, + workspace, + item.tabId ? unifiedTabById.get(item.tabId) : undefined + ) + ) + } + } + + const fallbackEditorTabs: { tabId: string; groupId: string | null }[] = [] + if (openFilesForWorktree) { + const unifiedEditorTabs = inputs.unifiedTabs.filter( + (tab) => tab.contentType === 'editor' || tab.contentType === 'diff' + ) + const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId)) + for (const unifiedTab of unifiedEditorTabs) { + if (emittedEditorTabIds.has(unifiedTab.id)) { + continue + } + const file = openFilesForWorktree.get(unifiedTab.entityId) + if (!file || !isMobilePublishableOpenFile(file)) { + continue + } + const fallbackTab = + buildMobileMarkdownTab(inputs, file, unifiedTab) ?? + buildMobileFileTab(inputs, file, unifiedTab) + tabs.push(fallbackTab) + fallbackEditorTabs.push({ tabId: fallbackTab.id, groupId: unifiedTab.groupId }) + emittedEditorTabIds.add(unifiedTab.id) + } + for (const file of openFilesForWorktree.values()) { + if (!isMobilePublishableOpenFile(file) || emittedEditorFileIds.has(file.id)) { + continue + } + if (unifiedEditorFileIds.has(file.id)) { + emittedEditorFileIds.add(file.id) + continue + } + const fallbackTab = buildMobileMarkdownTab(inputs, file) ?? buildMobileFileTab(inputs, file) + tabs.push(fallbackTab) + fallbackEditorTabs.push({ tabId: fallbackTab.id, groupId: null }) + emittedEditorFileIds.add(file.id) + } + } + + const active = tabs.find((tab) => tab.isActive) ?? null + const tabGroups = appendFallbackEditorTabsToGroups( + groupProjection.tabGroups, + inputs.groups, + inputs.activeGroupId, + fallbackEditorTabs, + active?.id ?? null + ) + const tabGroupLayout = + tabGroups && tabGroups.length > 0 + ? pruneTabGroupLayout(inputs.tabGroupLayout, new Set(tabGroups.map((group) => group.id))) + : groupProjection.tabGroupLayout + const content = { + activeGroupId: inputs.activeGroupId, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}), + ...(tabGroupLayout ? { tabGroupLayout } : {}), + tabs + } + const candidateVersion = ++graphState.mobileSessionSnapshotVersion + if (cached && jsonContentEquals(cached.content, content)) { + graphState.mobileSessionSnapshotCacheByWorktree.set(worktreeId, { + inputs, + content, + snapshot: cached.snapshot + }) + snapshots.push(cached.snapshot) + continue + } + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: mobilePublicationEpoch, + snapshotVersion: candidateVersion, + ...content + } + graphState.mobileSessionSnapshotCacheByWorktree.set(worktreeId, { inputs, content, snapshot }) + snapshots.push(snapshot) + } + for (const worktreeId of graphState.mobileSessionSnapshotCacheByWorktree.keys()) { + if (!worktreeIds.has(worktreeId)) { + graphState.mobileSessionSnapshotCacheByWorktree.delete(worktreeId) + } + } + return snapshots +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts new file mode 100644 index 00000000000..9012a0fc0d1 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts @@ -0,0 +1,110 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import type { AppState } from '@/store/types' +import { isClaudeManagementTitle } from '../../../../shared/agent-detection' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import type { Tab } from '../../../../shared/tab-types' +import { parseRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { + isNativeChatTabWideFallbackSafe, + nativeChatLaunchAgentForLeaf, + resolveNativeChatActiveLayoutLeafId +} from '../../components/native-chat/native-chat-leaf-routing' +import type { MobileSessionWorktreeInputs, MountedTerminalSurfaceCapture } from './types' + +export function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { + return `${parentTabId}::${leafId}` +} + +export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null +} + +export function isWebOnlyMirroredTerminalTab( + tab: Pick[number], 'id' | 'ptyId'>, + layout: AppState['terminalLayoutsByTabId'][string] | undefined +): boolean { + if (!isWebTerminalSurfaceTabId(tab.id)) { + return false + } + const layoutPtyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) + const ptyIds = [tab.ptyId, ...layoutPtyIds].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 + ) + // Only-remote/no-PTY tabs are web mirrors; legacy local-PTY tabs still publish. + return ptyIds.every(isRemoteRuntimePtyId) +} + +export function getRuntimeLeafIdsForTerminal( + capture: MountedTerminalSurfaceCapture | undefined, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): readonly string[] { + const liveLeafIds = capture?.paneLeafIds ?? [] + if (liveLeafIds.length > 0) { + return liveLeafIds + } + const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + if (persistedLeafIds.length > 0) { + return persistedLeafIds + } + // A new tab can predate TerminalPane mount; do not fabricate a stale pane:1. + return [] +} + +export function resolveMobileTabWideAgentHintLeafId( + capture: MountedTerminalSurfaceCapture | undefined, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): string | null { + if (capture) { + return capture.tabWideAgentHintLeafId + } + return isNativeChatTabWideFallbackSafe(savedLayout) + ? resolveNativeChatActiveLayoutLeafId(savedLayout) + : null +} + +export function isEditorSurfaceTab(tab: Pick): boolean { + // Mobile can mirror ordinary edit/diff files; other editor tabs need extra metadata. + return tab.contentType === 'editor' || tab.contentType === 'diff' +} + +export function isFileActiveEditorSurface( + inputs: Pick, + file: Pick +): boolean { + return inputs.activeEditorTabType === 'editor' && inputs.activeEditorFileId === file.id +} + +export function isMobileFileDiffSource( + diffSource: AppState['openFiles'][number]['diffSource'] +): diffSource is 'staged' | 'unstaged' { + return diffSource === 'staged' || diffSource === 'unstaged' +} + +export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean { + // Combined diff tabs use display labels as paths and need the desktop renderer. + return !( + file.diffSource === 'combined-all' || + file.diffSource === 'combined-uncommitted' || + file.diffSource === 'combined-branch' || + file.diffSource === 'combined-commit' + ) +} + +export function isMobilePublishableBrowserWorkspace( + workspace: NonNullable[number] +): boolean { + // Document previews are served through a desktop-only grant. + return !workspace.docLocation +} + +export function isUnifiedTabActiveInActiveGroup( + inputs: Pick, + unifiedTabId: string +): boolean { + return inputs.groups.some( + (group) => group.id === inputs.activeGroupId && group.activeTabId === unifiedTabId + ) +} + +export { isClaudeManagementTitle, isTerminalLeafId, makePaneKey, nativeChatLaunchAgentForLeaf } diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts new file mode 100644 index 00000000000..0d364c92970 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts @@ -0,0 +1,126 @@ +import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/layout-serialization' +import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' +import type { AppState } from '@/store/types' +import type { RuntimeMobileSessionSnapshotTab } from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { + isNativeChatTabWideFallbackSafe, + nativeChatLaunchAgentForLeaf +} from '../../components/native-chat/native-chat-leaf-routing' +import type { MobileSessionWorktreeInputs } from './types' +import { + isClaudeManagementTitle, + isTerminalLeafId, + makePaneKey, + mobileTerminalSurfaceId, + getRuntimeLeafIdsForTerminal, + resolveMobileTabWideAgentHintLeafId, + isUnifiedTabActiveInActiveGroup +} from './mobile-session-tab-helpers' +import { resolveRuntimeTerminalTitle } from './sync-projections' +import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' + +export function buildMobileTerminalSurfaceTabs( + inputs: MobileSessionWorktreeInputs, + terminal: NonNullable[number], + unifiedTabId?: string +): RuntimeMobileSessionSnapshotTab[] { + const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) + const isDesktopTabActive = unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : inputs.activeTerminalTabId === terminal.id + const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const launchAgentLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) + const activeLeafId = capture?.hasLiveActivePane + ? capture.liveActiveLeafId + : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) + const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} + const sanitizedSavedLayout = savedLayout + ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal) + : undefined + const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} + const liveLayoutRoot = capture?.liveLayoutRoot ?? null + const parentLayout = normalizeTerminalLayoutSnapshot({ + // Live DOM is authoritative when mounted; use saved tree otherwise. + root: resolveTerminalLayoutRoot({ + authoritativeRoot: liveLayoutRoot, + existingRoot: sanitizedSavedLayout?.root, + leafIds, + onSynthesize: (leafCount) => + console.warn( + `[sync-runtime-graph] synthesized parentLayout for ${leafCount} leaves with no live or saved tree` + ) + }), + activeLeafId, + expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null, + ...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}), + ...(sanitizedSavedLayout?.titlesByLeafId + ? { titlesByLeafId: sanitizedSavedLayout.titlesByLeafId } + : {}) + } satisfies TerminalLayoutSnapshot).snapshot + + return leafIds.map((leafId) => { + const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null + const ptyId = + numericPaneId === null + ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) + : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) + const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null + const paneTitle = + numericPaneId !== null + ? paneTitles[numericPaneId] + : legacyPaneId + ? paneTitles[Number(legacyPaneId)] + : undefined + const leafTitle = paneTitle?.trim() || sanitizedSavedLayout?.titlesByLeafId?.[leafId]?.trim() + const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null + const tabWideFallbackSafe = + isNativeChatTabWideFallbackSafe(parentLayout) && launchAgentLeafId === leafId + const title = tabWideFallbackSafe + ? resolveRuntimeTerminalTitle( + terminal, + inputs.generatedTitlesEnabled, + leafTitle ?? terminal.title ?? 'Terminal' + ) + : (leafTitle ?? 'Terminal') + const agentStatusTitle = leafTitle ?? (tabWideFallbackSafe ? terminal.title : '') ?? '' + const agentStatus = + paneKey && !isClaudeManagementTitle(agentStatusTitle) + ? inputs.agentStatusByPaneKey.get(paneKey) + : undefined + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminal.launchAgent, + launchAgentLeafId, + leafId, + leafIds + }) + const launchDraft = paneKey ? inputs.launchDraftByPaneKey.get(paneKey) : undefined + const publishedLaunchDraft = + launchDraft && launchDraft.agent === launchAgent && launchDraft.text.trim() + ? launchDraft + : null + return { + type: 'terminal' as const, + id: mobileTerminalSurfaceId(terminal.id, leafId), + title, + ...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim() + ? { quickCommandLabel: terminal.quickCommandLabel.trim() } + : {}), + parentTabId: terminal.id, + leafId, + ptyId, + ...(inputs.terminalTheme ? { terminalTheme: inputs.terminalTheme } : {}), + ...(agentStatus ? { agentStatus } : {}), + ...(launchAgent ? { launchAgent } : {}), + ...(publishedLaunchDraft + ? { + launchDraft: publishedLaunchDraft.text, + launchDraftCreatedAt: publishedLaunchDraft.createdAt + } + : {}), + parentLayout, + isActive: isDesktopTabActive && leafId === activeLeafId + } + }) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts new file mode 100644 index 00000000000..ab9e2c05042 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts @@ -0,0 +1,74 @@ +import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import type { AppState } from '@/store/types' +import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types' +import { graphState } from './graph-state' + +function hexToRgba(hex: string, alpha: number): string { + let clean = hex.replace('#', '') + if (clean.length === 3) { + clean = clean + .split('') + .map((character) => character + character) + .join('') + } + const r = Number.parseInt(clean.slice(0, 2), 16) + const g = Number.parseInt(clean.slice(2, 4), 16) + const b = Number.parseInt(clean.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +function isHexColor(value: string | undefined): value is string { + return typeof value === 'string' && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) +} + +export function resolveMobileTerminalTheme( + state: AppState, + systemPrefersDark: boolean +): RuntimeMobileTerminalTheme | undefined { + const settings = state.settings + if (!settings) { + return undefined + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const resolvedTheme = appearance.theme + ? { ...appearance.theme, ...settings.terminalColorOverrides } + : undefined + if (!resolvedTheme) { + return undefined + } + if (settings.terminalBackgroundOpacity !== undefined && isHexColor(resolvedTheme.background)) { + resolvedTheme.background = hexToRgba( + resolvedTheme.background, + settings.terminalBackgroundOpacity + ) + } + if (settings.terminalCursorOpacity !== undefined && isHexColor(resolvedTheme.cursor)) { + resolvedTheme.cursor = hexToRgba(resolvedTheme.cursor, settings.terminalCursorOpacity) + } + + const theme: Record = {} + for (const [key, value] of Object.entries(resolvedTheme)) { + if (typeof value === 'string') { + theme[key] = value + } + } + return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } +} + +export function getMobileTerminalTheme( + state: AppState, + systemPrefersDark = getSystemPrefersDark() +): RuntimeMobileTerminalTheme | undefined { + if ( + graphState.hasCachedMobileTerminalTheme && + graphState.cachedMobileTerminalThemeSettings === state.settings && + graphState.cachedMobileTerminalThemeSystemPrefersDark === systemPrefersDark + ) { + return graphState.cachedMobileTerminalTheme + } + graphState.cachedMobileTerminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) + graphState.cachedMobileTerminalThemeSettings = state.settings + graphState.cachedMobileTerminalThemeSystemPrefersDark = systemPrefersDark + graphState.hasCachedMobileTerminalTheme = true + return graphState.cachedMobileTerminalTheme +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts new file mode 100644 index 00000000000..5eac69789cf --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts @@ -0,0 +1,182 @@ +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import type { AppState } from '@/store/types' +import { + EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_BROWSER_PAGES_BY_WORKSPACE, + EMPTY_BROWSER_TABS_BY_WORKTREE, + EMPTY_LAYOUT_BY_WORKTREE +} from './graph-state' +import { + buildRuntimeMobileAgentStatusProjection, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests, + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS +} from './agent-status-projection' +import { + buildRuntimeMobileBrowserProjection, + buildRuntimeMobileEditorDraftsProjection, + buildRuntimeMobileOpenFilesProjection, + buildRuntimeMobileTabsProjection, + getBrowserPagesByWorkspace, + getBrowserTabsByWorktree +} from './sync-projections' +import { resolveMobileTerminalTheme } from './mobile-terminal-theme' +import type { RuntimeMobileSessionSyncKey } from './types' + +export { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests +} +export type { RuntimeMobileSessionSyncKey } from './types' + +function getTerminalThemeSystemPrefersDark( + state: Pick, + systemPrefersDark: boolean | null | undefined +): boolean | null { + return state.settings?.theme === 'system' ? (systemPrefersDark ?? null) : null +} + +export function canSkipRuntimeMobileSessionSyncKeyBuild( + state: AppState, + previousState: AppState, + systemPrefersDark?: boolean, + previousSystemPrefersDark: boolean | null | undefined = systemPrefersDark +): boolean { + const currentThemePreference = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) + const previousThemePreference = getTerminalThemeSystemPrefersDark( + previousState, + previousSystemPrefersDark + ) + return ( + currentThemePreference === previousThemePreference && + state.tabsByWorktree === previousState.tabsByWorktree && + state.groupsByWorktree === previousState.groupsByWorktree && + state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree && + state.layoutByWorktree === previousState.layoutByWorktree && + state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree && + state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree && + state.activeFileId === previousState.activeFileId && + state.activeFileIdByWorktree === previousState.activeFileIdByWorktree && + state.activeTabType === previousState.activeTabType && + state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree && + getBrowserTabsByWorktree(state) === getBrowserTabsByWorktree(previousState) && + getBrowserPagesByWorkspace(state) === getBrowserPagesByWorkspace(previousState) && + (state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE) === + (previousState.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE) && + state.openFiles === previousState.openFiles && + state.editorDrafts === previousState.editorDrafts && + state.settings === previousState.settings && + state.activeTabId === previousState.activeTabId && + state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && + state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && + state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && + state.folderWorkspaces === previousState.folderWorkspaces && + state.agentStatusEpoch === previousState.agentStatusEpoch && + state.agentStatusByPaneKey === previousState.agentStatusByPaneKey + ) +} + +export function getRuntimeMobileSessionSyncKey( + state: AppState, + previousState?: AppState, + previousKey?: RuntimeMobileSessionSyncKey, + systemPrefersDark = getSystemPrefersDark() +): RuntimeMobileSessionSyncKey { + const canReusePrevious = previousState !== undefined && previousKey !== undefined + const themeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) + const browserTabsByWorktree = getBrowserTabsByWorktree(state) + const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) + const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY + const previousBrowserTabsByWorktree = previousState + ? getBrowserTabsByWorktree(previousState) + : EMPTY_BROWSER_TABS_BY_WORKTREE + const previousBrowserPagesByWorkspace = previousState + ? getBrowserPagesByWorkspace(previousState) + : EMPTY_BROWSER_PAGES_BY_WORKSPACE + const previousAgentStatusByPaneKey = previousState + ? (previousState.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY) + : EMPTY_AGENT_STATUS_BY_PANE_KEY + return { + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, + nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, + folderWorkspaces: state.folderWorkspaces, + groupsByWorktree: state.groupsByWorktree, + activeGroupIdByWorktree: state.activeGroupIdByWorktree, + layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + tabBarOrderByWorktree: state.tabBarOrderByWorktree, + activeFileId: state.activeFileId, + activeFileIdByWorktree: state.activeFileIdByWorktree, + activeTabType: state.activeTabType, + activeTabTypeByWorktree: state.activeTabTypeByWorktree, + activeTabId: state.activeTabId, + activeBrowserTabIdByWorktree: + state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, + agentStatusEpoch: state.agentStatusEpoch ?? 0, + agentStatusProjection: + canReusePrevious && agentStatusByPaneKey === previousAgentStatusByPaneKey + ? previousKey.agentStatusProjection + : buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey), + generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, + systemPrefersDark: themeSystemPrefersDark, + terminalThemeProjection: + canReusePrevious && + state.settings === previousState.settings && + previousKey.systemPrefersDark === themeSystemPrefersDark + ? previousKey.terminalThemeProjection + : JSON.stringify(resolveMobileTerminalTheme(state, systemPrefersDark) ?? null), + tabsProjection: + canReusePrevious && state.tabsByWorktree === previousState.tabsByWorktree + ? previousKey.tabsProjection + : buildRuntimeMobileTabsProjection(state.tabsByWorktree), + openFilesProjection: + canReusePrevious && state.openFiles === previousState.openFiles + ? previousKey.openFilesProjection + : buildRuntimeMobileOpenFilesProjection(state.openFiles), + browserProjection: + canReusePrevious && + browserTabsByWorktree === previousBrowserTabsByWorktree && + browserPagesByWorkspace === previousBrowserPagesByWorkspace + ? previousKey.browserProjection + : buildRuntimeMobileBrowserProjection(state), + editorDraftsProjection: + canReusePrevious && state.editorDrafts === previousState.editorDrafts + ? previousKey.editorDraftsProjection + : buildRuntimeMobileEditorDraftsProjection(state.editorDrafts) + } +} + +export function runtimeMobileSessionSyncKeysEqual( + a: RuntimeMobileSessionSyncKey, + b: RuntimeMobileSessionSyncKey +): boolean { + return ( + a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && + a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && + a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && + a.folderWorkspaces === b.folderWorkspaces && + a.groupsByWorktree === b.groupsByWorktree && + a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && + a.layoutByWorktree === b.layoutByWorktree && + a.unifiedTabsByWorktree === b.unifiedTabsByWorktree && + a.tabBarOrderByWorktree === b.tabBarOrderByWorktree && + a.activeFileId === b.activeFileId && + a.activeFileIdByWorktree === b.activeFileIdByWorktree && + a.activeTabType === b.activeTabType && + a.activeTabTypeByWorktree === b.activeTabTypeByWorktree && + a.activeTabId === b.activeTabId && + a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree && + a.agentStatusEpoch === b.agentStatusEpoch && + a.agentStatusProjection === b.agentStatusProjection && + a.generatedTabTitlesEnabled === b.generatedTabTitlesEnabled && + a.systemPrefersDark === b.systemPrefersDark && + a.terminalThemeProjection === b.terminalThemeProjection && + a.tabsProjection === b.tabsProjection && + a.openFilesProjection === b.openFilesProjection && + a.browserProjection === b.browserProjection && + a.editorDraftsProjection === b.editorDraftsProjection + ) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts new file mode 100644 index 00000000000..911348fae64 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -0,0 +1,157 @@ +import type { AppState } from '@/store/types' +import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_BROWSER_PAGES_BY_WORKSPACE, + EMPTY_BROWSER_TABS_BY_WORKTREE, + graphState +} from './graph-state' +import { buildRuntimeMobileAgentStatusProjection } from './agent-status-projection' + +export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { + // Some callers/tests build partial pre-browser states; treat missing slices as empty. + return state.browserTabsByWorktree ?? EMPTY_BROWSER_TABS_BY_WORKTREE +} + +export function getBrowserPagesByWorkspace(state: AppState): AppState['browserPagesByWorkspace'] { + return state.browserPagesByWorkspace ?? EMPTY_BROWSER_PAGES_BY_WORKSPACE +} + +export function resolveRuntimeTerminalTitle( + tab: Pick< + TerminalTab, + 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' + >, + generatedTitlesEnabled: boolean, + liveTitle = tab.title +): string { + return resolveTerminalTabTitle({ ...tab, title: liveTitle }, generatedTitlesEnabled, liveTitle) +} + +export function buildRuntimeMobileTabsProjection( + tabsByWorktree: AppState['tabsByWorktree'] +): string { + if (graphState.cachedTabsProjection?.source === tabsByWorktree) { + return graphState.cachedTabsProjection.projection + } + + const previousEntries = graphState.cachedTabsProjection?.entries + const entries = new Map< + string, + { + tabs: NonNullable + worktreeIdJson: string + projection: string + } + >() + const parts: string[] = [] + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const previous = previousEntries?.get(worktreeId) + const entry = + previous?.tabs === tabs + ? previous + : { + tabs, + worktreeIdJson: previous?.worktreeIdJson ?? JSON.stringify(worktreeId), + projection: JSON.stringify( + tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + quickCommandLabel: tab.quickCommandLabel, + aiVaultTitle: tab.aiVaultTitle, + generatedTitle: tab.generatedTitle, + customTitle: tab.customTitle, + launchAgent: tab.launchAgent + })) + ) + } + entries.set(worktreeId, entry) + parts.push(`${entry.worktreeIdJson}:${entry.projection}`) + } + graphState.cachedTabsProjection = { + source: tabsByWorktree, + entries, + projection: `{${parts.join(',')}}` + } + return graphState.cachedTabsProjection.projection +} + +export function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { + return JSON.stringify( + openFiles.map((file) => ({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + diffSource: file.diffSource, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + deleteUntouchedOnClose: file.deleteUntouchedOnClose, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + })) + ) +} + +export function buildRuntimeMobileBrowserProjection(state: AppState): string { + const browserTabsByWorktree = getBrowserTabsByWorktree(state) + const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) + return JSON.stringify({ + workspacesByWorktree: Object.fromEntries( + Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [ + worktreeId, + workspaces.map((workspace) => ({ + id: workspace.id, + activePageId: workspace.activePageId, + title: workspace.title, + url: workspace.url, + loading: workspace.loading, + canGoBack: workspace.canGoBack, + canGoForward: workspace.canGoForward + })) + ]) + ), + pagesByWorkspace: Object.fromEntries( + Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ + workspaceId, + pages.map((page) => ({ + id: page.id, + title: page.title, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward + })) + ]) + ) + }) +} + +export function buildRuntimeMobileEditorDraftsProjection( + editorDrafts: AppState['editorDrafts'] +): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) + ) + ) +} + +export function stableHashString(value: string): string { + let hash = 2166136261 + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return `draft:${value.length}:${(hash >>> 0).toString(16)}` +} + +export function buildRuntimeMobileAgentStatusProjectionForState( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] | undefined +): string { + return buildRuntimeMobileAgentStatusProjection( + agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY + ) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/types.ts b/src/renderer/src/runtime/sync-runtime-graph/types.ts new file mode 100644 index 00000000000..85b1d961d5f --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/types.ts @@ -0,0 +1,142 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store/types' +import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' + +export type RuntimeMobileSessionSyncKey = { + // Reference changes signal layout/title updates without stringifying thousands of tabs. + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] + nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] + folderWorkspaces: AppState['folderWorkspaces'] + groupsByWorktree: AppState['groupsByWorktree'] + activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] + layoutByWorktree: AppState['layoutByWorktree'] + unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] + tabBarOrderByWorktree: AppState['tabBarOrderByWorktree'] + activeFileId: AppState['activeFileId'] + activeFileIdByWorktree: AppState['activeFileIdByWorktree'] + activeTabType: AppState['activeTabType'] + activeTabTypeByWorktree: AppState['activeTabTypeByWorktree'] + activeTabId: AppState['activeTabId'] + activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree'] + agentStatusEpoch: number + agentStatusProjection: string + generatedTabTitlesEnabled: boolean + systemPrefersDark: boolean | null + terminalThemeProjection: string + tabsProjection: string + openFilesProjection: string + browserProjection: string + editorDraftsProjection: string +} + +export type RegisteredTerminalTab = { + tabId: string + worktreeId: string + getManager: () => PaneManager | null + getContainer: () => HTMLDivElement | null + getPtyIdForPane: (paneId: number) => string | null + getTabWideAgentHintLeafId: () => string | null +} + +export type OpenFileByWorktreeAndId = Map> +export type OpenFileIndexes = { + byWorktreeAndId: OpenFileByWorktreeAndId + idsByWorktree: Map +} +export type FallbackEditorTabTarget = { + tabId: string + groupId: string | null +} +export type TabsProjectionCacheEntry = { + tabs: NonNullable + worktreeIdJson: string + projection: string +} +export type TabsProjectionCache = { + source: AppState['tabsByWorktree'] + entries: Map + projection: string +} +export type AgentStatusProjectionCacheEntry = { + entry: AppState['agentStatusByPaneKey'][string] + projection: string +} +export type AgentStatusProjectionCache = { + source: AppState['agentStatusByPaneKey'] + entries: Map + projection: string +} +export type MobileSessionAgentStatusByWorktree = ReadonlyMap< + string, + ReadonlyMap +> +/** Slices shared by every worktree in one publication; derived from `AppState` exactly once. */ +export type MobileSessionPublicationInputs = { + browserTabsByWorktree: AppState['browserTabsByWorktree'] + openFileIndexes: OpenFileIndexes + editorDraftVersionByFileId: ReadonlyMap + agentStatusByWorktreeId: MobileSessionAgentStatusByWorktree + generatedTitlesEnabled: boolean + terminalTheme: RuntimeMobileTerminalTheme | undefined +} +/** + * Live PaneManager/DOM reads for one mounted terminal tab, captured once per + * publication. + * + * Why: builders read this instead of the registry, so live state the memo + * cannot witness is unrepresentable — mounted worktrees memoize like the rest + * instead of rebuilding on every publication. + */ +export type MountedTerminalSurfaceCapture = { + paneLeafIds: readonly string[] + hasLiveActivePane: boolean + liveActiveLeafId: string | null + liveLayoutRoot: TerminalPaneLayoutNode | null + numericPaneIdByLeafId: ReadonlyMap + ptyIdByNumericPaneId: ReadonlyMap + tabWideAgentHintLeafId: string | null +} +/** + * One worktree's complete mobile-snapshot input set. + * + * Why: every builder below takes this instead of `AppState`, so the compiler — + * not a reviewer — proves what a worktree's snapshot actually depends on. + */ +export type MobileSessionWorktreeInputs = { + worktreeId: string + terminalTabs: AppState['tabsByWorktree'][string] + browserWorkspaces: AppState['browserTabsByWorktree'][string] + unifiedTabs: AppState['unifiedTabsByWorktree'][string] + groups: AppState['groupsByWorktree'][string] + tabBarOrder: AppState['tabBarOrderByWorktree'][string] | undefined + activeGroupId: string | null + tabGroupLayout: TabGroupLayoutNode | undefined + openFilesById: ReadonlyMap | undefined + openFileIds: readonly string[] + terminalLayoutByTabId: ReadonlyMap + paneTitlesByTabId: ReadonlyMap + launchDraftByPaneKey: ReadonlyMap< + string, + NonNullable[string] + > + agentStatusByPaneKey: ReadonlyMap + editorDraftVersionByFileId: ReadonlyMap + pagesByBrowserWorkspaceId: ReadonlyMap< + string, + NonNullable[string] + > + certificateFailureByBrowserPageId: ReadonlyMap< + string, + NonNullable[string] + > + activeEditorFileId: string | null + activeEditorTabType: AppState['activeTabType'] | null + activeTerminalTabId: string | null + activeBrowserWorkspaceId: string | null + generatedTitlesEnabled: boolean + terminalTheme: RuntimeMobileTerminalTheme | undefined + mountedSurfaceCaptureByTabId: ReadonlyMap +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index fd6b3f86c50..693fb3133cb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1,6159 +1,55 @@ -/* eslint-disable max-lines -- web session-tab sync reconciles terminal, unified-tab, group, and PTY maps atomically to avoid split-brain tab state */ -import { useEffect, useLayoutEffect, useRef } from 'react' -import type { AppState } from '../store' -import { useAppStore } from '../store' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { toRuntimeExecutionHostId } from '../../../shared/execution-host' -import { - AGENT_STATUS_STALE_AFTER_MS, - pickParsedAgentStatusPayload, - type AgentStatusEntry -} from '../../../shared/agent-status-types' -import { agentEntryCompletionAt } from '../../../shared/agent-completion-time' -import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-field-normalization' -import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' -import type { - RuntimeMobileSessionTabsResult, - RuntimeMobileSessionAgentTab, - RuntimeMobileSessionTabsRemovedResult, - RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionFileTab, - RuntimeMobileSessionMarkdownTab, - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionTerminalClientTab -} from '../../../shared/runtime-types' -import { hostSnapshotAffirmsClientHostedPages } from './host-session-snapshot-authority' -import type { - BrowserCertificateFailure, - BrowserPage, - BrowserWorkspace -} from '../../../shared/browser-workspace-types' -import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import { reconcileClientOwnedTabPlacement } from './web-session-client-owned-tab-placement' -import { - clearWebSessionTerminalPlacementsForEnvironment, - clearWebSessionTerminalPlacementsForWorktree, - peekWebSessionTerminalPlacementGroup -} from './web-session-terminal-placement' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' -import type { OpenFile } from '../store/slices/editor' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { - buildRetiredTerminalTabStateSweepPatch, - type RetiredTerminalTabSweepState -} from '../store/slices/retired-terminal-tab-state-sweep' -import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' -import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' -import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' -import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' -import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - clearHostSessionMirrorHydration, - markHostSessionMirrorHydrated, - markHostSessionMirrorWorktreeHydrated -} from './host-session-mirror-hydration' -import { probeHostLiveTerminals } from './host-live-terminal-probe' -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -import { - createWebRuntimeSessionTerminal, - HOST_TERMINAL_SURFACE_SEPARATOR, - isWebTerminalSurfaceTabId, - toWebTerminalSurfaceTabId, - WEB_TERMINAL_SURFACE_TAB_PREFIX -} from './web-runtime-session' -import { - normalizeCompatibleAgentStatusEntryForOwner, - normalizeCompatibleAgentTitleForOwner -} from '../../../shared/agent-title-owner' -import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner' -import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' -import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -import { readBrowserClientHostId } from './browser-client-host-identity' -import { - clearWebSessionFocusIntent, - clearWebSessionFocusIntentsForOwner, - peekWebSessionFocusIntent, - resolveWebSessionSiblingVisibleTabId, - resolveWebSessionVisibleTabId -} from './web-session-focus-intent' -import { - clearWebSessionCloseIntentsForOwner, - clearWebSessionCloseIntentsForWorktree, - isWebSessionCloseIntentPending, - reconcileWebSessionCloseIntents -} from './web-session-close-intent' -import { - clearWebSessionReorderIntentsForOwner, - clearWebSessionReorderIntentsForWorktree, - resolveWebSessionReorderedOrder -} from './web-session-reorder-intent' -import { - beginWebRuntimeWakeTerminalRespawn, - clearAllWebRuntimeWakeTerminalRespawn, - clearWebRuntimeWakeTerminalRespawnForWorktree, - endWebRuntimeWakeTerminalRespawn, - shouldSkipWebRuntimeWakeTerminalRespawn -} from './web-runtime-wake-terminal-respawn' -import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' -import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' -import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events' -import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' -import { - clearWebAgentSessionHandoff, - clearWebAgentSessionHandoffsForEnvironment, - clearWebAgentSessionHandoffsForWorktree, - isWebAgentSessionHandoffPostCreateSnapshotConfirmed, - resolveWebAgentSessionHandoff -} from './web-agent-session-handoff' -import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { useRuntimeSessionMirrorEnvironmentKey } from './use-runtime-session-mirror-environment-key' -import { - installWindowVisibilitySubscriptionParking, - type WindowVisibilitySubscriptionSpec -} from './window-visibility-subscription-parking' -import { - clearWebSessionBrowserPlacementsForEnvironment, - clearWebSessionBrowserPlacementsForWorktree, - isWebSessionBrowserPlacementGroupReserved, - markWebSessionBrowserPlacementAdopted, - peekWebSessionBrowserPlacementGroup, - resetWebSessionBrowserPlacementsForTests -} from './web-session-browser-placement' -import { suppressE2eWebRuntimeBrowserSnapshot } from './web-runtime-browser-creation-e2e-fault' -import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications' -import { - buildWebSessionExistingTabIndex, - type WebSessionExistingTabIndex -} from './web-session-existing-tab-index' -import { - sameRuntimeBrowserPlacement, - type RuntimeBrowserPlacement -} from '../../../shared/runtime-browser-placement' - -const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' -export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 - -type SessionTabsStreamEvent = - | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { - type: 'snapshots' - snapshots: RuntimeMobileSessionTabsResult[] - authoritative?: boolean - } - | { type: 'end' } - -type SessionTabsListAllResult = { - snapshots: RuntimeMobileSessionTabsResult[] - authoritative?: boolean -} - -type SnapshotFreshness = { - publicationEpoch: string - snapshotVersion: number -} - -type ReceivedSessionTabsSnapshot = SnapshotFreshness & { - receivedFrame: number - runtimeId?: string -} - -/** - * Runtime ids identify a host process, unlike publication epochs which may be - * minted by several publishers. Retain a bounded predecessor set so a frame - * queued by a restarted host cannot be mistaken for a fresh publication. - */ -type RetiredValueHistory = { - current: string | null - retired: string[] -} - -function hasRetiredValue(history: RetiredValueHistory | undefined, value: string): boolean { - return history?.retired.includes(value) ?? false -} - -function noteRetiredValue( - history: RetiredValueHistory | undefined, - value: string, - retiredLimit: number -): RetiredValueHistory { - if (!history) { - return { current: value, retired: [] } - } - if (history.current === value) { - return history - } - if (history.current && !history.retired.includes(history.current)) { - history.retired.push(history.current) - if (history.retired.length > retiredLimit) { - history.retired.splice(0, history.retired.length - retiredLimit) - } - } - history.current = value - return history -} - -type SessionTabsRuntimeHistory = RetiredValueHistory - -/** - * A host restart changes the publication epoch, but frames from the previous - * epoch can still be queued on a sibling subscription. Keep a small history - * of epochs that have already been superseded so those delayed frames cannot - * roll the mirror back after the replacement epoch is accepted. - */ -type SessionTabsPublicationEpochHistory = RetiredValueHistory - -type SessionTabsRecoveryState = { - pendingCount: number -} - -type SessionTabsRemovalFence = { - receivedFrame: number - recoveryState: SessionTabsRecoveryState - pendingCount: number -} - -export type WebSessionTabsSnapshotApplyOptions = { - contentScope?: 'all' | 'agent-session' - preserveLocalLayout?: boolean - terminalPtyMode?: 'local' | 'remote' -} - -type TrackedWebSessionTabsWorktree = { - worktree: string - freshness: SnapshotFreshness -} - -type VisibilityResumeOmission = { - baseline: SnapshotFreshness - environmentId: string - inventoryReceivedFrame: number - superseded: boolean - visibilityGeneration: number -} - -const latestSessionTabsSnapshotByWorktree = new Map() -const replayableSessionTabsSnapshotByWorktree = new Map() -const latestReceivedSessionTabsSnapshotByWorktree = new Map() -const sessionTabsRuntimeHistoryByEnvironment = new Map() -const sessionTabsPublicationEpochHistoryByWorktree = new Map< - string, - SessionTabsPublicationEpochHistory ->() -const latestReceivedSessionTabsFrameByEnvironment = new Map() -const latestReceivedSessionTabsInventoryFrameByEnvironment = new Map() -const latestSessionTabsRemovalFenceByWorktree = new Map() -const sessionTabsRecoveryStateByWorktree = new Map() -const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() -const sessionTabsEnvironmentsByWorktree = new Map>() -const sessionTabsTrackingGenerationByEnvironment = new Map() -const lastHostTerminalTabCountByWorktree = new Map() -const hostSessionTabIdByLocalKey = new Map() -const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< - string, - Map> ->() -// Why: a delayed host stamp belongs to the client boundary seen with its preceding ordered frame. -const hostWorkingClientBoundaryByPaneKey = new Map< - string, - { - hostStateStartedAt: number - hostPrompt: string - clientStateStartedAt: number - stamped: boolean - } ->() -const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 -let receivedSessionTabsFrameSequence = 0 - -type TerminalSurface = RuntimeMobileSessionTerminalClientTab -type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } -type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } -type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab - -type MirroredAgentTab = { - hostTabId: string - unifiedTab: Tab -} - -type MirroredTerminalTab = { - tab: TerminalTab - hostTabId: string - ptyIds: string[] - layout: TerminalLayoutSnapshot - retainedSurfaceByPrunedLeafId?: ReadonlyMap -} - -type MirroredBrowserTab = { - workspace: BrowserWorkspace - page: BrowserPage - certificateFailure: BrowserCertificateFailure | null - remotePageId: string - placement?: RuntimeBrowserPlacement - unifiedTab: Tab - hostTabId: string - clientGroupId?: string -} - -type MirroredEditorTab = { - file: OpenFile - unifiedTab: Tab - hostTabId: string -} - -export type WebSessionTabsSyncState = Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' - | 'activeWorktreeId' - | 'agentStatusByPaneKey' - | 'agentStatusEpoch' - | 'browserPagesByWorkspace' - | 'browserCertificateFailuresByPageId' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'ptyIdsByTabId' - | 'remoteBrowserPageHandlesByPageId' - | 'tabBarOrderByWorktree' - | 'tabsByWorktree' - | 'terminalLayoutsByTabId' - | 'unifiedTabsByWorktree' - | 'unreadTerminalTabs' - | 'sortEpoch' -> & - Partial< - Pick< - AppState, - | 'acknowledgedAgentsByPaneKey' - | 'agentLaunchConfigByPaneKey' - | 'automaticAgentResumeClaimsByTabId' - | 'migrationUnsupportedByPtyId' - | 'paneForegroundAgentByPaneKey' - | 'pendingStartupByTabId' - | 'recentlyClosedAgentStatusTabIds' - | 'recentlyRetiredAgentStatusPaneKeys' - | 'retainedAgentsByPaneKey' - | 'retentionSuppressedPaneKeys' - > - > - -type WebSessionTabsBatchRecordKey = - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'activeTabTypeByWorktree' - | 'agentStatusByPaneKey' - | 'automaticAgentResumeClaimsByTabId' - | 'browserCertificateFailuresByPageId' - | 'browserPagesByWorkspace' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'pendingStartupByTabId' - | 'ptyIdsByTabId' - | 'remoteBrowserPageHandlesByPageId' - | 'tabBarOrderByWorktree' - | 'tabsByWorktree' - | 'terminalLayoutsByTabId' - | 'unifiedTabsByWorktree' - | 'unreadTerminalTabs' - -/** Open files bucketed by worktree. A snapshot only reconciles its own worktree, so a - * batch can decide there is nothing to do without walking every open file in the app. - * `source` pins the array it describes; each rebuild updates it in place instead of - * re-bucketing, so a batch never pays for the whole array twice. */ -type WebSessionOpenFilesIndex = { - source: readonly OpenFile[] - byWorktree: Map -} - -type WebSessionTabsBatchContext = { - agentPaneKeysByTabId: Map> | null - changedRecords: Set - openFilesIndex: WebSessionOpenFilesIndex | null -} - -function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { - return ( - Boolean(value) && - typeof value === 'object' && - Array.isArray((value as { snapshots?: unknown }).snapshots) - ) -} - -function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string { - return `${environmentId}:${worktreeId}` -} - -function advancesSessionTabsFreshness( - snapshot: RuntimeMobileSessionTabsResult, - baseline: SnapshotFreshness -): boolean { - return ( - snapshot.publicationEpoch !== baseline.publicationEpoch || - snapshot.snapshotVersion > baseline.snapshotVersion - ) -} - -function getTrackedWebSessionTabsWorktrees(environmentId: string): TrackedWebSessionTabsWorktree[] { - return [...(trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? [])].flatMap( - (worktree) => { - const key = sessionTabsFreshnessKey(environmentId, worktree) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - return freshness - ? [ - { - worktree, - freshness - } - ] - : [] - } - ) -} - -function trackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { - const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? new Set() - worktrees.add(worktreeId) - trackedSessionTabsWorktreeIdsByEnvironment.set(environmentId, worktrees) -} - -function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { - const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) - if (!worktrees) { - return - } - worktrees.delete(worktreeId) - if (worktrees.size === 0) { - trackedSessionTabsWorktreeIdsByEnvironment.delete(environmentId) - } -} - -function nextReceivedSessionTabsFrame(): number { - return (receivedSessionTabsFrameSequence += 1) -} - -const SESSION_TABS_RETIRED_EPOCH_LIMIT = 8 -const SESSION_TABS_RETIRED_RUNTIME_ID_LIMIT = 8 - -function normalizeSessionTabsRuntimeId(runtimeId: unknown): string | undefined { - if (typeof runtimeId !== 'string') { - return undefined - } - const trimmed = runtimeId.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function getSessionTabsRuntimeIdFromResponse( - response: RuntimeRpcResponse -): string | undefined { - return response.ok ? normalizeSessionTabsRuntimeId(response._meta?.runtimeId) : undefined -} - -function isRetiredSessionTabsRuntimeId(environmentId: string, runtimeId: string): boolean { - return hasRetiredValue(sessionTabsRuntimeHistoryByEnvironment.get(environmentId), runtimeId) -} - -function noteSessionTabsRuntimeId( - environmentId: string, - runtimeId: string -): SessionTabsRuntimeHistory { - const history = noteRetiredValue( - sessionTabsRuntimeHistoryByEnvironment.get(environmentId), - runtimeId, - SESSION_TABS_RETIRED_RUNTIME_ID_LIMIT - ) - sessionTabsRuntimeHistoryByEnvironment.set(environmentId, history) - return history -} - -function isCurrentSessionTabsRuntimeId(environmentId: string, runtimeId: string): boolean { - const history = sessionTabsRuntimeHistoryByEnvironment.get(environmentId) - return history === undefined || history.current === runtimeId -} - -function isCurrentSessionTabsRuntimeFrame(environmentId: string, runtimeId?: string): boolean { - return ( - runtimeId === undefined || - (!isRetiredSessionTabsRuntimeId(environmentId, runtimeId) && - isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) - ) -} - -/** Returns false for a runtime identity already superseded on this environment. */ -function acceptSessionTabsRuntimeId( - environmentId: string, - runtimeId: string, - receivedFrame?: number -): boolean { - const history = sessionTabsRuntimeHistoryByEnvironment.get(environmentId) - const latestReceivedFrame = latestReceivedSessionTabsFrameByEnvironment.get(environmentId) ?? 0 - // A late bootstrap response may carry the predecessor process id. Do not - // let that older frame retire the runtime that already published newer data. - if ( - receivedFrame !== undefined && - receivedFrame < latestReceivedFrame && - history !== undefined && - history.current !== runtimeId - ) { - return false - } - if (isRetiredSessionTabsRuntimeId(environmentId, runtimeId)) { - return false - } - noteSessionTabsRuntimeId(environmentId, runtimeId) - return true -} - -function isRetiredSessionTabsPublicationEpoch(key: string, publicationEpoch: string): boolean { - return hasRetiredValue(sessionTabsPublicationEpochHistoryByWorktree.get(key), publicationEpoch) -} - -function noteSessionTabsPublicationEpoch( - key: string, - publicationEpoch: string -): SessionTabsPublicationEpochHistory { - const history = noteRetiredValue( - sessionTabsPublicationEpochHistoryByWorktree.get(key), - publicationEpoch, - SESSION_TABS_RETIRED_EPOCH_LIMIT - ) - sessionTabsPublicationEpochHistoryByWorktree.set(key, history) - return history -} - -function recordReceivedWebSessionTabsSnapshot( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number | undefined = undefined, - runtimeId?: string, - source: 'stream' | 'bootstrap' = 'stream' -): number { - const frame = receivedFrame ?? nextReceivedSessionTabsFrame() - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const current = latestReceivedSessionTabsSnapshotByWorktree.get(key) - // A bootstrap listAll reserves its frame before the request starts. If a - // stream frame for this worktree arrived meanwhile, the late list is stale - // evidence and must not advance epoch history. - if (source === 'bootstrap' && current && frame < current.receivedFrame) { - return frame - } - if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId, frame)) { - return frame - } - recordReceivedWebSessionTabsEnvironmentFrame(environmentId, frame) - const publicationEpoch = snapshot.publicationEpoch - const history = sessionTabsPublicationEpochHistoryByWorktree.get(key) - const isRetired = history?.retired.includes(publicationEpoch) ?? false - if (isRetired) { - return frame - } - if (!history) { - noteSessionTabsPublicationEpoch(key, publicationEpoch) - } else if (history.current !== publicationEpoch) { - noteSessionTabsPublicationEpoch(key, publicationEpoch) - } - // Stream delivery order is the freshest evidence even when a host's version - // counter briefly moves backwards (for example across a visibility resume). - // Bootstrap listAll responses retain version/epoch ordering so a late - // response cannot replace a stream frame received after the request began. - if ( - source === 'stream' || - !current || - current.publicationEpoch !== publicationEpoch || - snapshot.snapshotVersion > current.snapshotVersion || - (snapshot.snapshotVersion === current.snapshotVersion && current.receivedFrame <= frame) - ) { - latestReceivedSessionTabsSnapshotByWorktree.set(key, { - receivedFrame: frame, - publicationEpoch, - snapshotVersion: snapshot.snapshotVersion, - ...(runtimeId ? { runtimeId } : {}) - }) - if ((snapshot as { removed?: unknown }).removed === true) { - recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, frame) - } - } - return frame -} - -function recordReceivedWebSessionTabsEnvironmentFrame( - environmentId: string, - receivedFrame: number -): void { - const current = latestReceivedSessionTabsFrameByEnvironment.get(environmentId) ?? 0 - if (receivedFrame > current) { - latestReceivedSessionTabsFrameByEnvironment.set(environmentId, receivedFrame) - } -} - -function recordReceivedWebSessionTabsInventory(environmentId: string): number { - const receivedFrame = nextReceivedSessionTabsFrame() - recordReceivedWebSessionTabsEnvironmentFrame(environmentId, receivedFrame) - latestReceivedSessionTabsInventoryFrameByEnvironment.set(environmentId, receivedFrame) - return receivedFrame -} - -function beginWebSessionTabsSnapshotRecovery( - environmentId: string, - worktreeId: string, - receivedFrame: number -): () => void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 } - recoveryState.pendingCount += 1 - sessionTabsRecoveryStateByWorktree.set(key, recoveryState) - let settled = false - return () => { - if (settled) { - return - } - settled = true - recoveryState.pendingCount -= 1 - if ( - recoveryState.pendingCount === 0 && - sessionTabsRecoveryStateByWorktree.get(key) === recoveryState - ) { - sessionTabsRecoveryStateByWorktree.delete(key) - } - const removalFence = latestSessionTabsRemovalFenceByWorktree.get(key) - if ( - removalFence?.recoveryState === recoveryState && - receivedFrame < removalFence.receivedFrame - ) { - removalFence.pendingCount -= 1 - if (removalFence.pendingCount === 0) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - } -} - -function recordReceivedWebSessionTabsRemoval( - environmentId: string, - worktreeId: string, - receivedFrame: number -): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const current = latestSessionTabsRemovalFenceByWorktree.get(key) - if (current && current.receivedFrame >= receivedFrame) { - return - } - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) - if (!recoveryState || recoveryState.pendingCount === 0) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - return - } - latestSessionTabsRemovalFenceByWorktree.set(key, { - receivedFrame, - recoveryState, - pendingCount: recoveryState.pendingCount - }) - // An inventory omission/removal is a new visibility boundary. A later live - // frame may legitimately restart its version counter, while recoveries - // queued before this boundary are fenced by receivedFrame above. - latestReceivedSessionTabsSnapshotByWorktree.delete(key) -} - -function shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string -): boolean { - if ( - runtimeId && - (isRetiredSessionTabsRuntimeId(environmentId, runtimeId) || - !isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) - ) { - return false - } - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - if (isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch)) { - return false - } - const removalFrame = latestSessionTabsRemovalFenceByWorktree.get(key)?.receivedFrame - if (removalFrame !== undefined && receivedFrame < removalFrame) { - return false - } - const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key) - if (!latest || latest.receivedFrame === receivedFrame) { - return latest !== undefined - } - if (latest.publicationEpoch !== snapshot.publicationEpoch) { - return receivedFrame > latest.receivedFrame - } - return snapshot.snapshotVersion >= latest.snapshotVersion -} - -function isTrackedWebSessionTabsOmissionCurrent( - environmentId: string, - trackedWorktree: TrackedWebSessionTabsWorktree -): boolean { - const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree) - const current = latestSessionTabsSnapshotByWorktree.get(key) - return ( - current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch && - current.snapshotVersion === trackedWorktree.freshness.snapshotVersion - ) -} - -function recordAcceptedWebSessionTabsEnvironment( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult -): void { - const environments = new Set(sessionTabsEnvironmentsByWorktree.get(snapshot.worktree) ?? []) - if (snapshot.tabs.length > 0) { - environments.add(environmentId) - } else { - environments.delete(environmentId) - } - if (environments.size > 0) { - sessionTabsEnvironmentsByWorktree.set(snapshot.worktree, environments) - } else { - sessionTabsEnvironmentsByWorktree.delete(snapshot.worktree) - } -} - -function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: string): void { - const environments = new Set(sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) - environments.delete(environmentId) - if (environments.size > 0) { - sessionTabsEnvironmentsByWorktree.set(worktreeId, environments) - } else { - sessionTabsEnvironmentsByWorktree.delete(worktreeId) - } -} - -const VISIBILITY_INVENTORY_REMOVAL_EPOCH = 'visibility-inventory-removal' - -// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a -// visibility fact, never evidence that the host closed anything. -function isWebSessionTabsWorktreeRemovalFrame(snapshot: RuntimeMobileSessionTabsResult): boolean { - return ( - (snapshot as { removed?: unknown }).removed === true || - snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH - ) -} - -// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever -// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived. -function buildMissingWebSessionTabsRemovals( - environmentId: string, - trackedWorktrees: readonly TrackedWebSessionTabsWorktree[], - publishedWorktrees: ReadonlySet -): { - trackedWorktree: TrackedWebSessionTabsWorktree - snapshot: RuntimeMobileSessionTabsRemovedResult -}[] { - return trackedWorktrees - .filter( - (trackedWorktree) => - !publishedWorktrees.has(trackedWorktree.worktree) && - isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree) - ) - .map((trackedWorktree) => ({ - trackedWorktree, - snapshot: { - worktree: trackedWorktree.worktree, - publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH, - snapshotVersion: 0, - removed: true, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - })) -} - -function rememberHostTerminalTabCount( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult -): void { - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const terminalCount = snapshot.tabs.filter((tab) => tab.type === 'terminal').length - lastHostTerminalTabCountByWorktree.set(key, terminalCount) -} - -export function getLastKnownHostTerminalTabCount( - environmentId: string, - worktreeId: string -): number { - return ( - lastHostTerminalTabCountByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) ?? 0 - ) -} - -export function getLatestWebSessionTabsPublicationEpoch( - environmentId: string, - worktreeId: string -): string | null { - return ( - latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) - ?.publicationEpoch ?? null - ) -} - -// Why: a replay may repeat the current epoch/version; permit only that exact -// identity once so an older concurrent frame cannot bypass monotonic ordering. -export function acceptReplayedWebSessionTabsSnapshot( - environmentId: string, - worktreeId: string -): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const current = latestSessionTabsSnapshotByWorktree.get(key) - if (current) { - replayableSessionTabsSnapshotByWorktree.set(key, current) - } -} - -/** - * A frame's fate, paired with whether that fate is host evidence for the - * worktree — the mirror latch reads the pair, never a bare boolean. - * - * Settling on a REJECTED frame is correct only when the store already holds an - * equal-or-newer accepted view of that worktree, so every rejection branch has - * to state its answer here. A `false` that meant "the mirror never writes this workspace" - * would otherwise read as staleness and drain parked resume work — the sweep - * would treat an undecidable pane as decidable and fork a live agent. - */ -export type WebSessionTabsSnapshotDecision = { - readonly apply: boolean - readonly settlesHostMirror: boolean -} - -/** The frame's own store patch carries the verdict. */ -const WEB_SESSION_TABS_FRAME_APPLIED = { - apply: true, - settlesHostMirror: true -} as const satisfies WebSessionTabsSnapshotDecision - -/** Outranked by an equal-or-newer accepted view: the host HAS answered here. */ -const WEB_SESSION_TABS_FRAME_OUTRANKED = { - apply: false, - settlesHostMirror: true -} as const satisfies WebSessionTabsSnapshotDecision - -/** Discarded because the mirror never writes this workspace at all, so no - * accepted view backs the rejection and the pane stays unaccounted for. */ -const WEB_SESSION_TABS_FRAME_UNMIRRORED = { - apply: false, - settlesHostMirror: false -} as const satisfies WebSessionTabsSnapshotDecision - -/** Why: the floating workspace is a local synthetic terminal the mirror never - * writes, so its frames are no evidence about any mirrored pane. */ -function isHostMirroredWorktree(worktreeId: string): boolean { - return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID -} - -export function shouldApplyWebSessionTabsSnapshot( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - runtimeId?: string -): boolean { - return decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId).apply -} - -export function decideWebSessionTabsSnapshot( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - runtimeId?: string -): WebSessionTabsSnapshotDecision { - if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId)) { - return WEB_SESSION_TABS_FRAME_OUTRANKED - } - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - if ((snapshot as { removed?: unknown }).removed === true) { - // Why: removed worktrees can stop publishing, so clean up their tracking now instead of waiting for a replacement snapshot that may never arrive. - // Retain the removal epoch transition before dropping the live freshness - // record; delayed sibling frames from the predecessor stay fenced. - // Inventory omissions use a client-only sentinel epoch; recording that - // sentinel would retire the host epoch and reject the next live frame. - if (snapshot.publicationEpoch !== VISIBILITY_INVENTORY_REMOVAL_EPOCH) { - noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) - } - clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) - queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - return WEB_SESSION_TABS_FRAME_APPLIED - } - if (!isHostMirroredWorktree(snapshot.worktree)) { - // Why: a remote empty same-id snapshot would delete the user's local floating tabs. - return WEB_SESSION_TABS_FRAME_UNMIRRORED - } - const current = latestSessionTabsSnapshotByWorktree.get(key) - if (isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch)) { - return WEB_SESSION_TABS_FRAME_OUTRANKED - } - const replayable = replayableSessionTabsSnapshotByWorktree.get(key) - const isExactCurrentReplay = Boolean( - current && - replayable && - current.publicationEpoch === replayable.publicationEpoch && - current.snapshotVersion === replayable.snapshotVersion && - snapshot.publicationEpoch === replayable.publicationEpoch && - snapshot.snapshotVersion === replayable.snapshotVersion - ) - // Why: reject stale snapshots only within an epoch; host restarts create a new epoch. - if ( - current && - current.publicationEpoch === snapshot.publicationEpoch && - snapshot.snapshotVersion <= current.snapshotVersion && - !isExactCurrentReplay - ) { - return WEB_SESSION_TABS_FRAME_OUTRANKED - } - rememberHostTerminalTabCount(environmentId, snapshot) - replayableSessionTabsSnapshotByWorktree.delete(key) - noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) - latestSessionTabsSnapshotByWorktree.set(key, { - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion - }) - trackWebSessionTabsWorktree(environmentId, snapshot.worktree) - recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) - // Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write. - queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - return WEB_SESSION_TABS_FRAME_APPLIED -} - -export function shouldBootstrapInitialWebRuntimeTerminal(args: { - event: SessionTabsStreamEvent - activeWorktreeId: string - requestedInitialTerminal: boolean - snapshotIsFresh: boolean - localTerminalCount: number -}): boolean { - return ( - args.snapshotIsFresh && - args.event.type === 'snapshot' && - args.event.tabs.length === 0 && - args.localTerminalCount === 0 && - !args.requestedInitialTerminal && - args.activeWorktreeId === args.event.worktree - ) -} - -export function shouldRespawnWebRuntimeTerminalAfterWake(args: { - event: SessionTabsStreamEvent - activeWorktreeId: string - requestedRespawnAfterWake: boolean - snapshotIsFresh: boolean - localTerminalCount: number - hasLiveLocalPty: boolean - skipWakeRespawn?: boolean -}): boolean { - if ( - !args.snapshotIsFresh || - args.requestedRespawnAfterWake || - args.skipWakeRespawn === true || - args.localTerminalCount === 0 || - args.hasLiveLocalPty || - (args.event.type !== 'snapshot' && args.event.type !== 'updated') - ) { - return false - } - if (args.activeWorktreeId !== args.event.worktree) { - return false - } - const hostTerminalTabCount = args.event.tabs.filter((tab) => tab.type === 'terminal').length - return hostTerminalTabCount === 0 -} - -export function shouldSyncRuntimeSessionTabs(args: { - activeWorktreeId?: string | null - activeWorktreeRuntimeEnvironmentId?: string | null - workspaceSessionReady: boolean -}): boolean { - const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim() - if (!environmentId || !args.workspaceSessionReady) { - return false - } - return Boolean(args.activeWorktreeId?.trim()) -} - -export function shouldSyncAllRuntimeSessionTabs(args: { - activeRuntimeEnvironmentId: string | null | undefined - workspaceSessionReady: boolean -}): boolean { - const environmentId = args.activeRuntimeEnvironmentId?.trim() - return Boolean(environmentId && args.workspaceSessionReady) -} - -export function resetWebSessionTabsSnapshotFreshnessForTests(): void { - latestSessionTabsSnapshotByWorktree.clear() - replayableSessionTabsSnapshotByWorktree.clear() - latestReceivedSessionTabsSnapshotByWorktree.clear() - sessionTabsRuntimeHistoryByEnvironment.clear() - sessionTabsPublicationEpochHistoryByWorktree.clear() - latestReceivedSessionTabsFrameByEnvironment.clear() - latestReceivedSessionTabsInventoryFrameByEnvironment.clear() - latestSessionTabsRemovalFenceByWorktree.clear() - sessionTabsRecoveryStateByWorktree.clear() - trackedSessionTabsWorktreeIdsByEnvironment.clear() - sessionTabsEnvironmentsByWorktree.clear() - receivedSessionTabsFrameSequence = 0 - lastHostTerminalTabCountByWorktree.clear() - hostSessionTabIdByLocalKey.clear() - hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() - hostWorkingClientBoundaryByPaneKey.clear() - resetWebSessionBrowserPlacementsForTests() -} - -export function _getWebSessionTabsTrackingCountsForTest(): { - freshness: number - hostMappings: number - hostMappingWorktrees: number -} { - let hostMappingWorktrees = 0 - for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { - hostMappingWorktrees += mappingKeysByWorktree.size - } - return { - freshness: latestSessionTabsSnapshotByWorktree.size, - hostMappings: hostSessionTabIdByLocalKey.size, - // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. - hostMappingWorktrees - } -} - -export function _getWebSessionTabsRecoveryTrackingCountsForTest(): { - pendingRecoveries: number - removalFrames: number -} { - return { - pendingRecoveries: sessionTabsRecoveryStateByWorktree.size, - removalFrames: latestSessionTabsRemovalFenceByWorktree.size - } -} - -function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - latestSessionTabsSnapshotByWorktree.delete(key) - replayableSessionTabsSnapshotByWorktree.delete(key) - latestReceivedSessionTabsSnapshotByWorktree.delete(key) - // Keep the bounded epoch history as a tombstone fence. A sibling stream can - // still deliver an old frame after this removal has cleared the live view. - untrackWebSessionTabsWorktree(environmentId, worktreeId) - removeWebSessionTabsEnvironment(environmentId, worktreeId) - lastHostTerminalTabCountByWorktree.delete(key) - clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) - clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) - clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) - clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) - clearHostSessionTabIdMappings(environmentId, worktreeId) - clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId) - clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId) -} - -export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { - const trimmedEnvironmentId = environmentId.trim() - if (!trimmedEnvironmentId) { - return - } - const keyPrefix = `${trimmedEnvironmentId}:` - sessionTabsTrackingGenerationByEnvironment.set( - trimmedEnvironmentId, - (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 - ) - for (const key of latestSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of replayableSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - replayableSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of latestReceivedSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestReceivedSessionTabsSnapshotByWorktree.delete(key) - } - } - sessionTabsRuntimeHistoryByEnvironment.delete(trimmedEnvironmentId) - for (const key of sessionTabsPublicationEpochHistoryByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsPublicationEpochHistoryByWorktree.delete(key) - } - } - latestReceivedSessionTabsFrameByEnvironment.delete(trimmedEnvironmentId) - latestReceivedSessionTabsInventoryFrameByEnvironment.delete(trimmedEnvironmentId) - for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - for (const key of sessionTabsRecoveryStateByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsRecoveryStateByWorktree.delete(key) - } - } - trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) - for (const worktreeId of sessionTabsEnvironmentsByWorktree.keys()) { - removeWebSessionTabsEnvironment(trimmedEnvironmentId, worktreeId) - } - for (const key of lastHostTerminalTabCountByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - lastHostTerminalTabCountByWorktree.delete(key) - } - } - const mappingKeysByWorktree = - hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) - if (mappingKeysByWorktree) { - for (const mappingKeys of mappingKeysByWorktree.values()) { - for (const mappingKey of mappingKeys) { - hostSessionTabIdByLocalKey.delete(mappingKey) - } - } - hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) - } - clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) - clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId) - clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId) - clearHostSessionMirrorHydration(trimmedEnvironmentId) - clearAllWebRuntimeWakeTerminalRespawn() -} - -export function getWebSessionTabsTrackingGeneration(environmentId: string): number { - return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 -} - -function hostSessionTabMappingKey(args: { - environmentId: string - worktreeId: string - tabId: string -}): string { - return `${args.environmentId}:${args.worktreeId}:${args.tabId}` -} - -function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { - const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) - const mappingKeys = mappingKeysByWorktree?.get(worktreeId) - if (!mappingKeys) { - return - } - for (const mappingKey of mappingKeys) { - hostSessionTabIdByLocalKey.delete(mappingKey) - } - mappingKeysByWorktree?.delete(worktreeId) - if (mappingKeysByWorktree?.size === 0) { - hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) - } -} - -function setHostSessionTabIdMapping( - args: { environmentId: string; worktreeId: string; tabId: string }, - hostTabId: string -): void { - const mappingKey = hostSessionTabMappingKey(args) - hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) - const mappingKeysByWorktree = - hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() - const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() - mappingKeys.add(mappingKey) - mappingKeysByWorktree.set(args.worktreeId, mappingKeys) - hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) -} - -export function resolveHostSessionTabIdForWebSessionTab( - _state: WebSessionTabsSyncState, - args: { - environmentId: string - worktreeId: string - tabId: string - } -): string | null { - return ( - hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? - // Why: structured create returns canonical identity before its confirming - // snapshot; an immediate user close must already target that host tab. - resolveWebAgentSessionHandoff({ - environmentId: args.environmentId, - worktreeId: args.worktreeId, - provisionalTabId: args.tabId - }) - ) -} - -function isReadyTerminalTab( - tab: RuntimeMobileSessionTabsResult['tabs'][number] -): tab is ReadyTerminalSurface { - return tab.type === 'terminal' && tab.status === 'ready' && tab.terminal.trim().length > 0 -} - -function isTerminalSurfaceTab( - tab: RuntimeMobileSessionTabsResult['tabs'][number] -): tab is TerminalSurface { - return tab.type === 'terminal' -} - -function isReadyBrowserTab( - tab: RuntimeMobileSessionTabsResult['tabs'][number] -): tab is ReadyBrowserSurface { - return tab.type === 'browser' && typeof tab.browserPageId === 'string' && tab.browserPageId !== '' -} - -function isReadyEditorTab( - tab: RuntimeMobileSessionTabsResult['tabs'][number] -): tab is ReadyEditorSurface { - return tab.type === 'markdown' || tab.type === 'file' -} - -function isAgentSessionTab( - tab: RuntimeMobileSessionTabsResult['tabs'][number] -): tab is RuntimeMobileSessionAgentTab { - return tab.type === 'agent-session' -} - -function buildMirroredAgentTabs( - snapshot: RuntimeMobileSessionTabsResult, - hostGroupIdByTabId: ReadonlyMap, - fallbackGroupId: string, - sortOffset: number, - currentUnifiedTabs: readonly Tab[], - now: number -): MirroredAgentTab[] { - return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => { - const localId = structuredAgentSessionTabId(tab.sessionId) - const existing = currentUnifiedTabs.find( - (candidate) => candidate.contentType === 'agent-session' && candidate.id === localId - ) - return { - hostTabId: tab.id, - unifiedTab: { - id: localId, - entityId: tab.sessionId, - groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId, - worktreeId: snapshot.worktree, - contentType: 'agent-session', - agentSessionAgent: tab.agent, - label: tab.title.trim() || 'Codex Chat', - customLabel: null, - color: tab.color !== undefined ? tab.color : (existing?.color ?? null), - sortOrder: sortOffset + index, - createdAt: existing?.createdAt ?? now + sortOffset + index, - isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true - } - } - }) -} - -function localEditorFileId(tab: ReadyEditorSurface): string { - if (tab.type === 'markdown' && tab.mode === 'markdown-preview') { - return `markdown-preview::${tab.sourceFilePath}` - } - return tab.filePath -} - -function editorSourceFileId(tab: ReadyEditorSurface): string | undefined { - return tab.type === 'markdown' && tab.mode === 'markdown-preview' ? tab.sourceFilePath : undefined -} - -function isRuntimeTerminalTabForEnvironment(tab: TerminalTab, environmentId: string): boolean { - if (!tab.ptyId) { - return false - } - return getRemoteRuntimePtyEnvironmentId(tab.ptyId) === environmentId -} - -function isMirroredTerminalSurfaceId(tabId: string): boolean { - return ( - tabId.startsWith(WEB_TERMINAL_SURFACE_TAB_PREFIX) || - tabId.includes(HOST_TERMINAL_SURFACE_SEPARATOR) - ) -} - -function chooseRemoteTerminalLayout( - surfaces: readonly TerminalSurface[], - ptyIdsByLeafId: Record, - existingLayout?: TerminalLayoutSnapshot, - requestedActiveLeafId?: string -): TerminalLayoutSnapshot { - const leafIds = surfaces.map((surface) => surface.leafId) - const knownLeafIds = new Set(leafIds) - const parentLayoutSource = surfaces.find((surface) => surface.parentLayout) - const parentLayout = parentLayoutSource?.parentLayout - ? sanitizeTerminalLayoutPaneTitlesForLabels(parentLayoutSource.parentLayout, [ - parentLayoutSource.title - ]) - : undefined - const activeLeafId = - (requestedActiveLeafId && knownLeafIds.has(requestedActiveLeafId) - ? requestedActiveLeafId - : null) ?? - // Why: host title/status snapshots may still mark an agent pane active after this client selected a different split pane. - (existingLayout?.activeLeafId && knownLeafIds.has(existingLayout.activeLeafId) - ? existingLayout.activeLeafId - : null) ?? - (parentLayout?.activeLeafId && knownLeafIds.has(parentLayout.activeLeafId) - ? parentLayout.activeLeafId - : null) ?? - surfaces.find((surface) => surface.isActive)?.leafId ?? - leafIds[0] ?? - null - const expandedLeafId = - requestedActiveLeafId && - (Boolean(existingLayout?.expandedLeafId) || Boolean(parentLayout?.expandedLeafId)) - ? requestedActiveLeafId - : parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId) - ? parentLayout.expandedLeafId - : null - return { - // Why: host parentLayout is authoritative for split direction; else keep the prior client tree, then degenerate — never re-guess a direction. - root: resolveTerminalLayoutRoot({ - authoritativeRoot: parentLayout?.root, - existingRoot: existingLayout?.root, - leafIds, - onSynthesize: (leafCount) => - console.warn( - `[web-session-tabs-sync] synthesized layout for ${leafCount} leaves; no authoritative or prior tree covered them` - ) - }), - activeLeafId, - expandedLeafId, - ptyIdsByLeafId, - // Why: surface.title is the tab/PTY label, not a pane title; restoring it as one renders a fake title bar. Only host layout titles are real pane titles. - ...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {}) - } -} - -function pendingBindingBelongsToEnvironment( - ptyId: string, - environmentId: string, - terminalPtyMode: 'local' | 'remote' -): boolean { - const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) - return terminalPtyMode === 'local' - ? ownerEnvironmentId === null - : ownerEnvironmentId === environmentId -} - -/** Keep a known pane binding while the host briefly publishes its surface as pending. */ -function retainPendingTerminalBindings( - surfaces: readonly TerminalSurface[], - existingLayout: TerminalLayoutSnapshot | undefined, - ptyIdsByLeafId: Record, - environmentId: string, - terminalPtyMode: 'local' | 'remote' -): Record { - const existingBindings = existingLayout?.ptyIdsByLeafId - if (!existingBindings) { - return ptyIdsByLeafId - } - let retained = ptyIdsByLeafId - for (const surface of surfaces) { - if (surface.status !== 'pending-handle' || Object.hasOwn(retained, surface.leafId)) { - continue - } - const priorPtyId = existingBindings[surface.leafId] - if ( - !priorPtyId || - !pendingBindingBelongsToEnvironment(priorPtyId, environmentId, terminalPtyMode) - ) { - continue - } - if (retained === ptyIdsByLeafId) { - retained = { ...ptyIdsByLeafId } - } - retained[surface.leafId] = priorPtyId - } - return retained -} - -function shouldReplaceTerminalTab( - tab: TerminalTab, - environmentId: string, - nextRemotePtyIds: ReadonlySet, - nextMirroredTerminalIds: ReadonlySet, - exactProvisionalHandoffs: ReadonlySet -): boolean { - if (exactProvisionalHandoffs.has(tab.id)) { - // Why: agent kind is not session identity; retire only the provisional tab - // whose request or structured response identifies this exact host surface. - return true - } - if (isMirroredTerminalSurfaceId(tab.id)) { - // Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts. - return true - } - if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) { - return true - } - if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) { - return false - } - // Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot. - return ( - tab.ptyId !== null && - (nextRemotePtyIds.has(tab.ptyId) || - nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id))) - ) -} - -/** Constructs mirrored terminal tabs from the mobile session status payload, normalising Pi-compatible agent titles under launch ownership. */ -function buildMirroredTerminalTabs( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - existingById: ReadonlyMap, - existingLayoutsByTabId: Readonly>, - sortOffset: number, - now: number, - focusTarget?: { parentTabId: string; leafId: string }, - terminalPtyMode: 'local' | 'remote' = 'remote' -): MirroredTerminalTab[] { - const groups = new Map() - for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) { - const group = groups.get(tab.parentTabId) ?? [] - group.push(tab) - groups.set(tab.parentTabId, group) - } - - return [...groups.entries()].map(([parentTabId, surfaces], index) => { - const localTabId = toWebTerminalSurfaceTabId(parentTabId) - const existingLayout = existingLayoutsByTabId[localTabId] - const requestedActiveLeafId = - focusTarget?.parentTabId === parentTabId ? focusTarget.leafId : undefined - const activeSurface = - (requestedActiveLeafId - ? surfaces.find((surface) => surface.leafId === requestedActiveLeafId) - : undefined) ?? - (existingLayout?.activeLeafId - ? surfaces.find((surface) => surface.leafId === existingLayout.activeLeafId) - : undefined) ?? - surfaces.find((surface) => surface.isActive) ?? - surfaces[0]! - const ptyIdForSurface = (handle: string): string => - terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId) - const freshPtyIdsByLeafId = Object.fromEntries( - surfaces - .filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready') - .map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)]) - ) - const ptyIdsByLeafId = retainPendingTerminalBindings( - surfaces, - existingLayout, - freshPtyIdsByLeafId, - environmentId, - terminalPtyMode - ) - const layout = normalizeTerminalLayoutPtyOwnership( - chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) - ).snapshot - const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {}) - const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId) - let retainedSurfaceByPrunedLeafId: Map | undefined - if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) { - const retainedLeafIdByPtyId = new Map( - layoutPtyEntries.map(([leafId, ptyId]) => [ptyId, leafId]) - ) - const surfaceByLeafId = new Map(surfaces.map((surface) => [surface.leafId, surface])) - retainedSurfaceByPrunedLeafId = new Map() - for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { - const retainedLeafId = retainedLeafIdByPtyId.get(ptyId) - if (retainedLeafId && retainedLeafId !== leafId) { - const retainedSurface = surfaceByLeafId.get(retainedLeafId) - if (retainedSurface) { - retainedSurfaceByPrunedLeafId.set(leafId, retainedSurface) - } - } - } - } - const launchAgent = - activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent, - hookAgent: activeSurface.agentStatus?.agentType, - siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus - ?.agentType - }) - const existing = - existingById.get(localTabId) ?? - existingById.get(parentTabId) ?? - surfaces - .map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id))) - .find((tab): tab is TerminalTab => Boolean(tab)) - // Why: a headless host publishes the literal "Terminal" while an idle pane - // has no live PTY. Keep the client's known title until a ready surface reports one. - const hostTitle = activeSurface.title.trim() || surfaces[0]?.title.trim() || '' - const hostTitleIsPlaceholder = - hostTitle === '' || (activeSurface.status === 'pending-handle' && hostTitle === 'Terminal') - const retainedTitle = existing?.title?.trim() || existing?.defaultTitle?.trim() || '' - const title = normalizeCompatibleAgentTitleForOwner( - (hostTitleIsPlaceholder ? retainedTitle || hostTitle : hostTitle) || 'Terminal', - ownerRecord?.agent, - { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } - ) - const quickCommandLabel = - activeSurface.quickCommandLabel?.trim() || - surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || - existing?.quickCommandLabel?.trim() - // Why: startupCwd is host-owned launch metadata; once the host omits it, don't resurrect stale subdirectory intent. - const startupCwd = - activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd - // Why: color/pin echo back through host snapshots, so prefer the client's own record and fall back to host only without a prior tab (avoids echo-window reverts). - const hostColorSurface = surfaces.find((surface) => surface.color != null) - const color = existing ? (existing.color ?? null) : (hostColorSurface?.color ?? null) - const isPinned = existing - ? existing.isPinned === true - : surfaces.some((surface) => surface.isPinned) - // Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab. - const hostViewModeSurface = surfaces.find((surface) => surface.viewMode) - const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode - return { - tab: { - id: localTabId, - ptyId: ptyIdsByLeafId[activeSurface.leafId] ?? null, - worktreeId: snapshot.worktree, - title, - defaultTitle: existing?.defaultTitle ?? title, - // Why: the host transport carries no generated title, so rebuilding the tab - // without this dropped the client's agent-prompt label on every snapshot. - ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), - ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), - ...(quickCommandLabel ? { quickCommandLabel } : {}), - ...(startupCwd ? { startupCwd } : {}), - customTitle: existing?.customTitle ?? null, - color, - isPinned, - ...(viewMode ? { viewMode } : {}), - sortOrder: sortOffset + index, - createdAt: existing?.createdAt ?? now + index, - // Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent. - ...(launchAgent ? { launchAgent } : {}) - }, - hostTabId: parentTabId, - ptyIds, - layout, - ...(retainedSurfaceByPrunedLeafId ? { retainedSurfaceByPrunedLeafId } : {}) - } - }) -} - -function toMirroredPaneKey(surface: TerminalSurface, leafId = surface.leafId): string | null { - if (!isTerminalLeafId(leafId)) { - return null - } - return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), leafId) -} - -/** Normalises and mirrors agent status updates from the host payload, preserving ownership metadata. */ -function remapHostAgentStatus( - surface: TerminalSurface, - retainedSurface?: TerminalSurface -): AgentStatusEntry | null { - if (!surface.agentStatus) { - return null - } - const paneKey = toMirroredPaneKey(surface, retainedSurface?.leafId) - if (!paneKey) { - return null - } - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent, - hookAgent: surface.agentStatus.agentType - }) - return { - ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, { - ownerIsLaunch: ownerRecord?.ownerIsLaunch === true - }), - paneKey, - tabId: toWebTerminalSurfaceTabId(surface.parentTabId) - } -} - -function isMirroredAgentPaneKeyForTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const parsed = parsePaneKey(paneKey) - return parsed !== null && tabIds.has(parsed.tabId) -} - -/** Host states the client's byte pipeline cannot observe: permission blocks and - * interactive question cards reach the host over its HTTP agent hook, never - * through PTY bytes, so they must pierce the client-authority fence. */ -function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean { - return entry.state === 'blocked' || entry.interactivePrompt != null -} - -/** True while this renderer's own byte-derived status owns the pane: it claimed - * the pane at transport creation and wrote status from bytes. The claim is - * released on pane teardown, which is how the host takes the pane back. */ -function isClientOwnedAgentStatus( - paneKey: string, - existing: AgentStatusEntry | undefined -): existing is AgentStatusEntry { - return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey) -} - -/** Owned AND still fresh — the arbitration rule for a pane the host also has an - * opinion about: an OSC-silent dead agent hands that contest back to the host. */ -function isFencedClientAgentStatus( - paneKey: string, - existing: AgentStatusEntry | undefined, - now: number -): existing is AgentStatusEntry { - return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) -} - -function batchAgentPaneKeysForTabs( - state: WebSessionTabsSyncState, - tabIds: ReadonlySet, - batchContext?: WebSessionTabsBatchContext -): string[] { - if (!batchContext) { - return Object.keys(state.agentStatusByPaneKey) - } - if (!batchContext.agentPaneKeysByTabId) { - batchContext.agentPaneKeysByTabId = new Map() - for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { - const tabId = parsePaneKey(paneKey)?.tabId - if (!tabId) { - continue - } - const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() - paneKeys.add(paneKey) - batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) - } - } - return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) -} - -function updateBatchAgentPaneKey( - paneKey: string, - present: boolean, - batchContext?: WebSessionTabsBatchContext -): void { - const tabId = parsePaneKey(paneKey)?.tabId - const index = batchContext?.agentPaneKeysByTabId - if (!tabId || !index) { - return - } - if (present) { - const paneKeys = index.get(tabId) ?? new Set() - paneKeys.add(paneKey) - index.set(tabId, paneKeys) - return - } - const paneKeys = index.get(tabId) - paneKeys?.delete(paneKey) - if (paneKeys?.size === 0) { - index.delete(tabId) - } -} - -// Why: the closed-tab marker has no TTL, and setAgentStatus hard-drops writes for a -// marked tab id — for a stable mirrored id that returns, that is a permanent silent -// blackhole unless presence in a snapshot lifts it. -function buildRemirroredClosedTabMarkerLiftPatch( - recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'], - mirroredTerminalIds: ReadonlySet -): Partial | null { - let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null - for (const tabId of mirroredTerminalIds) { - if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) { - next ??= { ...recentlyClosedAgentStatusTabIds } - delete next[tabId] - } - } - return next ? { recentlyClosedAgentStatusTabIds: next } : null -} - -/** - * A host retraction owes the retracted tab closeTab's renderer-state sweep: without it, - * client-owned rows (STA-3107-exempt in the mirror's delete loop) and retention promotions - * outlive the tab forever (STA-4593). - */ -function buildRetractedMirroredTabSweepPatch( - state: WebSessionTabsSyncState, - worktreeId: string, - nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], - agentStatusPatch: Pick< - WebSessionTabsSyncState, - 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' - > | null, - removedTerminalResourceIds: readonly string[], - batchContext?: WebSessionTabsBatchContext -): Partial | null { - // Why: only a mirrored id the host stopped publishing is a retraction — a local or provisional - // tab in this list is being renamed into its mirror, and a rename must keep its rows. - const retractedTabIds = removedTerminalResourceIds.filter(isMirroredTerminalSurfaceId) - if (retractedTabIds.length === 0) { - return null - } - const sweepState: RetiredTerminalTabSweepState = { - acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, - agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, - agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, - agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, - migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, - paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, - recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, - recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, - retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, - sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, - // Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has", - // so it must see the post-removal tab list, not the one the snapshot replaced. - tabsByWorktree: nextTabsByWorktree - } - const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId) - if (!sweep?.agentStatusByPaneKey || !batchContext) { - return sweep ?? null - } - // Why: the batch republishes its own record copy at the end, which would undo the sweep. - const mutableState = state as unknown as Record - mutableState.agentStatusByPaneKey = sweep.agentStatusByPaneKey - batchContext.changedRecords.add('agentStatusByPaneKey') - for (const paneKey of Object.keys(sweepState.agentStatusByPaneKey)) { - if (!(paneKey in sweep.agentStatusByPaneKey)) { - updateBatchAgentPaneKey(paneKey, false, batchContext) - } - } - return sweep -} - -function buildMirroredAgentStatusPatch( - state: WebSessionTabsSyncState, - currentTerminalTabs: readonly TerminalTab[], - terminalSurfaceTabs: readonly TerminalSurface[], - mirroredTerminalTabs: readonly MirroredTerminalTab[], - now: number, - batchContext?: WebSessionTabsBatchContext -): Pick | null { - const mirroredTabIds = new Set() - for (const tab of currentTerminalTabs) { - if (isWebTerminalSurfaceTabId(tab.id)) { - mirroredTabIds.add(tab.id) - } - } - for (const surface of terminalSurfaceTabs) { - mirroredTabIds.add(toWebTerminalSurfaceTabId(surface.parentTabId)) - } - - if (mirroredTabIds.size === 0) { - return null - } - - let retainedSurfaceByHostTabAndPrunedLeafId: - | Map> - | undefined - for (const entry of mirroredTerminalTabs) { - if (entry.retainedSurfaceByPrunedLeafId) { - retainedSurfaceByHostTabAndPrunedLeafId ??= new Map() - retainedSurfaceByHostTabAndPrunedLeafId.set( - entry.hostTabId, - entry.retainedSurfaceByPrunedLeafId - ) - } - } - const nextByPaneKey = new Map() - for (const surface of terminalSurfaceTabs) { - const retainedSurface = retainedSurfaceByHostTabAndPrunedLeafId - ?.get(surface.parentTabId) - ?.get(surface.leafId) - const entry = remapHostAgentStatus(surface, retainedSurface) - if (!entry) { - continue - } - const existing = nextByPaneKey.get(entry.paneKey) ?? state.agentStatusByPaneKey[entry.paneKey] - // Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot. - const hostIdentityPredatesCurrentTurn = - existing !== undefined && - entry.state === 'done' && - existing.state !== 'done' && - existing.stateStartedAt > entry.stateStartedAt - // Why: cross-machine wall clocks are not comparable, so the host frame could - // outrank live client status forever; a proven client writer keeps its own - // state (still adopting the host's identity fields below) unless the host - // carries a state class the client's bytes can never see. - const clientOwnsEntry = - isFencedClientAgentStatus(entry.paneKey, existing, now) && - !hostAgentStatusPiercesClientAuthority(entry) - const nextEntry = - existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt) - ? { - ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), - ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' - ? { workingMode: entry.workingMode } - : {}), - paneKey: entry.paneKey, - worktreeId: entry.worktreeId ?? existing.worktreeId, - tabId: entry.tabId, - providerSession: - existing.providerSession ?? - (hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession), - // Why: hook-only content the byte pipeline can never see, and every OSC - // write blanks it, so a fenced pane's message line stayed empty forever - // (#12906). Host-first unlike providerSession: only the host can mint one. - lastAssistantMessage: - (hostIdentityPredatesCurrentTurn ? undefined : entry.lastAssistantMessage) ?? - existing.lastAssistantMessage, - lastAssistantMessageIsToolOutput: - hostIdentityPredatesCurrentTurn || entry.lastAssistantMessage === undefined - ? existing.lastAssistantMessageIsToolOutput - : entry.lastAssistantMessageIsToolOutput - } - : entry - nextByPaneKey.set(entry.paneKey, nextEntry) - } - - let nextAgentStatusByPaneKey = state.agentStatusByPaneKey - let changed = false - let aggregateRelevantChange = false - let sortRelevantChange = false - - for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { - if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { - continue - } - if (nextByPaneKey.has(paneKey)) { - continue - } - // Why: the host surface carrying no status is not proof the agent stopped — - // hook-only hosts publish nothing for OSC-driven panes. Keep a live entry - // this renderer owns; it decays through the normal freshness boundary. - // Ownership, not freshness, is the gate here: with no competing host value - // there is nothing to arbitrate, and a client asleep past the stale - // boundary would otherwise erase every pane it owns on the first snapshot - // after wake (STA-3107) instead of decaying it like a local pane. - if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) { - continue - } - if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = writableWebSessionTabsRecord( - state, - 'agentStatusByPaneKey', - batchContext - ) - } - delete nextAgentStatusByPaneKey[paneKey] - updateBatchAgentPaneKey(paneKey, false, batchContext) - changed = true - aggregateRelevantChange = true - sortRelevantChange = true - } - - for (const [paneKey, entry] of nextByPaneKey) { - const existing = nextAgentStatusByPaneKey[paneKey] - if (agentStatusEntryEqual(existing, entry)) { - continue - } - if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = writableWebSessionTabsRecord( - state, - 'agentStatusByPaneKey', - batchContext - ) - } - nextAgentStatusByPaneKey[paneKey] = entry - updateBatchAgentPaneKey(paneKey, true, batchContext) - changed = true - const entryAttributionChanged = - existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId - const entryFreshnessChanged = - !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) - const doneAttentionChanged = - existing?.state === 'done' && - entry.state === 'done' && - agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) - const workingModeChanged = existing?.workingMode !== entry.workingMode - const entrySortRelevantChange = - !existing || - existing.state !== entry.state || - !isAgentStatusFresh(existing, now) || - entryFreshnessChanged || - entryAttributionChanged || - doneAttentionChanged || - isMirroredCommandCodeTurnBump(existing, entry) - aggregateRelevantChange = - aggregateRelevantChange || entrySortRelevantChange || workingModeChanged - sortRelevantChange = sortRelevantChange || entrySortRelevantChange - } - - if (!changed) { - return null - } - - return { - agentStatusByPaneKey: nextAgentStatusByPaneKey, - agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch, - sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch - } -} - -function buildTerminalUnifiedTab( - tab: TerminalTab, - groupId: string, - environmentId: string, - // Why: viewMode is host-tracked but the client's optimistic toggle must win during the echo window; callers pass the reconciled value. - viewMode?: Tab['viewMode'] -): Tab { - return { - id: tab.id, - entityId: tab.id, - groupId, - worktreeId: tab.worktreeId, - executionHostId: toRuntimeExecutionHostId(environmentId), - contentType: 'terminal', - label: tab.title, - ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt, - isPreview: false, - isPinned: tab.isPinned === true, - ...(viewMode ? { viewMode } : {}) - } -} - -function buildBrowserUnifiedTab( - tab: BrowserWorkspace, - hostTab: RuntimeMobileSessionBrowserTab, - existingUnifiedTab: Tab | null, - groupId: string, - environmentId: string -): Tab { - return { - id: existingUnifiedTab?.id ?? hostTab.id, - entityId: tab.id, - groupId, - worktreeId: tab.worktreeId, - executionHostId: toRuntimeExecutionHostId(environmentId), - contentType: 'browser', - label: tab.title, - customLabel: null, - color: hostTab.color !== undefined ? hostTab.color : (existingUnifiedTab?.color ?? null), - sortOrder: tab.createdAt, - createdAt: tab.createdAt, - isPreview: false, - isPinned: - hostTab.isPinned !== undefined - ? hostTab.isPinned === true - : existingUnifiedTab?.isPinned === true - } -} - -function buildEditorUnifiedTab( - file: OpenFile, - tab: ReadyEditorSurface, - hostTabId: string, - existingUnifiedTab: Tab | null, - label: string, - groupId: string, - sortOrder: number, - createdAt: number, - environmentId: string -): Tab { - return { - id: hostTabId, - entityId: file.id, - groupId, - worktreeId: file.worktreeId, - executionHostId: toRuntimeExecutionHostId(environmentId), - contentType: 'editor', - label, - customLabel: null, - color: tab.color !== undefined ? tab.color : (existingUnifiedTab?.color ?? null), - sortOrder, - createdAt, - isPreview: false, - isPinned: - tab.isPinned !== undefined ? tab.isPinned === true : existingUnifiedTab?.isPinned === true - } -} - -function buildMirroredEditorTabs( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - worktreeOpenFileById: ReadonlyMap, - existingTabIndex: WebSessionExistingTabIndex, - hostGroupIdByTabId: ReadonlyMap, - fallbackGroupId: string, - sortOffset: number, - now: number -): MirroredEditorTab[] { - return snapshot.tabs.filter(isReadyEditorTab).map((tab, index) => { - const fileId = localEditorFileId(tab) - const existingFile = worktreeOpenFileById.get(fileId) - const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id) - const sourceFileId = editorSourceFileId(tab) - const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId - const file: OpenFile = { - ...existingFile, - id: fileId, - filePath: tab.filePath, - relativePath: tab.relativePath, - worktreeId: snapshot.worktree, - language: tab.language, - isDirty: tab.isDirty, - runtimeEnvironmentId: environmentId, - mode: tab.type === 'markdown' ? tab.mode : 'edit', - markdownPreviewSourceFileId: sourceFileId, - // Why: marks this tab host-owned so a later snapshot that omits it can cull it; locally opened tabs lack this flag and survive. - mirroredFromRuntimeSession: true - } - return { - file, - hostTabId: tab.id, - unifiedTab: buildEditorUnifiedTab( - file, - tab, - tab.id, - existingUnifiedTab, - tab.title.trim() || tab.relativePath || 'File', - groupId, - sortOffset + index, - existingUnifiedTab?.createdAt ?? now + sortOffset + index, - environmentId - ) - } - }) -} - -function findBrowserWorkspaceForRemotePage( - state: WebSessionTabsSyncState, - worktreeId: string, - environmentId: string, - remotePageId: string -): { workspace: BrowserWorkspace; page: BrowserPage; unifiedTab: Tab | null } | null { - const workspaces = state.browserTabsByWorktree[worktreeId] ?? [] - for (const workspace of workspaces) { - const pages = state.browserPagesByWorkspace[workspace.id] ?? [] - for (const page of pages) { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - if (handle?.environmentId === environmentId && handle.remotePageId === remotePageId) { - return { - workspace, - page, - unifiedTab: - (state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.contentType === 'browser' && tab.entityId === workspace.id - ) ?? null - } - } - } - } - return null -} - -function browserWorkspaceHasRemoteEnvironmentPage( - state: WebSessionTabsSyncState, - workspace: BrowserWorkspace, - environmentId: string -): boolean { - return (state.browserPagesByWorkspace[workspace.id] ?? []).some( - (page) => state.remoteBrowserPageHandlesByPageId[page.id]?.environmentId === environmentId - ) -} - -/** A page of this environment whose guest runs on this desktop, so it survives the host process. */ -function browserWorkspaceHasClientHostedEnvironmentPage( - state: WebSessionTabsSyncState, - workspace: BrowserWorkspace, - environmentId: string -): boolean { - return (state.browserPagesByWorkspace[workspace.id] ?? []).some((page) => { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - return ( - handle?.environmentId === environmentId && - (handle.placement?.kind === 'client' || - handle.stagedClientHosted === true || - handle.restoredClientHosted === true) - ) - }) -} - -/** - * The host publishes `title || url || 'Browser'` (see the runtime's browser tab projection), so a - * page that has not produced a real title yet arrives as its own url — or as the bare default. - * Neither says anything the local row does not already say better, so while the page still sits at - * the url this client gave it, the local title stands and only a real navigation replaces it. - * - * Scope: the pre-adoption/staged window, every placement the host actually drives, and the mirror - * of a page some other client hosts. Rows whose guest is ours never reach here — see - * resolveMirroredBrowserPageContent. - */ -function resolveMirroredBrowserTitle( - tab: RuntimeMobileSessionBrowserTab, - existingPage: BrowserPage | undefined -): string { - const published = tab.title.trim() - const publishedIsHostFallback = - published === '' || published === tab.url.trim() || published === 'Browser' - if (existingPage && publishedIsHostFallback && tab.url === existingPage.url) { - return existingPage.title - } - return published || 'Browser' -} - -/** - * Whether the guest running this page is one of ours. `placement.kind === 'client'` says only that - * *some* client hosts it: a second desktop, or the web client that installs no page renderer at - * all, mirrors the same row with nothing of its own to see it with. The placement names the lease - * holder, so the comparison is exact, and it holds from the moment the host mints the placement — - * before our guest has attached — which is the window the title would otherwise flicker through. - */ -function clientHostsMirroredBrowserPage(tab: RuntimeMobileSessionBrowserTab): boolean { - if (tab.placement?.kind !== 'client') { - return false - } - const hostClientId = readBrowserClientHostId() - return hostClientId !== null && tab.placement.browserHostClientId === hostClientId -} - -/** - * A page hosted by this client lives in our own guest webview; the host only ever learns its - * content second-hand through a fire-and-forget metadata publish, so its snapshot is a stale echo - * at best and the registry's `'Browser'`/create-time defaults at worst. Once a local row exists it - * is the truth. - * - * The url is part of the payload and not just a symptom: rewinding it to the host's create-time - * value is what breaks the staged-title hold's url-equality arm on the following snapshot. - * - * Every other row keeps taking the host values — a client that hosts no guest has no second - * opinion to offer, and freezing its mirror would strand it on whatever its first snapshot said. - */ -function resolveMirroredBrowserPageContent( - tab: RuntimeMobileSessionBrowserTab, - existingPage: BrowserPage | undefined -): Pick { - if (clientHostsMirroredBrowserPage(tab) && existingPage) { - return { - url: existingPage.url, - title: existingPage.title, - loading: existingPage.loading, - canGoBack: existingPage.canGoBack, - canGoForward: existingPage.canGoForward - } - } - return { - url: tab.url, - title: resolveMirroredBrowserTitle(tab, existingPage), - loading: tab.loading, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward - } -} - -function buildMirroredBrowserTabs( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - state: WebSessionTabsSyncState, - hostGroupIdByTabId: ReadonlyMap, - fallbackGroupId: string, - sortOffset: number, - now: number -): MirroredBrowserTab[] { - const renderedGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) - const clientGroupIds = new Set( - (state.groupsByWorktree[snapshot.worktree] ?? []).map((group) => group.id) - ) - return snapshot.tabs.filter(isReadyBrowserTab).map((tab, index) => { - const existing = findBrowserWorkspaceForRemotePage( - state, - snapshot.worktree, - environmentId, - tab.browserPageId - ) - const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId - const pageId = existing?.page.id ?? tab.browserPageId - const createdAt = existing?.page.createdAt ?? now + sortOffset + index - const recordedClientGroupId = peekWebSessionBrowserPlacementGroup({ - environmentId, - worktreeId: snapshot.worktree, - remotePageId: tab.browserPageId - }) - const hostGroupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId - const existingClientGroupId = - existing?.unifiedTab?.groupId !== hostGroupId ? existing?.unifiedTab?.groupId : undefined - // Why: a staged row was placed by this client, so wherever it sits now is the user's own - // choice — including a split made after the create recorded its intent. Rows the client never - // staged carry no such truth: a pre-response snapshot may have parked them in the host group, - // and there the record is what repairs them. - const preferredClientGroupId = - existing && state.remoteBrowserPageHandlesByPageId[existing.page.id]?.staged === true - ? (existing.unifiedTab?.groupId ?? recordedClientGroupId) - : (recordedClientGroupId ?? existingClientGroupId) - const clientGroupId = - preferredClientGroupId && - clientGroupIds.has(preferredClientGroupId) && - (renderedGroupIds.size === 0 || renderedGroupIds.has(preferredClientGroupId)) - ? preferredClientGroupId - : undefined - const groupId = clientGroupId ?? hostGroupId - const content = resolveMirroredBrowserPageContent(tab, existing?.page) - const nextPage: BrowserPage = { - id: pageId, - workspaceId, - worktreeId: snapshot.worktree, - ...content, - faviconUrl: existing?.page.faviconUrl ?? null, - // Why: a client-hosted page's load failure is observed by the local guest webview and the - // host publishes no loadError for it, so the host snapshot must not clear the local record. - loadError: - (tab.placement?.kind === 'client' ? existing?.page.loadError : tab.loadError) ?? null, - createdAt, - browserRuntimeEnvironmentId: environmentId, - viewportPresetId: existing?.page.viewportPresetId ?? null - } - // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace - // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. - const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage - const workspace: BrowserWorkspace = { - id: workspaceId, - worktreeId: snapshot.worktree, - label: existing?.workspace.label, - sessionProfileId: existing?.workspace.sessionProfileId ?? null, - activePageId: page.id, - pageIds: [page.id], - url: page.url, - title: page.title, - loading: page.loading, - faviconUrl: page.faviconUrl, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward, - loadError: page.loadError, - createdAt - } - return { - workspace, - page, - certificateFailure: tab.certificateFailure ?? null, - remotePageId: tab.browserPageId, - ...(tab.placement ? { placement: tab.placement } : {}), - unifiedTab: buildBrowserUnifiedTab( - workspace, - tab, - existing?.unifiedTab ?? null, - groupId, - environmentId - ), - hostTabId: tab.id, - ...(clientGroupId ? { clientGroupId } : {}) - } - }) -} - -function chooseTargetGroupId( - state: WebSessionTabsSyncState, - snapshot: RuntimeMobileSessionTabsResult -): string { - const groups = state.groupsByWorktree[snapshot.worktree] ?? [] - const layoutGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) - const inRenderedLayout = (groupId: string | null | undefined): boolean => - Boolean(groupId && (layoutGroupIds.size === 0 || layoutGroupIds.has(groupId))) - const preferred = - groups.find((group) => group.id === snapshot.activeGroupId && inRenderedLayout(group.id)) ?? - groups.find( - (group) => - group.id === state.activeGroupIdByWorktree[snapshot.worktree] && inRenderedLayout(group.id) - ) ?? - groups.find((group) => inRenderedLayout(group.id)) - // Why: host snapshots can reference desktop-only group ids; the rendered group is the only safe CSS anchor for mirrored panes. - const firstRenderedLayoutGroupId = layoutGroupIds.values().next().value as string | undefined - return ( - preferred?.id ?? - firstRenderedLayoutGroupId ?? - snapshot.activeGroupId ?? - `${WEB_SESSION_GROUP_PREFIX}${snapshot.worktree}` - ) -} - -function collectLayoutGroupIds(layout: TabGroupLayoutNode | undefined): Set { - const result = new Set() - const visit = (node: TabGroupLayoutNode | undefined): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - result.add(node.groupId) - return - } - visit(node.first) - visit(node.second) - } - visit(layout) - return result -} - -function buildHostGroupIdByTabId( - hostGroups: readonly RuntimeMobileSessionTabGroup[] | undefined -): Map { - const result = new Map() - for (const group of hostGroups ?? []) { - for (const tabId of group.tabOrder) { - result.set(tabId, group.id) - } - if (group.activeTabId) { - result.set(group.activeTabId, group.id) - } - } - return result -} - -function pruneTabGroupLayout( - layout: TabGroupLayoutNode | null | undefined, - validGroupIds: ReadonlySet -): TabGroupLayoutNode | null { - if (!layout) { - return null - } - if (layout.type === 'leaf') { - return validGroupIds.has(layout.groupId) ? layout : null - } - const first = pruneTabGroupLayout(layout.first, validGroupIds) - const second = pruneTabGroupLayout(layout.second, validGroupIds) - if (first && second) { - return { ...layout, first, second } - } - return first ?? second -} - -function dropTabGroupLayoutGroups( - layout: TabGroupLayoutNode | null, - excludedGroupIds: ReadonlySet -): TabGroupLayoutNode | null { - if (!layout) { - return null - } - if (layout.type === 'leaf') { - return excludedGroupIds.has(layout.groupId) ? null : layout - } - const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds) - const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds) - if (first && second) { - return { ...layout, first, second } - } - return first ?? second -} - -function appendTabGroupLayout( - first: TabGroupLayoutNode | null, - second: TabGroupLayoutNode | null -): TabGroupLayoutNode | null { - if (!first) { - return second - } - if (!second) { - return first - } - // Why: a group already placed by `first` must not gain a second leaf — two - // leaves for one group render the same tab strip in two columns, and each - // later snapshot appends another copy. - const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first)) - if (!appended) { - return first - } - return { - type: 'split', - direction: 'horizontal', - first, - second: appended - } -} - -function tabGroupLayoutEqual( - a: TabGroupLayoutNode | null | undefined, - b: TabGroupLayoutNode | null | undefined -): boolean { - if (!a || !b) { - return !a && !b - } - if (a.type !== b.type) { - return false - } - if (a.type === 'leaf') { - return b.type === 'leaf' && a.groupId === b.groupId - } - return ( - b.type === 'split' && - a.direction === b.direction && - a.ratio === b.ratio && - tabGroupLayoutEqual(a.first, b.first) && - tabGroupLayoutEqual(a.second, b.second) - ) -} - -function mapHostRecentTabIds( - recentTabIds: readonly string[] | undefined, - hostToLocalTabId: ReadonlyMap, - tabOrder: readonly string[] -): string[] { - if (!recentTabIds || recentTabIds.length === 0) { - return [] - } - const valid = new Set(tabOrder) - return sanitizeRecentTabIds( - recentTabIds.map((tabId) => hostToLocalTabId.get(tabId) ?? '').filter(Boolean), - [...valid] - ) -} - -function buildHostToLocalTabIdMap({ - terminalSurfaces, - terminalTabs, - browserTabs, - editorTabs, - agentTabs -}: { - terminalSurfaces: readonly TerminalSurface[] - terminalTabs: readonly TerminalTab[] - browserTabs: readonly MirroredBrowserTab[] - editorTabs: readonly MirroredEditorTab[] - agentTabs: readonly MirroredAgentTab[] -}): Map { - const hostToLocal = new Map() - const terminalIds = new Set(terminalTabs.map((tab) => tab.id)) - for (const surface of terminalSurfaces) { - const localId = toWebTerminalSurfaceTabId(surface.parentTabId) - if (terminalIds.has(localId)) { - hostToLocal.set(surface.parentTabId, localId) - hostToLocal.set(surface.id, localId) - } - } - for (const entry of browserTabs) { - hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) - hostToLocal.set(entry.unifiedTab.id, entry.unifiedTab.id) - } - for (const entry of editorTabs) { - hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) - } - for (const entry of agentTabs) { - hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) - } - return hostToLocal -} - -function updateHostSessionTabIdMappings(args: { - environmentId: string - worktreeId: string - terminalSurfaces: readonly TerminalSurface[] - terminalTabs: readonly TerminalTab[] - browserTabs: readonly MirroredBrowserTab[] - editorTabs: readonly MirroredEditorTab[] - agentTabs: readonly MirroredAgentTab[] -}): void { - clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) - - const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) - for (const surface of args.terminalSurfaces) { - const localId = toWebTerminalSurfaceTabId(surface.parentTabId) - if (mirroredTerminalIds.has(localId)) { - setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) - } - } - for (const entry of args.browserTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } - for (const entry of args.editorTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } - for (const entry of args.agentTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } -} - -function retainClientPlacedMirroredTabs(args: { - groups: readonly TabGroup[] - mirroredUnifiedIds: ReadonlySet - validUnifiedTabIds: ReadonlySet - clientGroupIdByLocalTabId: ReadonlyMap - nextActiveUnifiedTabId: string | null -}): TabGroup[] { - return args.groups.map((group) => { - const retainedTabOrder = group.tabOrder.filter( - (tabId) => - args.validUnifiedTabIds.has(tabId) && - (!args.mirroredUnifiedIds.has(tabId) || - args.clientGroupIdByLocalTabId.get(tabId) === group.id) - ) - const placedTabIds = [...args.clientGroupIdByLocalTabId] - .filter( - ([tabId, groupId]) => - groupId === group.id && - args.validUnifiedTabIds.has(tabId) && - !retainedTabOrder.includes(tabId) - ) - .map(([tabId]) => tabId) - const tabOrder = [...retainedTabOrder, ...placedTabIds] - const activeTabId = - args.nextActiveUnifiedTabId && tabOrder.includes(args.nextActiveUnifiedTabId) - ? args.nextActiveUnifiedTabId - : group.activeTabId && tabOrder.includes(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - return { - ...group, - tabOrder, - activeTabId, - recentTabIds: activeTabId - ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId) - : [] - } - }) -} - -function buildMirroredHostGroups({ - currentGroups, - hostGroups, - hostToLocalTabId, - mirroredUnifiedIds, - nextActiveUnifiedTabId, - now, - validUnifiedTabIds, - environmentId, - worktreeId, - clientGroupIdByLocalTabId -}: { - currentGroups: readonly TabGroup[] - hostGroups: readonly RuntimeMobileSessionTabGroup[] - hostToLocalTabId: ReadonlyMap - mirroredUnifiedIds: ReadonlySet - nextActiveUnifiedTabId: string | null - now: number - validUnifiedTabIds: ReadonlySet - environmentId: string - worktreeId: string - clientGroupIdByLocalTabId: ReadonlyMap -}): TabGroup[] | null { - const strippedGroups = retainClientPlacedMirroredTabs({ - groups: currentGroups, - mirroredUnifiedIds, - validUnifiedTabIds, - clientGroupIdByLocalTabId, - nextActiveUnifiedTabId - }) - const groupsById = new Map(strippedGroups.map((group) => [group.id, group])) - const orderedGroups: TabGroup[] = [] - const seen = new Set() - - for (const hostGroup of hostGroups) { - const existing = groupsById.get(hostGroup.id) - const localHostOrder = hostGroup.tabOrder - .map((tabId) => hostToLocalTabId.get(tabId)) - .filter( - (tabId): tabId is string => - tabId !== undefined && - validUnifiedTabIds.has(tabId) && - !clientGroupIdByLocalTabId.has(tabId) - ) - const localHostOrderIds = new Set(localHostOrder) - const hostTabOrder = [ - ...(existing?.tabOrder.filter((tabId) => !localHostOrderIds.has(tabId)) ?? []), - ...localHostOrder - ] - // Why: a pending client reorder wins over a stale pre-move host order until the host echoes the move (or membership changes). - const tabOrder = resolveWebSessionReorderedOrder( - { environmentId }, - worktreeId, - hostGroup.id, - hostTabOrder, - now - ) - if (tabOrder.length === 0) { - continue - } - const activeFromHost = - hostGroup.activeTabId !== null ? (hostToLocalTabId.get(hostGroup.activeTabId) ?? null) : null - const activeTabId = - nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId) - ? nextActiveUnifiedTabId - : activeFromHost && tabOrder.includes(activeFromHost) - ? activeFromHost - : existing?.activeTabId && tabOrder.includes(existing.activeTabId) - ? existing.activeTabId - : (tabOrder[0] ?? null) - orderedGroups.push({ - id: hostGroup.id, - worktreeId, - tabOrder, - activeTabId, - recentTabIds: activeTabId - ? pushRecentTabId( - mapHostRecentTabIds(hostGroup.recentTabIds, hostToLocalTabId, tabOrder), - activeTabId - ) - : [] - }) - seen.add(hostGroup.id) - } - - for (const group of strippedGroups) { - if ( - !seen.has(group.id) && - (group.tabOrder.length > 0 || - isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: group.id })) - ) { - orderedGroups.push(group) - } - } - - return orderedGroups.length > 0 ? orderedGroups : null -} - -function sameStringArray(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) { - return false - } - return a.every((value, index) => value === b[index]) -} - -function sameAgentStateHistory( - a: AgentStatusEntry['stateHistory'], - b: AgentStatusEntry['stateHistory'] -): boolean { - if (a.length !== b.length) { - return false - } - return a.every( - (entry, index) => - entry.state === b[index]?.state && - entry.prompt === b[index]?.prompt && - entry.startedAt === b[index]?.startedAt && - entry.interrupted === b[index]?.interrupted - ) -} - -function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEntry): boolean { - if (!a) { - return false - } - return ( - a.state === b.state && - a.workingMode === b.workingMode && - a.prompt === b.prompt && - a.updatedAt === b.updatedAt && - a.stateStartedAt === b.stateStartedAt && - a.agentType === b.agentType && - a.paneKey === b.paneKey && - a.worktreeId === b.worktreeId && - a.tabId === b.tabId && - a.terminalTitle === b.terminalTitle && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.interactivePrompt === b.interactivePrompt && - a.lastAssistantMessage === b.lastAssistantMessage && - a.lastAssistantMessageIsToolOutput === b.lastAssistantMessageIsToolOutput && - a.interrupted === b.interrupted && - a.promptInteractionKey === b.promptInteractionKey && - a.restoredUnconfirmed === b.restoredUnconfirmed && - agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && - sameAgentStateHistory(a.stateHistory, b.stateHistory) - ) -} - -function isAgentStatusFresh( - entry: Pick, - now: number -): boolean { - return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS -} - -function isMirroredCommandCodeTurnBump( - existing: AgentStatusEntry | undefined, - entry: AgentStatusEntry -): boolean { - return ( - existing?.agentType === 'command-code' && - entry.agentType === 'command-code' && - existing.state === 'working' && - entry.state === 'working' && - entry.stateStartedAt > existing.stateStartedAt - ) -} - -function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] { - if (!recent || recent.length === 0) { - return [] - } - const valid = new Set(tabOrder) - const seen = new Set() - const reversed: string[] = [] - for (let i = recent.length - 1; i >= 0; i -= 1) { - const id = recent[i] - if (!valid.has(id) || seen.has(id)) { - continue - } - seen.add(id) - reversed.push(id) - } - return reversed.toReversed() -} - -function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] { - const base = recent ?? [] - if (base.length > 0 && base.at(-1) === tabId) { - return base - } - return [...base.filter((id) => id !== tabId), tabId] -} - -function writableWebSessionTabsRecord( - state: WebSessionTabsSyncState, - recordKey: K, - batchContext?: WebSessionTabsBatchContext -): NonNullable { - const record = (state[recordKey] ?? {}) as NonNullable - if (!batchContext) { - return { ...record } as NonNullable - } - // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. - if (batchContext.changedRecords.has(recordKey)) { - return record - } - const next = { ...record } as NonNullable - const mutableState = state as unknown as Record< - WebSessionTabsBatchRecordKey, - Record - > - mutableState[recordKey] = next as Record - batchContext.changedRecords.add(recordKey) - return next -} - -function withWorktreeEntry( - state: WebSessionTabsSyncState, - recordKey: WebSessionTabsBatchRecordKey, - key: string, - value: T | null, - equal: (a: T | undefined, b: T | null) => boolean, - batchContext?: WebSessionTabsBatchContext, - deleteNull = true -): Record { - const record = (state[recordKey] ?? {}) as Record - if (equal(record[key], value)) { - return record - } - const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record - if (value === null && deleteNull) { - delete next[key] - } else { - next[key] = value as T - } - return next -} - -function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { - return ( - a.id === b.id && - a.ptyId === b.ptyId && - a.worktreeId === b.worktreeId && - a.title === b.title && - a.defaultTitle === b.defaultTitle && - a.quickCommandLabel === b.quickCommandLabel && - a.startupCwd === b.startupCwd && - a.generatedTitle === b.generatedTitle && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && - a.customTitle === b.customTitle && - a.color === b.color && - a.sortOrder === b.sortOrder && - a.createdAt === b.createdAt && - a.generation === b.generation && - a.shellOverride === b.shellOverride && - a.launchAgent === b.launchAgent && - a.pendingActivationSpawn === b.pendingActivationSpawn - ) -} - -function sameTerminalTabs( - a: readonly TerminalTab[] | undefined, - b: readonly TerminalTab[] | null -): boolean { - const left = a ?? [] - const right = b ?? [] - if (left.length !== right.length) { - return false - } - return left.every((tab, index) => terminalTabEqual(tab, right[index]!)) -} - -function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { - return ( - a.id === b.id && - a.workspaceId === b.workspaceId && - a.worktreeId === b.worktreeId && - a.url === b.url && - a.title === b.title && - a.loading === b.loading && - a.faviconUrl === b.faviconUrl && - a.canGoBack === b.canGoBack && - a.canGoForward === b.canGoForward && - a.loadError?.code === b.loadError?.code && - a.loadError?.description === b.loadError?.description && - a.loadError?.validatedUrl === b.loadError?.validatedUrl && - a.createdAt === b.createdAt && - a.browserRuntimeEnvironmentId === b.browserRuntimeEnvironmentId && - a.viewportPresetId === b.viewportPresetId - ) -} - -function optionalRuntimeBrowserPlacementsEqual( - left: RuntimeBrowserPlacement | undefined, - right: RuntimeBrowserPlacement | undefined -): boolean { - return left === undefined - ? right === undefined - : right !== undefined && sameRuntimeBrowserPlacement(left, right) -} - -function browserCertificateFailureEqual( - a: BrowserCertificateFailure | null | undefined, - b: BrowserCertificateFailure | null | undefined -): boolean { - const left = a ?? null - const right = b ?? null - if (left === right) { - return true - } - return Boolean( - left && - right && - left.challengeId === right.challengeId && - left.browserPageId === right.browserPageId && - left.errorCode === right.errorCode && - left.error === right.error && - left.origin === right.origin && - left.displayHost === right.displayHost && - left.canProceed === right.canProceed && - left.observedAt === right.observedAt - ) -} - -function sameBrowserPages( - a: readonly BrowserPage[] | undefined, - b: readonly BrowserPage[] | null -): boolean { - const left = a ?? [] - const right = b ?? [] - if (left.length !== right.length) { - return false - } - return left.every((page, index) => browserPageEqual(page, right[index]!)) -} - -function browserWorkspaceEqual(a: BrowserWorkspace, b: BrowserWorkspace): boolean { - return ( - a.id === b.id && - a.worktreeId === b.worktreeId && - a.label === b.label && - a.sessionProfileId === b.sessionProfileId && - a.activePageId === b.activePageId && - sameStringArray(a.pageIds ?? [], b.pageIds ?? []) && - a.url === b.url && - a.title === b.title && - a.loading === b.loading && - a.faviconUrl === b.faviconUrl && - a.canGoBack === b.canGoBack && - a.canGoForward === b.canGoForward && - a.loadError?.code === b.loadError?.code && - a.loadError?.description === b.loadError?.description && - a.loadError?.validatedUrl === b.loadError?.validatedUrl && - a.createdAt === b.createdAt - ) -} - -function sameBrowserTabs( - a: readonly BrowserWorkspace[] | undefined, - b: readonly BrowserWorkspace[] | null -): boolean { - const left = a ?? [] - const right = b ?? [] - if (left.length !== right.length) { - return false - } - return left.every((tab, index) => browserWorkspaceEqual(tab, right[index]!)) -} - -function openFileEqual(a: OpenFile, b: OpenFile): boolean { - return ( - a.id === b.id && - a.filePath === b.filePath && - a.relativePath === b.relativePath && - a.worktreeId === b.worktreeId && - a.language === b.language && - a.isDirty === b.isDirty && - a.runtimeEnvironmentId === b.runtimeEnvironmentId && - a.markdownPreviewSourceFileId === b.markdownPreviewSourceFileId && - a.markdownPreviewAnchor === b.markdownPreviewAnchor && - a.isPreview === b.isPreview && - a.isUntitled === b.isUntitled && - a.deleteUntouchedOnClose === b.deleteUntouchedOnClose && - a.externalMutation === b.externalMutation && - a.mirroredFromRuntimeSession === b.mirroredFromRuntimeSession && - a.mode === b.mode - ) -} - -function sameOpenFiles(a: readonly OpenFile[], b: readonly OpenFile[]): boolean { - if (a.length !== b.length) { - return false - } - return a.every((file, index) => openFileEqual(file, b[index]!)) -} - -/** This worktree's open files — the only scope a snapshot reconciles, so a batch can - * answer from here instead of walking every open file in the app. */ -function webSessionOpenFilesForWorktree( - state: WebSessionTabsSyncState, - worktreeId: string, - batchContext?: WebSessionTabsBatchContext -): readonly OpenFile[] { - if (!batchContext) { - return state.openFiles.filter((file) => file.worktreeId === worktreeId) - } - let index = batchContext.openFilesIndex - if (!index || index.source !== state.openFiles) { - const byWorktree = new Map() - for (const file of state.openFiles) { - const bucket = byWorktree.get(file.worktreeId) ?? [] - bucket.push(file) - byWorktree.set(file.worktreeId, bucket) - } - index = { source: state.openFiles, byWorktree } - batchContext.openFilesIndex = index - } - return index.byWorktree.get(worktreeId) ?? [] -} - -/** Retargets the index at the array a snapshot just produced, re-bucketing only the - * worktree that changed. Rebuilding it wholesale would cost the entire array again on - * every snapshot, which is the cost this index exists to avoid. */ -function advanceWebSessionOpenFilesIndex( - batchContext: WebSessionTabsBatchContext | undefined, - nextOpenFiles: readonly OpenFile[], - worktreeId: string -): void { - const index = batchContext?.openFilesIndex - if (!index || index.source === nextOpenFiles) { - return - } - const bucket: OpenFile[] = [] - for (const file of nextOpenFiles) { - if (file.worktreeId === worktreeId) { - bucket.push(file) - } - } - index.byWorktree.set(worktreeId, bucket) - index.source = nextOpenFiles -} - -/** Mirrors `openFiles.find()` first-wins lookup, which duplicate ids make observable. */ -function firstOpenFileByIdForWorktree(files: readonly OpenFile[]): Map { - const byId = new Map() - for (const file of files) { - if (!byId.has(file.id)) { - byId.set(file.id, file) - } - } - return byId -} - -function tabEqual(a: Tab, b: Tab): boolean { - return ( - a.id === b.id && - a.entityId === b.entityId && - a.groupId === b.groupId && - a.worktreeId === b.worktreeId && - a.executionHostId === b.executionHostId && - a.contentType === b.contentType && - a.agentSessionAgent === b.agentSessionAgent && - a.label === b.label && - // Why: the generated label is the visible tab title; ignoring it let the - // equality bail keep a unified tab that disagreed with its terminal tab. - a.generatedLabel === b.generatedLabel && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && - a.customLabel === b.customLabel && - a.color === b.color && - a.sortOrder === b.sortOrder && - a.createdAt === b.createdAt && - a.isPreview === b.isPreview && - a.isPinned === b.isPinned - ) -} - -function sameUnifiedTabs(a: readonly Tab[] | undefined, b: readonly Tab[] | null): boolean { - const left = a ?? [] - const right = b ?? [] - if (left.length !== right.length) { - return false - } - return left.every((tab, index) => tabEqual(tab, right[index]!)) -} - -function groupEqual(a: TabGroup, b: TabGroup): boolean { - return ( - a.id === b.id && - a.worktreeId === b.worktreeId && - a.activeTabId === b.activeTabId && - sameStringArray(a.tabOrder, b.tabOrder) && - sameStringArray(a.recentTabIds ?? [], b.recentTabIds ?? []) - ) -} - -function sameGroups(a: readonly TabGroup[] | undefined, b: readonly TabGroup[] | null): boolean { - const left = a ?? [] - const right = b ?? [] - if (left.length !== right.length) { - return false - } - return left.every((group, index) => groupEqual(group, right[index]!)) -} - -function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] { - if (tab.contentType === 'agent-session') { - return 'agent-session' - } - if (tab.contentType === 'browser' || tab.contentType === 'terminal') { - return tab.contentType - } - return 'editor' -} - -function applyWebSessionTabsSnapshotWithContext( - state: WebSessionTabsSyncState, - rawSnapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - now = Date.now(), - batchContext?: WebSessionTabsBatchContext, - options?: WebSessionTabsSnapshotApplyOptions -): WebSessionTabsSyncState | Partial { - if (suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot)) { - return state - } - const worktreeId = rawSnapshot.worktree - if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - return state - } - // Why: an in-flight pre-close snapshot can flash a closing tab back, so drop tabs the client is closing until the host confirms removal. - // Why: key close intents by host session tab id (terminal parentTabId else tab.id), not browserPageId, or browser closes never get suppressed. - const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => - tab.type === 'terminal' ? tab.parentTabId : tab.id - reconcileWebSessionCloseIntents( - { environmentId }, - worktreeId, - new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab))) - ) - const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) => - isWebSessionCloseIntentPending({ environmentId }, worktreeId, snapshotHostTabId(tab), now) - ) - ? { - ...rawSnapshot, - tabs: rawSnapshot.tabs.filter( - (tab) => - !isWebSessionCloseIntentPending( - { environmentId }, - worktreeId, - snapshotHostTabId(tab), - now - ) - ) - } - : rawSnapshot - // Why: only a caller-recorded create intent may focus its arriving tab; unsolicited server-active must not steal focus (#5435). - const focusIntent = peekWebSessionFocusIntent({ environmentId }, worktreeId) - const focusIntentHostTabId = focusIntent?.hostTabId ?? null - const matchingFocusIntentTab = - focusIntentHostTabId === null - ? null - : focusIntent?.leafId - ? (snapshot.tabs.find( - (tab) => - tab.type === 'terminal' && - tab.leafId === focusIntent.leafId && - (tab.id === focusIntentHostTabId || tab.parentTabId === focusIntentHostTabId) - ) ?? null) - : (snapshot.tabs.find( - (tab) => - tab.id === focusIntentHostTabId || - (tab.type === 'terminal' && tab.parentTabId === focusIntentHostTabId) || - (tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId) - ) ?? null) - const expectedCurrentLocalTabId = focusIntent?.expectedCurrentLocalTabId - const currentVisibleLocalTabId = resolveWebSessionVisibleTabId(state, worktreeId) - const callerFocusIntentTab = - matchingFocusIntentTab && - (expectedCurrentLocalTabId === undefined || - expectedCurrentLocalTabId === currentVisibleLocalTabId) - ? matchingFocusIntentTab - : null - const followIntentTab = - snapshot.navigationIntent === 'follow' - ? (snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? null) - : null - const navigationIntentTab = callerFocusIntentTab ?? followIntentTab - const honorSnapshotActiveFocus = navigationIntentTab !== null - if (matchingFocusIntentTab) { - clearWebSessionFocusIntent({ environmentId }, worktreeId) - } - const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) - const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' - const terminalSurfaceTabs = reconcilesNonAgentTabs - ? snapshot.tabs.filter(isTerminalSurfaceTab) - : [] - const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) - const nextRemotePtyIds = new Set( - readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) - ) - const nextMirroredTerminalIds = new Set( - terminalSurfaceTabs.map((tab) => toWebTerminalSurfaceTabId(tab.parentTabId)) - ) - const nextHostTerminalTabIds = new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId)) - const provisionalHandoffHostTabIds = new Map() - for (const tab of currentTerminalTabs) { - if (isMirroredTerminalSurfaceId(tab.id)) { - continue - } - if (nextHostTerminalTabIds.has(tab.id)) { - provisionalHandoffHostTabIds.set(tab.id, tab.id) - continue - } - const handoff = { - environmentId, - worktreeId, - provisionalTabId: tab.id - } - const hostTabId = resolveWebAgentSessionHandoff(handoff) - if ( - hostTabId !== null && - (nextHostTerminalTabIds.has(hostTabId) || - isWebAgentSessionHandoffPostCreateSnapshotConfirmed(handoff)) - ) { - provisionalHandoffHostTabIds.set(tab.id, hostTabId) - } - } - const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) - const retainedTerminalTabs = reconcilesNonAgentTabs - ? currentTerminalTabs.filter( - (tab) => - !shouldReplaceTerminalTab( - tab, - environmentId, - nextRemotePtyIds, - nextMirroredTerminalIds, - exactProvisionalHandoffs - ) - ) - : currentTerminalTabs - const mirroredTerminalTabs = buildMirroredTerminalTabs( - snapshot, - environmentId, - existingTerminalById, - state.terminalLayoutsByTabId, - retainedTerminalTabs.length, - now, - callerFocusIntentTab?.type === 'terminal' - ? { - parentTabId: callerFocusIntentTab.parentTabId, - leafId: callerFocusIntentTab.leafId - } - : undefined, - options?.terminalPtyMode - ) - const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab) - const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id)) - const nextTerminalTabs = - retainedTerminalTabs.length + mirroredTerminalTabEntries.length > 0 - ? [...retainedTerminalTabs, ...mirroredTerminalTabEntries] - : null - const mirroredTerminalIds = new Set(mirroredTerminalTabEntries.map((tab) => tab.id)) - const removedTerminalIds = new Set( - currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const removedTerminalResourceIds = [...removedTerminalIds].filter( - (tabId) => !mirroredTerminalIds.has(tabId) - ) - for (const provisionalTabId of exactProvisionalHandoffs) { - clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) - } - - const targetGroupId = chooseTargetGroupId(state, snapshot) - const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) - const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) - const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] - const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) - const mirroredBrowserTabs = buildMirroredBrowserTabs( - snapshot, - environmentId, - state, - hostGroupIdByTabId, - targetGroupId, - mirroredTerminalTabEntries.length, - now - ) - const mirroredBrowserWorkspaceIds = new Set( - mirroredBrowserTabs.map((entry) => entry.workspace.id) - ) - const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] - const removedBrowserWorkspaceIds = new Set( - (reconcilesNonAgentTabs ? currentBrowserTabs : []) - .filter((tab) => { - if (mirroredBrowserWorkspaceIds.has(tab.id)) { - return true - } - if (!browserWorkspaceHasRemoteEnvironmentPage(state, tab, environmentId)) { - return false - } - // Why: a staged tab holds a handle before its create RPC answers, and a restored tab holds - // one rebuilt from disk before the relaunched host has republished the page. Neither - // handle is evidence the host ever saw this snapshot's worktree, so absence proves - // nothing — culling here would erase an optimistic tab mid-create or a restored tab a - // recovering host is still about to hand back. - if ( - (state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - return handle?.staged === true || handle?.restoredFromSession === true - }) - ) { - return false - } - // Why: a runtime with nothing published for this worktree, or one that has restarted and not - // yet taken its client-hosted pages back, answers with a frame that looks exactly like - // "everything was closed". A page this desktop is still hosting outlives the runtime - // process, so its own guest is the better evidence — hold the row and let adoption publish - // it, rather than deleting a tab that is still rendering. The unreconciled flag is - // host-bounded; the unpublished-worktree frame is not, and its hold lasts until the runtime - // publishes that worktree at all. - if ( - !hostSnapshotAffirmsClientHostedPages(snapshot) && - browserWorkspaceHasClientHostedEnvironmentPage(state, tab, environmentId) - ) { - return false - } - return !(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - return ( - handle?.environmentId === environmentId && - nextRemoteBrowserPageIds.has(handle.remotePageId) - ) - }) - }) - .map((tab) => tab.id) - ) - const retainedBrowserTabs = currentBrowserTabs.filter( - (tab) => !removedBrowserWorkspaceIds.has(tab.id) - ) - const nextBrowserTabs = - retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 - ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] - : null - const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] - const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) - const mirroredEditorTabs = buildMirroredEditorTabs( - snapshot, - environmentId, - firstOpenFileByIdForWorktree(worktreeOpenFiles), - existingTabIndex, - hostGroupIdByTabId, - targetGroupId, - mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, - now - ) - const mirroredAgentTabs = buildMirroredAgentTabs( - snapshot, - hostGroupIdByTabId, - targetGroupId, - mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length, - currentUnifiedTabs, - now - ) - const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) - const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) - const removedEditorFileIds = new Set( - (reconcilesNonAgentTabs ? worktreeOpenFiles : []) - .filter( - (file) => - file.runtimeEnvironmentId === environmentId && - (file.mode === 'edit' || file.mode === 'markdown-preview') && - // Why: only cull host-mirrored tabs; locally opened files have no host counterpart, so their omission isn't a close signal. - file.mirroredFromRuntimeSession === true && - !mirroredEditorFileIds.has(file.id) - ) - .map((file) => file.id) - ) - const isReplacedOpenFile = (file: OpenFile): boolean => - file.runtimeEnvironmentId === environmentId && - (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) - const replacedOpenFileCount = worktreeOpenFiles.filter(isReplacedOpenFile).length - // Why: both consumers below ask only about this worktree, so the surviving ids answer - // them in worktree scope instead of walking every open file in the app. - const nextWorktreeOpenFileIds = new Set( - worktreeOpenFiles.filter((file) => !isReplacedOpenFile(file)).map((file) => file.id) - ) - for (const fileId of mirroredEditorFileIds) { - nextWorktreeOpenFileIds.add(fileId) - } - const mirroredOpenFiles = mirroredEditorTabs.map((entry) => entry.file) - const nextOpenFiles = (() => { - // Why: with nothing to drop or mirror, rebuilding reproduces the array exactly, so - // skip the global rebuild the equality check below would have thrown away anyway. - if (replacedOpenFileCount === 0 && mirroredOpenFiles.length === 0) { - return state.openFiles - } - const retained = state.openFiles.filter( - (file) => - !( - file.worktreeId === worktreeId && - file.runtimeEnvironmentId === environmentId && - (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) - ) - ) - const next = [...retained, ...mirroredOpenFiles] - return sameOpenFiles(state.openFiles, next) ? state.openFiles : next - })() - advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) - const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { - if (tab.contentType === 'agent-session') { - return false - } - if (tab.contentType === 'browser') { - return ( - !removedBrowserWorkspaceIds.has(tab.entityId) && - !mirroredBrowserWorkspaceIds.has(tab.entityId) - ) - } - if (tab.contentType === 'editor') { - return ( - !removedEditorFileIds.has(tab.entityId) && - !mirroredEditorFileIds.has(tab.entityId) && - !mirroredEditorHostTabIds.has(tab.id) - ) - } - if (tab.contentType !== 'terminal') { - return true - } - if (removedTerminalIds.has(tab.entityId) || removedTerminalIds.has(tab.id)) { - return false - } - return !mirroredTerminalIds.has(tab.entityId) && !mirroredTerminalIds.has(tab.id) - }) - const existingViewModeByTabId = new Map( - currentUnifiedTabs - .filter((tab) => tab.contentType === 'terminal' && tab.viewMode) - .map((tab) => [tab.id, tab.viewMode] as const) - ) - const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) => - buildTerminalUnifiedTab( - entry.tab, - hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId, - environmentId, - entry.tab.viewMode ?? existingViewModeByTabId.get(entry.tab.id) - ) - ) - const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab) - const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab) - const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab) - const mirroredUnifiedTabs = [ - ...mirroredTerminalUnifiedTabs, - ...mirroredBrowserUnifiedTabs, - ...mirroredEditorUnifiedTabs, - ...mirroredAgentUnifiedTabs - ] - const nextUnifiedTabs = - retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0 - ? [...retainedUnifiedTabs, ...mirroredUnifiedTabs] - : null - const validUnifiedTabIds = new Set(nextUnifiedTabs?.map((tab) => tab.id) ?? []) - const activeHostTerminalId = - terminalSurfaceTabs.find((tab) => tab.id === snapshot.activeTabId)?.id ?? - terminalSurfaceTabs.find((tab) => tab.isActive)?.id ?? - null - const activeHostTerminalParentId = - terminalSurfaceTabs.find((tab) => tab.id === activeHostTerminalId)?.parentTabId ?? - terminalSurfaceTabs.find((tab) => tab.isActive)?.parentTabId ?? - null - const activeMirroredTerminalId = activeHostTerminalId - ? toWebTerminalSurfaceTabId(activeHostTerminalParentId ?? activeHostTerminalId) - : null - const activeHostBrowser = - readyBrowserTabs.find((tab) => tab.id === snapshot.activeTabId) ?? - readyBrowserTabs.find((tab) => tab.isActive) ?? - null - const activeMirroredBrowser = activeHostBrowser - ? (mirroredBrowserTabs.find( - (entry) => entry.remotePageId === activeHostBrowser.browserPageId - ) ?? null) - : null - const activeMirroredBrowserTabId = activeMirroredBrowser?.unifiedTab.id ?? null - const activeMirroredBrowserWorkspaceId = activeMirroredBrowser?.workspace.id ?? null - const activeHostEditor = - readyEditorTabs.find((tab) => tab.id === snapshot.activeTabId) ?? - readyEditorTabs.find((tab) => tab.isActive) ?? - null - const activeMirroredEditor = activeHostEditor - ? (mirroredEditorTabs.find((entry) => entry.hostTabId === activeHostEditor.id) ?? null) - : null - const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null - const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null - const activeHostAgent = - snapshot.tabs - .filter(isAgentSessionTab) - .find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null - const activeMirroredAgentTabId = activeHostAgent - ? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ?? - null) - : null - const intentMirroredTerminalId = - navigationIntentTab?.type === 'terminal' - ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) - : null - const intentMirroredBrowser = - navigationIntentTab?.type === 'browser' - ? (mirroredBrowserTabs.find( - (entry) => - entry.hostTabId === navigationIntentTab.id || - entry.remotePageId === navigationIntentTab.browserPageId - ) ?? null) - : null - const intentMirroredEditor = - navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' - ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) - : null - const intentMirroredAgent = - navigationIntentTab?.type === 'agent-session' - ? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) - : null - const currentActiveTerminalStillExists = - state.activeTabIdByWorktree[worktreeId] && - (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) - ? state.activeTabIdByWorktree[worktreeId] - : null - // Why: caller intent targets the requested tab even when an older host leaves its own active tab unchanged. - const intentTerminalId = - honorSnapshotActiveFocus && navigationIntentTab?.type === 'terminal' - ? intentMirroredTerminalId - : null - const nextActiveTerminalId = - intentTerminalId ?? - currentActiveTerminalStillExists ?? - (snapshot.activeTabType === 'terminal' - ? (activeMirroredTerminalId ?? mirroredTerminalTabEntries[0]?.id) - : mirroredTerminalTabEntries[0]?.id) ?? - null - const currentActiveBrowserStillExists = - state.activeBrowserTabIdByWorktree[worktreeId] && - (nextBrowserTabs ?? []).some((tab) => tab.id === state.activeBrowserTabIdByWorktree[worktreeId]) - ? state.activeBrowserTabIdByWorktree[worktreeId] - : null - const intentBrowserWorkspaceId = - honorSnapshotActiveFocus && navigationIntentTab?.type === 'browser' - ? (intentMirroredBrowser?.workspace.id ?? null) - : null - const nextActiveBrowserWorkspaceId = - intentBrowserWorkspaceId ?? - currentActiveBrowserStillExists ?? - (snapshot.activeTabType === 'browser' - ? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id) - : mirroredBrowserTabs[0]?.workspace.id) ?? - null - const activeEditorFileIdForWorktree = state.activeFileIdByWorktree[worktreeId] - const currentActiveEditorStillExists = - activeEditorFileIdForWorktree && nextWorktreeOpenFileIds.has(activeEditorFileIdForWorktree) - ? activeEditorFileIdForWorktree - : null - const intentEditorFileId = honorSnapshotActiveFocus - ? (intentMirroredEditor?.file.id ?? null) - : null - const nextActiveEditorFileId = - intentEditorFileId ?? - currentActiveEditorStillExists ?? - (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id) - : mirroredEditorTabs[0]?.file.id) ?? - null - const currentVisibleUnifiedTabId = resolveWebSessionVisibleTabId( - state, - worktreeId, - nextUnifiedTabs ?? [] - ) - const currentVisibleStructuredTabId = - currentVisibleUnifiedTabId && - nextUnifiedTabs?.find( - (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' - ) - ? currentVisibleUnifiedTabId - : null - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] - // Why: Open Preview to the Side can activate an empty reserved group before the host - // browser lands. A snapshot that still has the host terminal active must not treat - // that emptiness as a terminal focus change. - const reservedEmptyPreviewFallbackTabId = - currentVisibleUnifiedTabId == null && - activeGroupId != null && - isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId }) - ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? []) - : null - // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. - const intentUnifiedTabId = honorSnapshotActiveFocus - ? navigationIntentTab?.type === 'browser' - ? (intentMirroredBrowser?.unifiedTab.id ?? null) - : navigationIntentTab?.type === 'terminal' - ? intentTerminalId - : navigationIntentTab?.type === 'agent-session' - ? (intentMirroredAgent?.unifiedTab.id ?? null) - : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' - ? (intentMirroredEditor?.unifiedTab.id ?? null) - : null - : null - const nextActiveUnifiedTabId = - intentUnifiedTabId ?? - currentVisibleUnifiedTabId ?? - reservedEmptyPreviewFallbackTabId ?? - (snapshot.activeTabType === 'agent-session' - ? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId) - : snapshot.activeTabType === 'browser' - ? (activeMirroredBrowserTabId ?? - mirroredBrowserTabs[0]?.unifiedTab.id ?? - state.activeTabIdByWorktree[worktreeId] ?? - nextActiveTerminalId) - : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? (activeMirroredEditorTabId ?? - mirroredEditorTabs[0]?.unifiedTab.id ?? - state.activeTabIdByWorktree[worktreeId] ?? - nextActiveTerminalId) - : nextActiveTerminalId) - const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id)) - const hostToLocalTabId = buildHostToLocalTabIdMap({ - terminalSurfaces: terminalSurfaceTabs, - terminalTabs: mirroredTerminalTabEntries, - browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs, - agentTabs: mirroredAgentTabs - }) - updateHostSessionTabIdMappings({ - environmentId, - worktreeId, - terminalSurfaces: terminalSurfaceTabs, - terminalTabs: mirroredTerminalTabEntries, - browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs, - agentTabs: mirroredAgentTabs - }) - - const currentGroups = state.groupsByWorktree[worktreeId] ?? [] - const clientGroupIdByLocalTabId = new Map( - mirroredBrowserTabs.flatMap((entry) => - entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : [] - ) - ) - // Why: once this worktree has client groups, placement is client-owned — snapshots may only - // append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order, - // host actives, and host layout apply only on first adoption (no client groups yet). - const clientOwnedPlacement = (() => { - // Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement - // is client-owned even before any local group record exists — first adoption on an - // empty worktree must repair a rendered-leaf-without-record or materialize a rendered - // group instead of publishing the tab into a group no local leaf will ever show. - if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) { - return null - } - // Why: an entity-identical replacement (provisional terminal → mirrored surface, local - // editor → host editor tab) is a rename — its position and focus must carry over. - const rekeyedTabIds = new Map() - for (const [provisionalTabId, hostTabId] of provisionalHandoffHostTabIds) { - const mirroredId = toWebTerminalSurfaceTabId(hostTabId) - if (mirroredId !== provisionalTabId) { - rekeyedTabIds.set(provisionalTabId, mirroredId) - } - } - for (const entry of mirroredEditorTabs) { - const existing = existingTabIndex.getEditorUnifiedTab(entry.file.id, entry.hostTabId) - if (existing && existing.id !== entry.unifiedTab.id) { - rekeyedTabIds.set(existing.id, entry.unifiedTab.id) - } - } - const knownGroupTabIds = new Set( - currentGroups.flatMap((group) => - group.tabOrder.map((tabId) => rekeyedTabIds.get(tabId) ?? tabId) - ) - ) - // Why: a pending record is this client's own create intent — authoritative even when the - // provisional tab was provisionally adopted elsewhere or the target group record lags its leaf. - // The entry's own clientGroupId comes first, so a group the user moved the row into after the - // create was recorded wins over the group the create asked for. - const placementMoves = mirroredBrowserTabs.flatMap((entry) => { - const recordedGroupId = peekWebSessionBrowserPlacementGroup({ - environmentId, - worktreeId, - remotePageId: entry.remotePageId - }) - if (!recordedGroupId) { - return [] - } - return [{ tabId: entry.unifiedTab.id, groupId: entry.clientGroupId ?? recordedGroupId }] - }) - for (const parentTabId of new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId))) { - const recordedGroupId = peekWebSessionTerminalPlacementGroup({ - environmentId, - worktreeId, - hostTabId: parentTabId - }) - if (recordedGroupId) { - placementMoves.push({ - tabId: toWebTerminalSurfaceTabId(parentTabId), - groupId: recordedGroupId - }) - } - } - const adoptedTabs = mirroredUnifiedTabs - .filter((tab) => !knownGroupTabIds.has(tab.id)) - .map((tab) => ({ - tabId: tab.id, - groupId: clientGroupIdByLocalTabId.get(tab.id) ?? tab.groupId - })) - return reconcileClientOwnedTabPlacement({ - currentGroups, - worktreeId, - validUnifiedTabIds, - adoptedTabs, - placementMoves, - rekeyedTabIds, - intentTabId: honorSnapshotActiveFocus ? (intentUnifiedTabId ?? null) : null, - reservedEmptyGroupFallbackTabId: reservedEmptyPreviewFallbackTabId, - currentActiveGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, - currentLayout: state.layoutByWorktree[worktreeId] ?? null, - isGroupReserved: (groupId) => - isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId }) - }) - })() - const nextGroups = (() => { - if (clientOwnedPlacement) { - return clientOwnedPlacement.groups - } - if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) { - return null - } - if (snapshot.tabGroups && snapshot.tabGroups.length > 0) { - return buildMirroredHostGroups({ - currentGroups, - hostGroups: snapshot.tabGroups, - hostToLocalTabId, - mirroredUnifiedIds, - nextActiveUnifiedTabId, - now, - validUnifiedTabIds, - environmentId, - worktreeId, - clientGroupIdByLocalTabId - }) - } - const strippedGroups = retainClientPlacedMirroredTabs({ - groups: currentGroups, - mirroredUnifiedIds, - validUnifiedTabIds, - clientGroupIdByLocalTabId, - nextActiveUnifiedTabId - }) - const target = strippedGroups.find((group) => group.id === targetGroupId) ?? { - id: targetGroupId, - worktreeId, - activeTabId: null, - tabOrder: [], - recentTabIds: [] - } - const targetOrder = [ - ...target.tabOrder.filter((tabId) => validUnifiedTabIds.has(tabId)), - ...mirroredUnifiedTabs - .filter((tab) => !clientGroupIdByLocalTabId.has(tab.id)) - .map((tab) => tab.id) - ] - const targetActiveTabId = - nextActiveUnifiedTabId && targetOrder.includes(nextActiveUnifiedTabId) - ? nextActiveUnifiedTabId - : target.activeTabId && targetOrder.includes(target.activeTabId) - ? target.activeTabId - : (targetOrder[0] ?? null) - const updatedTarget: TabGroup = { - ...target, - worktreeId, - tabOrder: targetOrder, - activeTabId: targetActiveTabId, - recentTabIds: targetActiveTabId - ? pushRecentTabId(sanitizeRecentTabIds(target.recentTabIds, targetOrder), targetActiveTabId) - : [] - } - const merged = strippedGroups.some((group) => group.id === targetGroupId) - ? strippedGroups.map((group) => (group.id === targetGroupId ? updatedTarget : group)) - : [...strippedGroups, updatedTarget] - return merged.filter( - (group) => - group.id === targetGroupId || - group.tabOrder.length > 0 || - isWebSessionBrowserPlacementGroupReserved({ - worktreeId, - groupId: group.id - }) - ) - })() - - const nextTabBarOrder = (() => { - const current = state.tabBarOrderByWorktree[worktreeId] ?? [] - const validTabBarIds = new Set([ - ...retainedUnifiedTabs.map((tab) => tab.id), - ...mirroredUnifiedTabs.map((tab) => tab.id) - ]) - const hostTabBarOrder = - snapshot.tabGroups?.flatMap((group) => - group.tabOrder - .map((tabId) => hostToLocalTabId.get(tabId)) - .filter((tabId): tabId is string => tabId !== undefined && validTabBarIds.has(tabId)) - ) ?? [] - const next: string[] = [] - const seen = new Set() - const push = (tabId: string): void => { - if (validTabBarIds.has(tabId) && !seen.has(tabId)) { - seen.add(tabId) - next.push(tabId) - } - } - // Why: snapshots can arrive after the client staged local browser tabs, so preserve visible order and only append new host tabs. - for (const tabId of current) { - push(tabId) - } - const hostOrMirroredOrder = - hostTabBarOrder.length > 0 ? hostTabBarOrder : mirroredUnifiedTabs.map((tab) => tab.id) - for (const tabId of hostOrMirroredOrder) { - push(tabId) - } - return next - })() - - let nextPtyIdsByTabId = state.ptyIdsByTabId - for (const removedId of removedTerminalResourceIds) { - if (nextPtyIdsByTabId[removedId]) { - nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId - ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) - : nextPtyIdsByTabId - delete nextPtyIdsByTabId[removedId] - } - } - for (const { tab, ptyIds } of mirroredTerminalTabs) { - if (ptyIds.length === 0) { - if (nextPtyIdsByTabId[tab.id]) { - nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId - ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) - : nextPtyIdsByTabId - delete nextPtyIdsByTabId[tab.id] - } - continue - } - const current = nextPtyIdsByTabId[tab.id] ?? [] - if (!sameStringArray(current, ptyIds)) { - nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId - ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) - : nextPtyIdsByTabId - nextPtyIdsByTabId[tab.id] = ptyIds - } - } - - let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId - for (const removedId of removedTerminalResourceIds) { - if (nextTerminalLayoutsByTabId[removedId]) { - nextTerminalLayoutsByTabId = - nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) - : nextTerminalLayoutsByTabId - delete nextTerminalLayoutsByTabId[removedId] - } - } - for (const { tab, layout } of mirroredTerminalTabs) { - if (!terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { - nextTerminalLayoutsByTabId = - nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) - : nextTerminalLayoutsByTabId - nextTerminalLayoutsByTabId[tab.id] = layout - } - } - - let nextUnreadTerminalTabs = state.unreadTerminalTabs - for (const removedId of removedTerminalIds) { - if (nextUnreadTerminalTabs[removedId]) { - nextUnreadTerminalTabs = - nextUnreadTerminalTabs === state.unreadTerminalTabs - ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) - : nextUnreadTerminalTabs - delete nextUnreadTerminalTabs[removedId] - } - } - - const pendingStartupByTabId = state.pendingStartupByTabId ?? {} - let nextPendingStartupByTabId = pendingStartupByTabId - const automaticAgentResumeClaimsByTabId = state.automaticAgentResumeClaimsByTabId ?? {} - let nextAutomaticAgentResumeClaimsByTabId = automaticAgentResumeClaimsByTabId - for (const removedId of exactProvisionalHandoffs) { - if (nextPendingStartupByTabId[removedId]) { - nextPendingStartupByTabId = - nextPendingStartupByTabId === pendingStartupByTabId - ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) - : nextPendingStartupByTabId - delete nextPendingStartupByTabId[removedId] - } - if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { - nextAutomaticAgentResumeClaimsByTabId = - nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId - ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) - : nextAutomaticAgentResumeClaimsByTabId - delete nextAutomaticAgentResumeClaimsByTabId[removedId] - } - } - - let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace - let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId - let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId - if (removedBrowserWorkspaceIds.size > 0) { - const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? []) - const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id)) - for (const workspace of retainedBrowserTabs) { - for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) { - nextBrowserPageIds.add(page.id) - } - } - for (const removedWorkspaceId of removedBrowserWorkspaceIds) { - const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] - if ( - !nextBrowserWorkspaceIds.has(removedWorkspaceId) && - nextBrowserPagesByWorkspace[removedWorkspaceId] - ) { - nextBrowserPagesByWorkspace = - nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) - : nextBrowserPagesByWorkspace - delete nextBrowserPagesByWorkspace[removedWorkspaceId] - } - for (const page of pages) { - if (nextBrowserPageIds.has(page.id)) { - continue - } - if (nextBrowserCertificateFailuresByPageId[page.id]) { - nextBrowserCertificateFailuresByPageId = - nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? writableWebSessionTabsRecord( - state, - 'browserCertificateFailuresByPageId', - batchContext - ) - : nextBrowserCertificateFailuresByPageId - delete nextBrowserCertificateFailuresByPageId[page.id] - } - if (nextRemoteBrowserPageHandlesByPageId[page.id]) { - nextRemoteBrowserPageHandlesByPageId = - nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? writableWebSessionTabsRecord( - state, - 'remoteBrowserPageHandlesByPageId', - batchContext - ) - : nextRemoteBrowserPageHandlesByPageId - delete nextRemoteBrowserPageHandlesByPageId[page.id] - } - } - } - } - for (const { page, certificateFailure, remotePageId, placement } of mirroredBrowserTabs) { - const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] - if (!sameBrowserPages(current, [page])) { - nextBrowserPagesByWorkspace = - nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) - : nextBrowserPagesByWorkspace - nextBrowserPagesByWorkspace[page.workspaceId] = [page] - } - const currentHandle = nextRemoteBrowserPageHandlesByPageId[page.id] - if ( - currentHandle?.environmentId !== environmentId || - currentHandle.remotePageId !== remotePageId || - // Why: this snapshot is the host publishing the page, which is exactly what clears a - // staged handle — without this the optimistic flag would survive every later snapshot. - currentHandle.staged === true || - // Why separately: a host that puts a restored page back on the server publishes no - // placement, so the handle is otherwise identical to the seed and the restored markers - // would never be spent — leaving the row cull-proof and pinned to the client-hosted pane. - currentHandle.restoredFromSession === true || - !optionalRuntimeBrowserPlacementsEqual(currentHandle.placement, placement) - ) { - nextRemoteBrowserPageHandlesByPageId = - nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) - : nextRemoteBrowserPageHandlesByPageId - nextRemoteBrowserPageHandlesByPageId[page.id] = { - environmentId, - remotePageId, - ...(placement ? { placement } : {}) - } - } - // Why here and not on the staged flag alone: the flag is cleared by this very block, so every - // later snapshot in the create's materialization wait would see an un-spent record and move the - // tab back to the group the create asked for. Both peeks (the group precedence and the - // placementMoves repair) run earlier in this same pass by design: moving the mark ahead of them - // would spend the intent before the pass that adopts the page can place it. - markWebSessionBrowserPlacementAdopted({ environmentId, worktreeId, remotePageId }) - // Why: a client-hosted page's certificate failure is raised by the local guest webview and is - // structurally absent from what the host publishes, so host snapshots must not own that record - // — reconciling here would delete the local one on the next metadata update. - if ( - placement?.kind !== 'client' && - !browserCertificateFailureEqual( - nextBrowserCertificateFailuresByPageId[page.id], - certificateFailure - ) - ) { - nextBrowserCertificateFailuresByPageId = - nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) - : nextBrowserCertificateFailuresByPageId - if (certificateFailure) { - nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure - } else { - delete nextBrowserCertificateFailuresByPageId[page.id] - } - } - } - - const nextTabsByWorktree = withWorktreeEntry( - state, - 'tabsByWorktree', - worktreeId, - nextTerminalTabs, - sameTerminalTabs, - batchContext - ) - const nextBrowserTabsByWorktree = withWorktreeEntry( - state, - 'browserTabsByWorktree', - worktreeId, - nextBrowserTabs, - sameBrowserTabs, - batchContext - ) - // Why: under client-owned placement the reconciled groups are the membership truth; - // the unified tabs' groupId field must agree or TabBar filters them out of their strip. - const placedUnifiedTabs = (() => { - if (!clientOwnedPlacement?.groups || !nextUnifiedTabs) { - return nextUnifiedTabs - } - const groupIdByTabId = new Map( - clientOwnedPlacement.groups.flatMap((group) => - group.tabOrder.map((tabId) => [tabId, group.id] as const) - ) - ) - let changed = false - const placed = nextUnifiedTabs.map((tab) => { - const groupId = groupIdByTabId.get(tab.id) - if (!groupId || groupId === tab.groupId) { - return tab - } - changed = true - return { ...tab, groupId } - }) - return changed ? placed : nextUnifiedTabs - })() - const nextUnifiedTabsByWorktree = withWorktreeEntry( - state, - 'unifiedTabsByWorktree', - worktreeId, - placedUnifiedTabs, - sameUnifiedTabs, - batchContext - ) - const nextGroupsByWorktree = withWorktreeEntry( - state, - 'groupsByWorktree', - worktreeId, - nextGroups, - sameGroups, - batchContext - ) - const nextActiveGroupId = clientOwnedPlacement - ? clientOwnedPlacement.activeGroupId - : // Why: status/title snapshots carry the host's last active tab; a client that already switched panes keeps its local group focus. - (nextGroups?.find((group) => group.activeTabId === nextActiveUnifiedTabId)?.id ?? - nextGroups?.find((group) => group.id === snapshot.activeGroupId)?.id ?? - nextGroups?.[0]?.id ?? - null) - const nextActiveGroupIdByWorktree = - nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId - ? withWorktreeEntry( - state, - 'activeGroupIdByWorktree', - worktreeId, - nextActiveGroupId ?? targetGroupId, - (current, next) => current === next, - batchContext - ) - : state.activeGroupIdByWorktree - const nextLayoutByWorktree = (() => { - if (!nextGroups) { - return state.layoutByWorktree - } - // Why: client-owned placement derives its layout from the local one (pruned, plus - // repair leaves for surviving groups the layout lost), so a preserveLocalLayout owner - // still applies it — the option only rejects host-authored layout below. - if (clientOwnedPlacement) { - const clientLayout = - clientOwnedPlacement.layout ?? - (nextActiveGroupId ? { type: 'leaf' as const, groupId: nextActiveGroupId } : null) - if (!clientLayout || tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], clientLayout)) { - return state.layoutByWorktree - } - return withWorktreeEntry( - state, - 'layoutByWorktree', - worktreeId, - clientLayout, - (current, next) => current === next, - batchContext - ) - } - if (options?.preserveLocalLayout) { - return state.layoutByWorktree - } - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) - const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId } - const hostLayoutGroupIds = collectLayoutGroupIds(hostLayout ?? undefined) - const hostGroupIds = new Set(snapshot.tabGroups?.map((group) => group.id) ?? []) - const extraGroupIds = new Set( - nextGroups - .map((group) => group.id) - .filter((groupId) => - hostLayout - ? !hostLayoutGroupIds.has(groupId) - : snapshot.tabGroups && snapshot.tabGroups.length > 0 - ? !hostGroupIds.has(groupId) - : false - ) - ) - const localExtraLayout = pruneTabGroupLayout(state.layoutByWorktree[worktreeId], extraGroupIds) - const hostBaseLayout = - hostLayout ?? (snapshot.tabGroups && snapshot.tabGroups.length > 0 ? defaultLeafLayout : null) - const fallbackLayout = - appendTabGroupLayout(hostBaseLayout, localExtraLayout) ?? - (snapshot.tabGroups && snapshot.tabGroups.length > 0 - ? defaultLeafLayout - : state.layoutByWorktree[worktreeId] - ? null - : defaultLeafLayout) - if (!fallbackLayout) { - return state.layoutByWorktree - } - if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) { - return state.layoutByWorktree - } - return withWorktreeEntry( - state, - 'layoutByWorktree', - worktreeId, - fallbackLayout, - (current, next) => current === next, - batchContext - ) - })() - const nextTabBarOrderByWorktree = withWorktreeEntry( - state, - 'tabBarOrderByWorktree', - worktreeId, - nextTabBarOrder.length > 0 ? nextTabBarOrder : null, - (a, b) => sameStringArray(a ?? [], b ?? []), - batchContext - ) - const nextActiveTabIdByWorktree = - (state.activeTabIdByWorktree[worktreeId] ?? null) !== - (intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId) - ? withWorktreeEntry( - state, - 'activeTabIdByWorktree', - worktreeId, - intentMirroredAgent?.unifiedTab.id ?? - currentVisibleStructuredTabId ?? - nextActiveTerminalId, - (current, next) => (current ?? null) === next, - batchContext, - false - ) - : state.activeTabIdByWorktree - const nextActiveBrowserTabIdByWorktree = - (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId - ? withWorktreeEntry( - state, - 'activeBrowserTabIdByWorktree', - worktreeId, - nextActiveBrowserWorkspaceId, - (current, next) => (current ?? null) === next, - batchContext, - false - ) - : state.activeBrowserTabIdByWorktree - const nextActiveFileIdByWorktree = - (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId - ? withWorktreeEntry( - state, - 'activeFileIdByWorktree', - worktreeId, - nextActiveEditorFileId, - (current, next) => (current ?? null) === next, - batchContext, - false - ) - : state.activeFileIdByWorktree - const isActiveWorktree = state.activeWorktreeId === worktreeId - const focusIntentVisibleTabType = - navigationIntentTab?.type === 'agent-session' && intentMirroredAgent - ? ('agent-session' as const) - : navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId - ? ('browser' as const) - : navigationIntentTab?.type === 'terminal' && intentTerminalId - ? ('terminal' as const) - : intentEditorFileId - ? ('editor' as const) - : null - const snapshotVisibleTabType = - snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId - ? ('agent-session' as const) - : snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId - ? ('browser' as const) - : snapshot.activeTabType === 'terminal' && nextActiveTerminalId - ? ('terminal' as const) - : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && - nextActiveEditorFileId - ? ('editor' as const) - : null - const currentVisibleTabType = - state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null) - const currentVisibleTabTypeStillValid = - currentVisibleStructuredTabId !== null - ? ('agent-session' as const) - : currentVisibleTabType === 'agent-session' && - currentVisibleUnifiedTabId && - nextUnifiedTabs?.some( - (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' - ) - ? ('agent-session' as const) - : currentVisibleTabType === 'browser' && currentActiveBrowserStillExists - ? ('browser' as const) - : currentVisibleTabType === 'editor' && currentActiveEditorStillExists - ? ('editor' as const) - : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists - ? ('terminal' as const) - : null - const activeUnifiedTab = - nextActiveUnifiedTabId && nextUnifiedTabs - ? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null) - : null - const fallbackVisibleTabType = - activeUnifiedTab !== null - ? toVisibleTabType(activeUnifiedTab) - : nextActiveTerminalId - ? ('terminal' as const) - : nextActiveBrowserWorkspaceId - ? ('browser' as const) - : nextActiveEditorFileId - ? ('editor' as const) - : ('terminal' as const) - // Why: don't keep pointing shortcuts at a removed browser/editor; a client-initiated activation lets the snapshot's type switch the visible pane. - const nextVisibleTabType = honorSnapshotActiveFocus - ? (focusIntentVisibleTabType ?? - currentVisibleTabTypeStillValid ?? - snapshotVisibleTabType ?? - fallbackVisibleTabType) - : (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType) - const currentActiveTerminalStillValid = - state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId) - ? state.activeTabId - : null - const currentActiveEditorStillValid = - state.activeFileId && nextWorktreeOpenFileIds.has(state.activeFileId) - ? state.activeFileId - : null - const nextActiveTabId = isActiveWorktree - ? (intentMirroredAgent?.unifiedTab.id ?? - (snapshot.activeTabType === 'terminal' - ? nextActiveTerminalId - : (currentActiveTerminalStillValid ?? nextActiveTerminalId))) - : state.activeTabId - const nextActiveBrowserTabId = isActiveWorktree - ? nextActiveBrowserWorkspaceId - : state.activeBrowserTabId - const nextActiveFileId = isActiveWorktree - ? snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? nextActiveEditorFileId - : (currentActiveEditorStillValid ?? nextActiveEditorFileId) - : state.activeFileId - const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType - const nextActiveTabTypeByWorktree = - state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType - ? withWorktreeEntry( - state, - 'activeTabTypeByWorktree', - worktreeId, - nextVisibleTabType, - (current, next) => current === next, - batchContext - ) - : state.activeTabTypeByWorktree - const agentStatusPatch = buildMirroredAgentStatusPatch( - state, - currentTerminalTabs, - terminalSurfaceTabs, - mirroredTerminalTabs, - now, - batchContext - ) - // Why: only a host snapshot that omits a tab is a retraction; a tombstone clears the mirror - // for every environment at once, and sweeping there erases rows a live sibling still owns. - const retractedTabSweepPatch = isWebSessionTabsWorktreeRemovalFrame(snapshot) - ? null - : buildRetractedMirroredTabSweepPatch( - state, - worktreeId, - nextTabsByWorktree, - agentStatusPatch, - removedTerminalResourceIds, - batchContext - ) - // Why: mirrored ids are stable, so a published-again id proves the tab is not closed — - // a lingering closed-tab marker would blackhole its byte-derived status for the whole - // session (host-restart subset frames, cross-host collision replays). The close-intent - // filter already holds genuinely closing tabs out of the snapshot. - const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( - retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? - state.recentlyClosedAgentStatusTabIds, - mirroredTerminalIds - ) - - const patch: Partial = { - ...agentStatusPatch, - ...retractedTabSweepPatch, - ...remirroredClosedTabLiftPatch, - ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), - ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree - ? { browserTabsByWorktree: nextBrowserTabsByWorktree } - : {}), - ...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree - ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } - : {}), - ...(nextGroupsByWorktree !== state.groupsByWorktree - ? { groupsByWorktree: nextGroupsByWorktree } - : {}), - ...(nextActiveGroupIdByWorktree !== state.activeGroupIdByWorktree - ? { activeGroupIdByWorktree: nextActiveGroupIdByWorktree } - : {}), - ...(nextLayoutByWorktree !== state.layoutByWorktree - ? { layoutByWorktree: nextLayoutByWorktree } - : {}), - ...(nextTabBarOrderByWorktree !== state.tabBarOrderByWorktree - ? { tabBarOrderByWorktree: nextTabBarOrderByWorktree } - : {}), - ...(nextPtyIdsByTabId !== state.ptyIdsByTabId ? { ptyIdsByTabId: nextPtyIdsByTabId } : {}), - ...(nextTerminalLayoutsByTabId !== state.terminalLayoutsByTabId - ? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId } - : {}), - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextPendingStartupByTabId !== pendingStartupByTabId - ? { pendingStartupByTabId: nextPendingStartupByTabId } - : {}), - ...(nextAutomaticAgentResumeClaimsByTabId !== automaticAgentResumeClaimsByTabId - ? { automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId } - : {}), - ...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace - ? { browserPagesByWorkspace: nextBrowserPagesByWorkspace } - : {}), - ...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId - ? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } - : {}), - ...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId - ? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId } - : {}), - ...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree - ? { activeTabIdByWorktree: nextActiveTabIdByWorktree } - : {}), - ...(nextActiveBrowserTabIdByWorktree !== state.activeBrowserTabIdByWorktree - ? { activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree } - : {}), - ...(nextActiveFileIdByWorktree !== state.activeFileIdByWorktree - ? { activeFileIdByWorktree: nextActiveFileIdByWorktree } - : {}), - ...(nextActiveTabId !== state.activeTabId ? { activeTabId: nextActiveTabId } : {}), - ...(nextActiveBrowserTabId !== state.activeBrowserTabId - ? { activeBrowserTabId: nextActiveBrowserTabId } - : {}), - ...(nextActiveFileId !== state.activeFileId ? { activeFileId: nextActiveFileId } : {}), - ...(nextActiveTabType !== state.activeTabType ? { activeTabType: nextActiveTabType } : {}), - ...(nextActiveTabTypeByWorktree !== state.activeTabTypeByWorktree - ? { activeTabTypeByWorktree: nextActiveTabTypeByWorktree } - : {}) - } - - return Object.keys(patch).length === 0 ? state : patch -} - -export function applyWebSessionTabsSnapshot( - state: WebSessionTabsSyncState, - rawSnapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - now = Date.now(), - options?: WebSessionTabsSnapshotApplyOptions -): WebSessionTabsSyncState | Partial { - return applyWebSessionTabsSnapshotWithContext( - state, - rawSnapshot, - environmentId, - now, - undefined, - options - ) -} - -export function applyWebSessionTabsSnapshots( - state: WebSessionTabsSyncState, - snapshots: readonly RuntimeMobileSessionTabsResult[], - environmentId: string, - now = Date.now() -): WebSessionTabsSyncState | Partial { - const nextState = { ...state } - const batchContext: WebSessionTabsBatchContext = { - agentPaneKeysByTabId: null, - changedRecords: new Set(), - openFilesIndex: null - } - let mergedPatch: Partial = {} - for (const snapshot of snapshots) { - const patch = applyWebSessionTabsSnapshotWithContext( - nextState, - snapshot, - environmentId, - now, - batchContext - ) - if (patch === nextState) { - continue - } - mergedPatch = { ...mergedPatch, ...patch } - Object.assign(nextState, patch) - } - const mutableMergedPatch = mergedPatch as Record - const mutableNextState = nextState as unknown as Record - for (const recordKey of batchContext.changedRecords) { - mutableMergedPatch[recordKey] = mutableNextState[recordKey] - } - return Object.keys(mergedPatch).length === 0 ? state : mergedPatch -} - -export function applyFreshWebSessionTabsSnapshot( - state: WebSessionTabsSyncState, - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - now = Date.now() -): WebSessionTabsSyncState | Partial { - if (!shouldApplyWebSessionTabsSnapshot(snapshot, environmentId)) { - return state - } - return applyWebSessionTabsSnapshot(state, snapshot, environmentId, now) -} - -export function applyFreshWebSessionTabsSnapshots( - state: WebSessionTabsSyncState, - snapshots: readonly RuntimeMobileSessionTabsResult[], - environmentId: string, - now = Date.now() -): WebSessionTabsSyncState | Partial { - const freshSnapshots = snapshots.filter((snapshot) => - shouldApplyWebSessionTabsSnapshot(snapshot, environmentId) - ) - return freshSnapshots.length === 0 - ? state - : applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId, now) -} - -type WebSessionTabsSnapshotOperation = { - environmentId: string - snapshot: RuntimeMobileSessionTabsResult - runtimeId?: string -} - -type DecidedWebSessionTabsSnapshotOperation = WebSessionTabsSnapshotOperation & { - decision: WebSessionTabsSnapshotDecision -} - -/** Why: the settle must name the decision the apply ran on, so each operation - * is decided once here instead of again inside the store updater. */ -function decideWebSessionTabsSnapshotOperations( - operations: readonly WebSessionTabsSnapshotOperation[] -): DecidedWebSessionTabsSnapshotOperation[] { - return operations.map((operation) => ({ - ...operation, - decision: decideWebSessionTabsSnapshot( - operation.snapshot, - operation.environmentId, - operation.runtimeId - ) - })) -} - -function applyWebSessionTabsSnapshotOperations( - state: WebSessionTabsSyncState, - operations: readonly DecidedWebSessionTabsSnapshotOperation[] -): WebSessionTabsSyncState | Partial { - let nextState = state - let mergedPatch: Partial = {} - for (const { environmentId, snapshot, decision } of operations) { - if (!decision.apply) { - continue - } - const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId) - if (patch === nextState) { - continue - } - mergedPatch = { ...mergedPatch, ...patch } - nextState = { ...nextState, ...patch } - } - return Object.keys(mergedPatch).length === 0 ? state : mergedPatch -} - -/** Proof that a session-tabs store patch committed. Invoke it after the - * frame's finishRecovery to settle the mirror latch for its verdicts. */ -export type HostSessionMirrorSettle = () => void - -/** A worktree the patch carried, named with the decision its frame's fate came - * from. Requiring the decision is the point: a settle cannot be written for a - * pair whose frame was never decided, so no site can overstate its evidence. */ -export type HostSessionMirrorPatchFrame = { - environmentId: string - worktreeId: string - decision: WebSessionTabsSnapshotDecision - expectedEnvironmentConnectionGeneration?: number - expectedEnvironmentPairingRevision?: number - expectedTrackingGeneration?: number -} - -/** - * The host verdicts a store patch carries. Every frame it touched is listed; - * only the ones whose decision carries `settlesHostMirror` settle, so a frame - * the patch wrote no bytes for settles ONLY when the store already holds an - * equal/newer accepted view of it. `fullInventory` upgrades a completely - * applied inventory to an environment-wide verdict: absence from it is itself - * the host answering, but a partial one speaks only for the worktrees whose - * frames reached the store — the rest wait for the next clean inventory rather - * than read as retracted. - */ -export type HostSessionMirrorPatchVerdict = { - frames: readonly HostSessionMirrorPatchFrame[] - fullInventory?: { - environmentId: string - publishedSnapshotCount: number - authoritative?: boolean - expectedEnvironmentConnectionGeneration?: number - expectedEnvironmentPairingRevision?: number - expectedTrackingGeneration?: number - } -} - -/** - * An inventory that published nothing is the one shape carrying no host - * evidence at all: `settles.length === publishedSnapshotCount` is `0 === 0`, so - * a live host answering `[]` before its renderer's first publish used to be - * upgraded into an environment-wide "the host has spoken" — draining parked - * resumes into forking a second agent onto a PTY the host still runs. - * - * The distinguisher has to be host readiness, not list emptiness: a host with - * genuinely zero terminals must still settle or its panes park forever. Only - * `none` settles; `live` and `unverifiable` leave waiters for the next - * inventory or per-worktree frame. - */ -type HostSessionMirrorSettleFence = { - environmentId: string - connectionGeneration: number - pairingRevision?: number - trackingGeneration: number -} - -function captureHostSessionMirrorSettleFence( - environmentId: string, - expected: { - connectionGeneration?: number - pairingRevision?: number - trackingGeneration?: number - } = {} -): HostSessionMirrorSettleFence { - return { - environmentId, - connectionGeneration: - expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), - pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), - trackingGeneration: - expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) - } -} - -function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { - return ( - getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && - getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && - getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration - ) -} - -function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( - fence: HostSessionMirrorSettleFence -): void { - void probeHostLiveTerminals( - fence.environmentId, - undefined, - fence.connectionGeneration, - fence.pairingRevision - ).then((verdict) => { - // Why: the probe is a round trip, and a reconnect in between would make its - // answer speak for a connection whose PTYs nobody listed. - if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { - markHostSessionMirrorHydrated(fence.environmentId) - } - }) -} - -function createHostSessionMirrorSettle( - verdict: HostSessionMirrorPatchVerdict -): HostSessionMirrorSettle { - const fenceByEnvironment = new Map() - for (const frame of verdict.frames) { - fenceByEnvironment.set( - frame.environmentId, - captureHostSessionMirrorSettleFence(frame.environmentId, { - connectionGeneration: frame.expectedEnvironmentConnectionGeneration, - pairingRevision: frame.expectedEnvironmentPairingRevision, - trackingGeneration: frame.expectedTrackingGeneration - }) - ) - } - if (verdict.fullInventory) { - fenceByEnvironment.set( - verdict.fullInventory.environmentId, - captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { - connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, - pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, - trackingGeneration: verdict.fullInventory.expectedTrackingGeneration - }) - ) - } - return () => { - const { frames, fullInventory } = verdict - const settles = frames.filter(({ decision }) => decision.settlesHostMirror) - if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { - const fence = fenceByEnvironment.get(fullInventory.environmentId) - if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { - return - } - if (fullInventory.publishedSnapshotCount === 0) { - if (fullInventory.authoritative) { - markHostSessionMirrorHydrated(fullInventory.environmentId) - } else { - settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) - } - return - } - markHostSessionMirrorHydrated(fullInventory.environmentId) - return - } - for (const { environmentId, worktreeId } of settles) { - const fence = fenceByEnvironment.get(environmentId) - if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - } - } - } -} - -/** The settle a frame earns when its own patch wrote nothing — or null when it - * earns none. Only a decision carrying `settlesHostMirror` may stand in for a - * patch: it is backed by the equal/newer view already accepted into the store, - * so the host HAS answered for this worktree. Taking the decision as the - * argument is the point — a new rejection branch cannot inherit a settle by - * defaulting, and a transport failure is `unverifiable` and never gets here. */ -function hostSessionMirrorSettleForPatchlessFrame( - decision: WebSessionTabsSnapshotDecision, - environmentId: string, - worktreeId: string, - expected: { - connectionGeneration?: number - pairingRevision?: number - trackingGeneration?: number - } = {} -): HostSessionMirrorSettle | null { - if (!decision.settlesHostMirror) { - return null - } - const fence = captureHostSessionMirrorSettleFence(environmentId, expected) - return () => { - if (hostSessionMirrorSettleFenceIsCurrent(fence)) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - } - } -} - -export function applyWebSessionTabsStorePatch( - buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, - hostMirrorVerdict: HostSessionMirrorPatchVerdict, - agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], - allowCompletionNotification = false -): HostSessionMirrorSettle { - let mirroredAgentStatusChanged = false - // Why: zustand commits before notifying subscribers, so a producer that ran - // to completion has landed even when a subscriber throws afterwards. - let patchCommitted = false - const acceptedNotificationStatuses: { - paneKey: string - worktreeId: string - seedOnly?: true - payload: ReturnType & { - stateStartedAt: number - localStateStartedAt?: number - } - }[] = [] - const runStorePatch = ( - state: AppState - ): WebSessionTabsSyncState | Partial => { - const patch = buildPatch(state) - mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') - if (agentStatusSnapshots) { - const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey - const snapshots = Array.isArray(agentStatusSnapshots) - ? agentStatusSnapshots - : [agentStatusSnapshots] - for (const snapshot of snapshots) { - for (const surface of snapshot.tabs) { - if (surface.type !== 'terminal') { - continue - } - const remapped = remapHostAgentStatus(surface) - const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined - const turnCompletedAt = remapped - ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) - : undefined - if (remapped?.state === 'done' && turnCompletedAt === undefined) { - hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) - } - // Why: client OSC owns display state, but only the host hook stream carries background-turn stamps. - const clientOwnedNotification = Boolean( - remapped && - isClientAuthoritativeAgentStatusPane(remapped.paneKey) && - (remapped.state === 'working' || turnCompletedAt !== undefined) - ) - if ( - !remapped || - (!clientOwnedNotification && - (!accepted || - accepted === state.agentStatusByPaneKey[remapped.paneKey] || - !agentStatusEntryEqual(accepted, remapped))) - ) { - continue - } - const notificationStatus = clientOwnedNotification ? remapped : accepted - if (!notificationStatus) { - continue - } - const currentClientStateStartedAt = clientOwnedNotification - ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt - : undefined - let localStateStartedAt = currentClientStateStartedAt - if ( - clientOwnedNotification && - notificationStatus.state === 'working' && - currentClientStateStartedAt !== undefined - ) { - if (turnCompletedAt === undefined) { - const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( - notificationStatus.paneKey - ) - if ( - !retainedBoundary || - retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || - retainedBoundary.hostPrompt !== notificationStatus.prompt || - retainedBoundary.stamped - ) { - hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) - hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { - hostStateStartedAt: notificationStatus.stateStartedAt, - hostPrompt: notificationStatus.prompt, - clientStateStartedAt: currentClientStateStartedAt, - stamped: false - }) - if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { - const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value - if (oldestPaneKey !== undefined) { - hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) - } - } - } - } else { - const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( - notificationStatus.paneKey - ) - if ( - retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && - retainedBoundary.hostPrompt === notificationStatus.prompt - ) { - localStateStartedAt = retainedBoundary.clientStateStartedAt - retainedBoundary.stamped = true - } - } - } - if (!allowCompletionNotification && notificationStatus.state !== 'working') { - continue - } - acceptedNotificationStatuses.push({ - paneKey: notificationStatus.paneKey, - worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, - ...(!allowCompletionNotification ? { seedOnly: true as const } : {}), - payload: { - ...pickParsedAgentStatusPayload({ - ...notificationStatus, - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) - }), - stateStartedAt: notificationStatus.stateStartedAt, - ...(clientOwnedNotification - ? { - localStateStartedAt - } - : {}) - } - }) - } - } - } - patchCommitted = true - return patch - } - try { - useAppStore.setState(runStorePatch) - } catch (error) { - if (!patchCommitted) { - throw error - } - console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) - } - // Why: the receipt exists exactly from the commit on, so no caller can settle - // evidence the store does not hold, and none can lose one it does. - const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) - try { - // Why: paired-web snapshots bypass setAgentStatus, so arm the stale-boundary timer explicitly like local hook events do. - if (mirroredAgentStatusChanged) { - useAppStore.getState().scheduleAgentStatusFreshness() - } - for (const status of acceptedNotificationStatuses) { - observeAgentHookCompletionForNotification(status) - } - } catch (error) { - // Why: notification bookkeeping cannot un-land the patch, so it must not - // destroy the settle receipt of evidence already in the store. - console.warn('[web-session-tabs-sync] post-patch bookkeeping failed:', error) - } - return settleHostMirror -} - -function loadInitialWebSessionTabs( - environmentId: string, - expectedEnvironmentConnectionGeneration: number, - expectedEnvironmentPairingRevision: number | undefined, - expectedTrackingGeneration: number, - isCurrent: () => boolean -): void { - // Why: listAll is bootstrap fallback; a stream received after this boundary owns the result. - const requestReceivedFrame = nextReceivedSessionTabsFrame() - // Why: only a conclusion that reached the store may settle the mirror, so - // this stays null on every failure exit below. - let settleHydration: (() => void) | null = null - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'session.tabs.listAll', - params: {}, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision - }) - .then(async (response: RuntimeRpcResponse) => { - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { - return - } - if (response.ok === false) { - console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) - return - } - const result = response.result - if (!isSessionTabsListAllResult(result)) { - console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') - return - } - const runtimeId = getSessionTabsRuntimeIdFromResponse(response) - const latestReceivedFrame = - latestReceivedSessionTabsFrameByEnvironment.get(environmentId) ?? 0 - if ( - runtimeId && - latestReceivedFrame <= requestReceivedFrame && - !acceptSessionTabsRuntimeId(environmentId, runtimeId, requestReceivedFrame) - ) { - return - } - recordReceivedWebSessionTabsEnvironmentFrame(environmentId, requestReceivedFrame) - const receivedFrames = result.snapshots.map((snapshot) => - recordReceivedWebSessionTabsSnapshot( - environmentId, - snapshot, - requestReceivedFrame, - runtimeId, - 'bootstrap' - ) - ) - const finishRecoveries = result.snapshots.map((snapshot, index) => - beginWebSessionTabsSnapshotRecovery( - environmentId, - snapshot.worktree, - receivedFrames[index]! - ) - ) - try { - const recovered = await Promise.all( - result.snapshots.map((snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId, - { - expectedEnvironmentPairingRevision, - getCurrentState: () => useAppStore.getState() - } - ) - ) - ) - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { - return - } - const initialInventorySuperseded = - (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) > - requestReceivedFrame - const applicable = recovered.filter( - (snapshot, index): snapshot is RuntimeMobileSessionTabsResult => - snapshot !== null && - !initialInventorySuperseded && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - snapshot, - receivedFrames[index]!, - runtimeId - ) - ) - const decisions = applicable.map((snapshot) => - decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId) - ) - const freshSnapshots = applicable.filter( - (_snapshot, position) => decisions[position]!.apply - ) - const initialInventoryStillCurrent = - latestReceivedSessionTabsFrameByEnvironment.get(environmentId) === requestReceivedFrame && - (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) <= - requestReceivedFrame - settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), - { - frames: applicable.map((snapshot, position) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision: decisions[position]!, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - })), - ...(initialInventoryStillCurrent - ? { - fullInventory: { - environmentId, - authoritative: result.authoritative === true, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - // Why: a workspace the mirror never writes is not part of the - // inventory the environment-wide verdict has to account for. - publishedSnapshotCount: result.snapshots.filter((snapshot) => - isHostMirroredWorktree(snapshot.worktree) - ).length - } - } - : {}) - }, - applicable - ) - } finally { - for (const finishRecovery of finishRecoveries) { - finishRecovery() - } - } - }) - .catch((error) => { - if (isCurrent()) { - console.warn( - '[web-session-tabs-sync] failed to load initial session tabs:', - error instanceof Error ? error.message : String(error) - ) - } - }) - .finally(() => { - // Why: a rejected or timed-out inventory is `unverifiable`, never proof a - // host-owned PTY exited, and this latch releases into replaying a resume. - // Parked work waits for the next inventory instead. - if ( - isCurrent() && - getRuntimeEnvironmentRevision(environmentId) === expectedEnvironmentPairingRevision - ) { - settleHydration?.() - } - }) -} - -export function useWebSessionTabsSync(): void { - const recordVisibilityResumeSnapshotRef = useRef< - ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string - ) => void - >(() => {}) - const recordVisibilityResumeSnapshotReceiptRef = useRef< - ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string - ) => void - >(() => {}) - const shouldApplyVisibilityResumeSnapshotRef = useRef< - ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string - ) => boolean - >(() => true) - const visibilityResumeOmissionsByKeyRef = useRef(new Map()) - const mirroredSessionTabsOwnerRevisionByEnvironmentRef = useRef( - new Map() - ) - const activeRuntimeEnvironmentIdRef = useRef(null) - const activeRuntimeWorktreeKeyRef = useRef(null) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const runtimeSessionMirrorEnvironmentKey = useRuntimeSessionMirrorEnvironmentKey() - const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => - getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - ) - const activeWorktreeRuntimeId = useAppStore((state) => { - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - return environmentId - ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.status?.runtimeId ?? null) - : null - }) - const activeWorktreeRuntimeConnectionGeneration = useAppStore((state) => { - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - return environmentId - ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.connectionGeneration ?? 0) - : 0 - }) - const activeWorktreeRuntimePairingRevision = useAppStore((state) => { - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - const environment = state.runtimeEnvironments.find( - (candidate) => candidate.id === environmentId - ) - return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined - }) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - // Why: only resume callbacks read these refs, and resumes fire from visibilitychange or - // stale-visibility recovery — outside React — so committing during layout leaves no window - // where a resume could read the previous worktree's key. - useLayoutEffect(() => { - activeRuntimeEnvironmentIdRef.current = activeWorktreeRuntimeEnvironmentId?.trim() || null - activeRuntimeWorktreeKeyRef.current = - activeWorktreeRuntimeEnvironmentId && activeWorktreeId - ? sessionTabsFreshnessKey(activeWorktreeRuntimeEnvironmentId, activeWorktreeId) - : null - }, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId]) - - useEffect( - () => () => { - for (const environmentId of mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.keys()) { - clearWebSessionTabsTrackingForEnvironment(environmentId) - } - mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.clear() - visibilityResumeOmissionsByKeyRef.current.clear() - }, - [] - ) - - useEffect(() => { - const environments = runtimeSessionMirrorEnvironmentKey - ? runtimeSessionMirrorEnvironmentKey - .split('\u0000') - .map((entry) => { - const [environmentId = '', , rawConnectionGeneration = '0', rawRevision = ''] = - entry.split('\u0001') - return { - environmentId, - expectedEnvironmentConnectionGeneration: Number(rawConnectionGeneration), - expectedEnvironmentPairingRevision: - rawRevision === '' ? undefined : Number(rawRevision) - } - }) - .filter(({ environmentId }) => environmentId.trim()) - : [] - const mirroredEnvironmentOwnerRevisions = new Map( - (workspaceSessionReady ? environments : []).map( - ({ environmentId, expectedEnvironmentPairingRevision }) => - [environmentId, expectedEnvironmentPairingRevision] as const - ) - ) - const previousOwnerRevisions = mirroredSessionTabsOwnerRevisionByEnvironmentRef.current - // Why: same-owner tracking must survive effect restarts to reconcile removals missed while hidden. - for (const [environmentId, previousRevision] of previousOwnerRevisions) { - if ( - !mirroredEnvironmentOwnerRevisions.has(environmentId) || - mirroredEnvironmentOwnerRevisions.get(environmentId) !== previousRevision - ) { - clearWebSessionTabsTrackingForEnvironment(environmentId) - } - } - mirroredSessionTabsOwnerRevisionByEnvironmentRef.current = mirroredEnvironmentOwnerRevisions - for (const [key, omission] of visibilityResumeOmissionsByKeyRef.current) { - const previousRevision = previousOwnerRevisions.get(omission.environmentId) - if ( - !mirroredEnvironmentOwnerRevisions.has(omission.environmentId) || - (previousOwnerRevisions.has(omission.environmentId) && - mirroredEnvironmentOwnerRevisions.get(omission.environmentId) !== previousRevision) - ) { - visibilityResumeOmissionsByKeyRef.current.delete(key) - } - } - // Why: mirror all paired runtimes' sessions, not just the selected worktree, so background worktrees don't look asleep (selectedness isn't liveness). - // Why: applying the host snapshot before startup hydration writes browser-local session state clobbers it and leaves the sidebar stale. - if (!workspaceSessionReady || environments.length === 0) { - return - } - - type VisibilityResumeEnvironment = { - trackedWorktrees: readonly TrackedWebSessionTabsWorktree[] - inventoryReceived: boolean - latestInventoryReceivedFrame: number - pendingMissingWorktrees: Set - expectedEnvironmentConnectionGeneration: number - expectedEnvironmentPairingRevision?: number - expectedTrackingGeneration: number - } - type VisibilityResumeMissing = { - environmentId: string - inventoryReceivedFrame: number - trackedWorktree: TrackedWebSessionTabsWorktree - snapshot: RuntimeMobileSessionTabsRemovedResult - runtimeId?: string - } - type VisibilityResumeBatch = { - visibilityGeneration: number - environments: Map - pendingInventoryCount: number - pendingMissingByWorktree: Map> - deferredRepairWorktrees: Set - trackedWorktreeIds: ReadonlySet - reapplyableSnapshotsByKey: Map< - string, - { snapshot: RuntimeMobileSessionTabsResult; receivedFrame: number; runtimeId?: string } - > - } - - const subscriptionSpecs: WindowVisibilitySubscriptionSpec[] = [] - const environmentIdBySubscriptionSpec: string[] = [] - let visibilityResumeBatch: VisibilityResumeBatch | null = null - const visibilityResumeOmissionsByKey = visibilityResumeOmissionsByKeyRef.current - - const recordVisibilityResumeSnapshotReceipt = ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string - ): void => { - if ( - runtimeId && - (isRetiredSessionTabsRuntimeId(environmentId, runtimeId) || - !isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) - ) { - return - } - const omission = visibilityResumeOmissionsByKey.get( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) - if ( - omission && - receivedFrame > omission.inventoryReceivedFrame && - ((snapshot as { removed?: unknown }).removed === true || - advancesSessionTabsFreshness(snapshot, omission.baseline)) - ) { - omission.superseded = true - if (visibilityResumeBatch?.pendingMissingByWorktree.has(snapshot.worktree)) { - reconcileVisibilityResumeWorktrees([snapshot.worktree]) - } - } - } - - const shouldApplyVisibilityResumeSnapshot = ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string - ): boolean => { - if ( - runtimeId && - (isRetiredSessionTabsRuntimeId(environmentId, runtimeId) || - !isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) - ) { - return false - } - const omission = visibilityResumeOmissionsByKey.get( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) - if (!omission) { - return true - } - if (receivedFrame < omission.inventoryReceivedFrame) { - return false - } - return ( - (snapshot as { removed?: unknown }).removed === true || - advancesSessionTabsFreshness(snapshot, omission.baseline) - ) - } - recordVisibilityResumeSnapshotReceiptRef.current = recordVisibilityResumeSnapshotReceipt - shouldApplyVisibilityResumeSnapshotRef.current = shouldApplyVisibilityResumeSnapshot - - const isVisibilityResumeMissingCurrent = (missing: VisibilityResumeMissing): boolean => { - const omission = visibilityResumeOmissionsByKey.get( - sessionTabsFreshnessKey(missing.environmentId, missing.snapshot.worktree) - ) - return ( - omission?.inventoryReceivedFrame === missing.inventoryReceivedFrame && !omission.superseded - ) - } - - const getVisibilityResumeSnapshotEntry = ( - batch: VisibilityResumeBatch, - environmentId: string, - worktreeId: string - ): { - snapshot: RuntimeMobileSessionTabsResult - receivedFrame: number - runtimeId?: string - } | null => { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const entry = batch.reapplyableSnapshotsByKey.get(key) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - if ( - !entry || - freshness?.publicationEpoch !== entry.snapshot.publicationEpoch || - freshness.snapshotVersion !== entry.snapshot.snapshotVersion || - !shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - entry.snapshot, - entry.receivedFrame, - entry.runtimeId - ) - ) { - return null - } - return entry - } - - const getVisibilityResumeSnapshot = ( - batch: VisibilityResumeBatch, - environmentId: string, - worktreeId: string - ): RuntimeMobileSessionTabsResult | null => { - return getVisibilityResumeSnapshotEntry(batch, environmentId, worktreeId)?.snapshot ?? null - } - - const finishVisibilityResumeBatchIfIdle = (batch: VisibilityResumeBatch): void => { - if ( - batch.pendingInventoryCount === 0 && - batch.pendingMissingByWorktree.size === 0 && - visibilityResumeBatch === batch - ) { - visibilityResumeBatch = null - } - } - - // Why: omission tombstones touch shared terminal ids, so apply them only before replaying every surviving host. - function reconcileVisibilityResumeWorktrees(worktreeIds: Iterable): void { - const batch = visibilityResumeBatch - if (!batch) { - return - } - const operations: WebSessionTabsSnapshotOperation[] = [] - for (const worktreeId of new Set(worktreeIds)) { - const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) - if (!pendingMissing) { - batch.deferredRepairWorktrees.delete(worktreeId) - continue - } - for (const [environmentId, missing] of pendingMissing) { - if (isVisibilityResumeMissingCurrent(missing)) { - continue - } - pendingMissing.delete(environmentId) - batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) - } - if (pendingMissing.size === 0) { - batch.pendingMissingByWorktree.delete(worktreeId) - batch.deferredRepairWorktrees.delete(worktreeId) - continue - } - const missingEnvironmentIds = new Set(pendingMissing.keys()) - const survivingSnapshots: { - environmentId: string - snapshot: RuntimeMobileSessionTabsResult - runtimeId?: string - }[] = [] - let canRepairSharedState = true - for (const environmentId of sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) { - if (missingEnvironmentIds.has(environmentId)) { - continue - } - const entry = getVisibilityResumeSnapshotEntry(batch, environmentId, worktreeId) - if (!entry) { - canRepairSharedState = false - break - } - survivingSnapshots.push({ - environmentId, - snapshot: entry.snapshot, - ...(entry.runtimeId ? { runtimeId: entry.runtimeId } : {}) - }) - } - if (!canRepairSharedState) { - batch.deferredRepairWorktrees.add(worktreeId) - continue - } - for (const missing of pendingMissing.values()) { - operations.push({ - environmentId: missing.environmentId, - snapshot: missing.snapshot, - ...(missing.runtimeId ? { runtimeId: missing.runtimeId } : {}) - }) - } - for (const { environmentId, snapshot, runtimeId } of survivingSnapshots) { - acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) - operations.push({ environmentId, snapshot, ...(runtimeId ? { runtimeId } : {}) }) - } - for (const environmentId of pendingMissing.keys()) { - batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) - } - batch.pendingMissingByWorktree.delete(worktreeId) - batch.deferredRepairWorktrees.delete(worktreeId) - } - if (operations.length > 0) { - const decided = decideWebSessionTabsSnapshotOperations(operations) - const settleMirror = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshotOperations(state, decided), - { - frames: decided.map(({ environmentId, snapshot, decision }) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision, - expectedEnvironmentConnectionGeneration: - batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision: - batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, - expectedTrackingGeneration: - batch.environments.get(environmentId)?.expectedTrackingGeneration - })) - }, - operations.map(({ snapshot }) => snapshot) - ) - // Why: every operation is post-recovery or inventory-absence evidence - // with no finishRecovery of its own pending, so settle now — a deferred - // tombstone otherwise leaves its worktree parked on a healthy host. - settleMirror() - } - finishVisibilityResumeBatchIfIdle(batch) - } - - const recordVisibilityResumeSnapshot = ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number, - runtimeId?: string - ): void => { - if ( - runtimeId && - (isRetiredSessionTabsRuntimeId(environmentId, runtimeId) || - !isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) - ) { - return - } - const batch = visibilityResumeBatch - if (!batch || !batch.trackedWorktreeIds.has(snapshot.worktree)) { - return - } - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const existingIsCurrent = getVisibilityResumeSnapshot(batch, environmentId, snapshot.worktree) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - const repairsCrossHostCollision = - (sessionTabsEnvironmentsByWorktree.get(snapshot.worktree)?.size ?? 0) > 1 || - batch.deferredRepairWorktrees.has(snapshot.worktree) - if ( - (snapshot as { removed?: unknown }).removed === true || - snapshot.tabs.length === 0 || - !repairsCrossHostCollision || - freshness?.publicationEpoch !== snapshot.publicationEpoch || - freshness.snapshotVersion !== snapshot.snapshotVersion || - !shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - snapshot, - receivedFrame, - runtimeId - ) - ) { - if (!existingIsCurrent) { - batch.reapplyableSnapshotsByKey.delete(key) - } - } else { - batch.reapplyableSnapshotsByKey.set(key, { snapshot, receivedFrame, runtimeId }) - } - if (batch.pendingMissingByWorktree.has(snapshot.worktree)) { - reconcileVisibilityResumeWorktrees([snapshot.worktree]) - } - } - - const recordVisibilityResumeInventory = ( - environmentId: string, - visibilityGeneration: number, - inventoryReceivedFrame: number, - missingWorktrees: readonly VisibilityResumeMissing[] - ): void => { - if ( - visibilityGeneration === 0 || - visibilityResumeBatch?.visibilityGeneration !== visibilityGeneration - ) { - return - } - const environment = visibilityResumeBatch.environments.get(environmentId) - if (!environment || environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { - return - } - const batch = visibilityResumeBatch - const affectedWorktrees = new Set(environment.pendingMissingWorktrees) - for (const worktreeId of environment.pendingMissingWorktrees) { - const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) - pendingMissing?.delete(environmentId) - if (pendingMissing?.size === 0) { - batch.pendingMissingByWorktree.delete(worktreeId) - } - } - environment.pendingMissingWorktrees.clear() - for (const missing of missingWorktrees) { - const worktreeId = missing.snapshot.worktree - const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) ?? new Map() - pendingMissing.set(environmentId, missing) - batch.pendingMissingByWorktree.set(worktreeId, pendingMissing) - environment.pendingMissingWorktrees.add(worktreeId) - affectedWorktrees.add(worktreeId) - } - if (!environment.inventoryReceived) { - environment.inventoryReceived = true - batch.pendingInventoryCount -= 1 - } - reconcileVisibilityResumeWorktrees(affectedWorktrees) - } - recordVisibilityResumeSnapshotRef.current = recordVisibilityResumeSnapshot - - const recordVisibilityResumeInventoryReceipt = ( - environmentId: string, - visibilityGeneration: number, - inventoryReceivedFrame: number, - snapshots: readonly RuntimeMobileSessionTabsResult[], - runtimeId?: string - ): VisibilityResumeMissing[] => { - if (!isCurrentSessionTabsRuntimeFrame(environmentId, runtimeId)) { - return [] - } - for (const snapshot of snapshots) { - visibilityResumeOmissionsByKey.delete( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) - } - if (visibilityResumeBatch?.visibilityGeneration !== visibilityGeneration) { - return [] - } - const environment = visibilityResumeBatch.environments.get(environmentId) - if (!environment) { - return [] - } - environment.latestInventoryReceivedFrame = Math.max( - environment.latestInventoryReceivedFrame, - inventoryReceivedFrame - ) - if (environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { - return [] - } - const publishedWorktrees = new Set(snapshots.map((snapshot) => snapshot.worktree)) - return buildMissingWebSessionTabsRemovals( - environmentId, - environment.trackedWorktrees, - publishedWorktrees - ).map((missing) => { - const key = sessionTabsFreshnessKey(environmentId, missing.snapshot.worktree) - visibilityResumeOmissionsByKey.set(key, { - baseline: missing.trackedWorktree.freshness, - environmentId, - inventoryReceivedFrame, - superseded: false, - visibilityGeneration - }) - recordReceivedWebSessionTabsRemoval( - environmentId, - missing.snapshot.worktree, - inventoryReceivedFrame - ) - return { - environmentId, - inventoryReceivedFrame, - ...(runtimeId ? { runtimeId } : {}), - ...missing - } - }) - } - - const beginVisibilityResume = ({ - visibilityGeneration, - restartingSpecIndexes - }: { - visibilityGeneration: number - restartingSpecIndexes: readonly number[] - }): void => { - const activeRuntimeWorktreeKey = activeRuntimeWorktreeKeyRef.current - for (const [key, omission] of visibilityResumeOmissionsByKey) { - // Why: the active worktree's scoped stream resumes before its host inventory, so its fence outlives exactly one resume - never more. - if ( - key !== activeRuntimeWorktreeKey || - omission.visibilityGeneration < visibilityGeneration - 1 - ) { - visibilityResumeOmissionsByKey.delete(key) - } - } - const resumedEnvironments = new Map() - const trackedWorktreeIds = new Set() - for (const index of restartingSpecIndexes) { - const environmentId = environmentIdBySubscriptionSpec[index] - if (environmentId) { - const trackedWorktrees = getTrackedWebSessionTabsWorktrees(environmentId) - if (trackedWorktrees.length === 0) { - continue - } - for (const { worktree } of trackedWorktrees) { - trackedWorktreeIds.add(worktree) - } - resumedEnvironments.set(environmentId, { - trackedWorktrees, - inventoryReceived: false, - latestInventoryReceivedFrame: 0, - pendingMissingWorktrees: new Set(), - expectedEnvironmentConnectionGeneration: - environments.find((environment) => environment.environmentId === environmentId) - ?.expectedEnvironmentConnectionGeneration ?? - getRuntimeEnvironmentConnectionGeneration(environmentId), - expectedEnvironmentPairingRevision: environments.find( - (environment) => environment.environmentId === environmentId - )?.expectedEnvironmentPairingRevision, - expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) - }) - } - } - visibilityResumeBatch = - resumedEnvironments.size > 0 - ? { - visibilityGeneration, - environments: resumedEnvironments, - pendingInventoryCount: resumedEnvironments.size, - pendingMissingByWorktree: new Map(), - deferredRepairWorktrees: new Set(), - trackedWorktreeIds, - reapplyableSnapshotsByKey: new Map() - } - : null - } - - // Why: the stream's initial snapshot can land after first render, so a one-shot fetch makes initial parity deterministic. - for (const { - environmentId, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision - } of environments) { - if ( - !shouldSyncAllRuntimeSessionTabs({ - activeRuntimeEnvironmentId: environmentId, - workspaceSessionReady - }) - ) { - continue - } - let requestedInitialLoad = false - const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) - environmentIdBySubscriptionSpec.push(environmentId) - subscriptionSpecs.push({ - subscribe: (isCurrent, { visibilityGeneration }) => { - const isVisibilityRestart = visibilityGeneration > 0 - let awaitingVisibilityResumeInventory = isVisibilityRestart - if (!requestedInitialLoad) { - requestedInitialLoad = true - loadInitialWebSessionTabs( - environmentId, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - isCurrent - ) - } - return window.api.runtimeEnvironments.subscribe( - { - selector: environmentId, - method: 'session.tabs.subscribeAll', - params: {}, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== - expectedEnvironmentPairingRevision - ) { - return - } - if (response.ok === false) { - console.warn( - '[web-session-tabs-sync] global subscription failed:', - response.error.message - ) - return - } - const runtimeId = getSessionTabsRuntimeIdFromResponse(response) - if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId)) { - return - } - const event = response.result as SessionTabsStreamEvent - const replayed = isRuntimeSubscriptionReplayResponse(response) - if (event.type === 'snapshots') { - const skipUnchangedResumeWork = awaitingVisibilityResumeInventory && !replayed - awaitingVisibilityResumeInventory = false - // Why: an unchanged epoch/version proves the host published nothing while parked, so there is no missed frame to recover or replay. - const unchangedVisibilityResumeSnapshots = event.snapshots.map((snapshot) => { - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - return Boolean( - skipUnchangedResumeWork && - // Why: an armed replay is an outstanding repair request, so that worktree must be rebuilt even when the host looks unchanged. - !replayableSessionTabsSnapshotByWorktree.has(key) && - freshness?.publicationEpoch === snapshot.publicationEpoch && - freshness.snapshotVersion === snapshot.snapshotVersion - ) - }) - const receivedFrames = event.snapshots.map((snapshot) => { - const receivedFrame = recordReceivedWebSessionTabsSnapshot( - environmentId, - snapshot, - undefined, - runtimeId - ) - recordVisibilityResumeSnapshotReceipt( - environmentId, - snapshot, - receivedFrame, - runtimeId - ) - return receivedFrame - }) - const inventoryReceivedFrame = - recordReceivedWebSessionTabsInventory(environmentId) - const missingWorktrees = recordVisibilityResumeInventoryReceipt( - environmentId, - visibilityGeneration, - inventoryReceivedFrame, - event.snapshots, - runtimeId - ) - const finishRecoveries = event.snapshots.map((snapshot, index) => - unchangedVisibilityResumeSnapshots[index] - ? null - : beginWebSessionTabsSnapshotRecovery( - environmentId, - snapshot.worktree, - receivedFrames[index]! - ) - ) - let settleHydration: (() => void) | null = null - void Promise.all( - event.snapshots.map((snapshot, index) => - unchangedVisibilityResumeSnapshots[index] - ? Promise.resolve(snapshot) - : recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId, - { - expectedEnvironmentPairingRevision, - getCurrentState: () => useAppStore.getState() - } - ) - ) - ) - .then((recovered) => { - if (isCurrent()) { - const applicable = recovered.flatMap((snapshot, index) => - snapshot !== null && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - snapshot, - receivedFrames[index]!, - runtimeId - ) && - shouldApplyVisibilityResumeSnapshot( - environmentId, - snapshot, - receivedFrames[index]!, - runtimeId - ) - ? [{ index, snapshot }] - : [] - ) - if (isVisibilityRestart || replayed) { - for (const { index, snapshot } of applicable) { - if (unchangedVisibilityResumeSnapshots[index]) { - continue - } - acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) - } - } - // Why: an unchanged resume snapshot is never re-decided - // — the store already holds the view it would rewrite. - const decisions = applicable.map(({ index, snapshot }) => - unchangedVisibilityResumeSnapshots[index] - ? WEB_SESSION_TABS_FRAME_OUTRANKED - : decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId) - ) - const freshSnapshots = applicable.flatMap(({ snapshot }, position) => - decisions[position]!.apply ? [snapshot] : [] - ) - settleHydration = applyWebSessionTabsStorePatch( - (state) => - applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), - { - frames: applicable.map(({ snapshot }, position) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision: decisions[position]!, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - })), - fullInventory: { - environmentId, - authoritative: event.authoritative === true, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - // Why: a workspace the mirror never writes is not - // part of the inventory this verdict accounts for. - publishedSnapshotCount: event.snapshots.filter((snapshot) => - isHostMirroredWorktree(snapshot.worktree) - ).length - } - }, - freshSnapshots - ) - const freshSnapshotSet = new Set(freshSnapshots) - for (const { index, snapshot } of applicable) { - if (unchangedVisibilityResumeSnapshots[index]) { - queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - } - if ( - unchangedVisibilityResumeSnapshots[index] || - freshSnapshotSet.has(snapshot) - ) { - recordVisibilityResumeSnapshot( - environmentId, - snapshot, - receivedFrames[index]!, - runtimeId - ) - } - } - recordVisibilityResumeInventory( - environmentId, - visibilityGeneration, - inventoryReceivedFrame, - missingWorktrees - ) - } - }) - .catch((error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) - } - }) - .finally(() => { - for (const finishRecovery of finishRecoveries) { - finishRecovery?.() - } - // Why: an inventory speaks only once its patch is in the - // store — settling earlier, or on snapshots recovery - // discarded, drains parked work against state nobody wrote. - if (isCurrent()) { - settleHydration?.() - } - }) - return - } - if (event.type !== 'snapshot' && event.type !== 'updated') { - // Why: silence carries no frame, and a stream that stopped - // talking has not reported a single PTY dead. - return - } - const receivedFrame = recordReceivedWebSessionTabsSnapshot( - environmentId, - event, - undefined, - runtimeId - ) - recordVisibilityResumeSnapshotReceipt( - environmentId, - event, - receivedFrame, - runtimeId - ) - const finishRecovery = beginWebSessionTabsSnapshotRecovery( - environmentId, - event.worktree, - receivedFrame - ) - let settleHydration: HostSessionMirrorSettle | null = null - void recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - event, - environmentId, - { - expectedEnvironmentPairingRevision, - getCurrentState: () => useAppStore.getState() - } - ) - .then((recovered) => { - if ( - isCurrent() && - recovered && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - recovered, - receivedFrame, - runtimeId - ) && - shouldApplyVisibilityResumeSnapshot( - environmentId, - recovered, - receivedFrame, - runtimeId - ) - ) { - if (replayed) { - acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) - } - const decision = decideWebSessionTabsSnapshot( - recovered, - environmentId, - runtimeId - ) - if (decision.apply) { - settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { - frames: [ - { - environmentId, - worktreeId: recovered.worktree, - decision, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - } - ] - }, - recovered, - event.type === 'updated' && !replayed - ) - recordVisibilityResumeSnapshot( - environmentId, - recovered, - receivedFrame, - runtimeId - ) - } else { - settleHydration = hostSessionMirrorSettleForPatchlessFrame( - decision, - environmentId, - recovered.worktree, - { - connectionGeneration: expectedEnvironmentConnectionGeneration, - pairingRevision: expectedEnvironmentPairingRevision, - trackingGeneration: expectedTrackingGeneration - } - ) - } - } - }) - .catch((error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) - } - }) - .finally(() => { - finishRecovery() - // Why: this frame speaks for its own worktree only, and only - // through the receipt of what actually landed — a discarded - // frame leaves the pane's PTY as unaccounted as before. - if (isCurrent()) { - settleHydration?.() - } - }) - }, - onError: (error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] global subscription error:', error.message) - } - } - } - ) - }, - onSubscribeError: (error) => { - console.warn( - '[web-session-tabs-sync] failed to subscribe globally:', - error instanceof Error ? error.message : String(error) - ) - }, - onUnsubscribeError: (error) => { - console.warn('[web-session-tabs-sync] failed to unsubscribe globally:', error) - } - }) - } - // Why: pace full-host inventories while the scoped active-worktree mirror resumes immediately. - const disposeSubscriptions = installWindowVisibilitySubscriptionParking(subscriptionSpecs, { - getVisibilityResumePriority: (index) => - environmentIdBySubscriptionSpec[index] === activeRuntimeEnvironmentIdRef.current ? 0 : 1, - visibilityResumeStaggerMs: WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS, - onVisibilityResume: beginVisibilityResume - }) - - return () => { - recordVisibilityResumeSnapshotRef.current = () => {} - recordVisibilityResumeSnapshotReceiptRef.current = () => {} - shouldApplyVisibilityResumeSnapshotRef.current = () => true - disposeSubscriptions() - for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { - const owner = { - environmentId, - pairingRevision: expectedEnvironmentPairingRevision - } - clearWebSessionCloseIntentsForOwner(owner) - clearWebSessionFocusIntentsForOwner(owner) - clearWebSessionReorderIntentsForOwner(owner) - } - } - }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) - - useEffect(() => { - const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() - const expectedEnvironmentPairingRevision = activeWorktreeRuntimePairingRevision - if ( - !shouldSyncRuntimeSessionTabs({ - activeWorktreeId, - activeWorktreeRuntimeEnvironmentId, - workspaceSessionReady - }) || - !environmentId || - !activeWorktreeId - ) { - return - } - const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) - - let requestedInitialTerminal = false - let requestedRespawnAfterWake = false - /** Resolves the settle receipt of the evidence this frame put (or already - * had) in the store, or null when the frame was discarded undecided. */ - const applyActiveSnapshot = async ( - event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, - response: RuntimeRpcResponse, - isCurrent: () => boolean, - receivedFrame: number, - expectedTrackingGeneration: number, - runtimeId?: string - ): Promise => { - const recovered = await recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - event, - environmentId, - { - expectedEnvironmentPairingRevision, - getCurrentState: () => useAppStore.getState() - } - ) - if ( - !isCurrent() || - !recovered || - !shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - recovered, - receivedFrame, - runtimeId - ) || - !shouldApplyVisibilityResumeSnapshotRef.current( - environmentId, - recovered, - receivedFrame, - runtimeId - ) - ) { - return null - } - if (event.type === 'snapshot' || isRuntimeSubscriptionReplayResponse(response)) { - // Why: the parallel global stream can consume an earlier replay allowance before this authoritative snapshot lands. - acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) - } - const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type } - const decision = decideWebSessionTabsSnapshot(recovered, environmentId, runtimeId) - const fresh = decision.apply - const syncState = useAppStore.getState() - const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] - const localTerminalCount = localWorktreeTabs.length - const hasLiveLocalPty = localWorktreeTabs.some( - (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 - ) - const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({ - event: recoveredEvent, - activeWorktreeId, - requestedInitialTerminal, - snapshotIsFresh: fresh, - localTerminalCount - }) - const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({ - event: recoveredEvent, - activeWorktreeId, - requestedRespawnAfterWake, - snapshotIsFresh: fresh, - localTerminalCount, - hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) - }) - // Why: a rejected frame settles only on the accepted view that outranked it. - let settleMirror: HostSessionMirrorSettle | null = fresh - ? null - : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { - connectionGeneration: activeWorktreeRuntimeConnectionGeneration, - pairingRevision: expectedEnvironmentPairingRevision, - trackingGeneration: expectedTrackingGeneration - }) - try { - if (fresh) { - const replayed = isRuntimeSubscriptionReplayResponse(response) - settleMirror = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { - frames: [ - { - environmentId, - worktreeId: recovered.worktree, - decision, - expectedEnvironmentConnectionGeneration: - activeWorktreeRuntimeConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - } - ] - }, - recovered, - event.type === 'updated' && !replayed - ) - recordVisibilityResumeSnapshotRef.current( - environmentId, - recovered, - receivedFrame, - runtimeId - ) - } - if (isCurrent() && shouldBootstrapInitialTerminal) { - requestedInitialTerminal = true - await createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId, - activate: true - }) - } else if ( - isCurrent() && - shouldRespawnAfterWake && - beginWebRuntimeWakeTerminalRespawn(activeWorktreeId) - ) { - requestedRespawnAfterWake = true - await createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId, - activate: true, - selectWorktree: false - }).finally(() => endWebRuntimeWakeTerminalRespawn(activeWorktreeId)) - } - } catch (error) { - // Why: the spawn is a side effect of the frame, not part of it — failing - // after the patch landed cannot un-land it, and the host is still healthy. - if (isCurrent()) { - console.warn('[web-session-tabs-sync] snapshot follow-up failed:', error) - } - } - return settleMirror - } - const disposeSubscription = installWindowVisibilitySubscriptionParking([ - { - subscribe: (isCurrent) => - window.api.runtimeEnvironments.subscribe( - { - selector: environmentId, - method: 'session.tabs.subscribe', - params: { worktree: toRuntimeWorktreeSelector(activeWorktreeId) }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== - expectedEnvironmentPairingRevision - ) { - return - } - if (response.ok === false) { - console.warn( - '[web-session-tabs-sync] subscription failed:', - response.error.message - ) - return - } - const event = response.result as SessionTabsStreamEvent - if (event.type !== 'snapshot' && event.type !== 'updated') { - // No frame to apply, and a dead stream is not a verdict about - // this worktree — let alone about the whole environment. - return - } - const runtimeId = getSessionTabsRuntimeIdFromResponse(response) - if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId)) { - return - } - const receivedFrame = recordReceivedWebSessionTabsSnapshot( - environmentId, - event, - undefined, - runtimeId - ) - recordVisibilityResumeSnapshotReceiptRef.current( - environmentId, - event, - receivedFrame, - runtimeId - ) - const finishRecovery = beginWebSessionTabsSnapshotRecovery( - environmentId, - event.worktree, - receivedFrame - ) - void applyActiveSnapshot( - event, - response, - isCurrent, - receivedFrame, - expectedTrackingGeneration, - runtimeId - ) - .catch((error) => { - if (isCurrent()) { - console.warn( - '[web-session-tabs-sync] active snapshot recovery failed:', - error - ) - } - return null - }) - .then((settleMirror) => { - finishRecovery() - // Why: the active-worktree mirror is authoritative for this - // worktree alone, and only through its landed receipt. - if (isCurrent()) { - settleMirror?.() - } - }) - }, - onError: (error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] subscription error:', error.message) - } - } - } - ), - onSubscribeError: (error) => { - console.warn( - '[web-session-tabs-sync] failed to subscribe:', - error instanceof Error ? error.message : String(error) - ) - }, - onUnsubscribeError: (error) => { - console.warn('[web-session-tabs-sync] failed to unsubscribe:', error) - } - } - ]) - - return disposeSubscription - }, [ - activeWorktreeId, - activeWorktreeRuntimeEnvironmentId, - activeWorktreeRuntimeConnectionGeneration, - activeWorktreeRuntimeId, - activeWorktreeRuntimePairingRevision, - workspaceSessionReady - ]) -} +/** Public boundary for the web runtime session-tab mirror. */ +export { + applyFreshWebSessionTabsSnapshot, + applyFreshWebSessionTabsSnapshots, + applyWebSessionTabsSnapshot, + applyWebSessionTabsSnapshots, + applyWebSessionTabsSnapshotOperations, + decideWebSessionTabsSnapshotOperations +} from './web-session-tabs-sync/snapshot-api' +export type { + DecidedWebSessionTabsSnapshotOperation, + WebSessionTabsSnapshotOperation +} from './web-session-tabs-sync/snapshot-api' + +export { + acceptReplayedWebSessionTabsSnapshot, + clearWebSessionTabsTrackingForEnvironment, + getLastKnownHostTerminalTabCount, + getLatestWebSessionTabsPublicationEpoch, + getWebSessionTabsTrackingGeneration, + resetWebSessionTabsSnapshotFreshnessForTests, + _getWebSessionTabsRecoveryTrackingCountsForTest, + _getWebSessionTabsTrackingCountsForTest +} from './web-session-tabs-sync/tracking-lifecycle' +export { resolveHostSessionTabIdForWebSessionTab } from './web-session-tabs-sync/tracking-mappings' +export { + decideWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot, + shouldBootstrapInitialWebRuntimeTerminal, + shouldRespawnWebRuntimeTerminalAfterWake, + shouldSyncAllRuntimeSessionTabs, + shouldSyncRuntimeSessionTabs, + WEB_SESSION_TABS_FRAME_OUTRANKED +} from './web-session-tabs-sync/tracking-decisions' +export type { WebSessionTabsSnapshotDecision } from './web-session-tabs-sync/tracking-decisions' + +export { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' +export type { + HostSessionMirrorPatchFrame, + HostSessionMirrorPatchVerdict, + HostSessionMirrorSettle +} from './web-session-tabs-sync/mirror-settle' +export { + hostSessionMirrorSettleForPatchlessFrame, + createHostSessionMirrorSettle +} from './web-session-tabs-sync/mirror-settle' + +export { WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync/state' +export type { + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState, + SessionTabsStreamEvent +} from './web-session-tabs-sync/state' + +export { useWebSessionTabsSync } from './web-session-tabs-sync/use-web-session-tabs-sync' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts new file mode 100644 index 00000000000..8c7850b2885 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -0,0 +1,288 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay' +import { useAppStore } from '../../store' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { installWindowVisibilitySubscriptionParking } from '../window-visibility-subscription-parking' +import { + beginWebSessionTabsSnapshotRecovery, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { + decideWebSessionTabsSnapshot, + shouldBootstrapInitialWebRuntimeTerminal, + shouldRespawnWebRuntimeTerminalAfterWake, + shouldSyncRuntimeSessionTabs +} from './tracking-decisions' +import { + acceptReplayedWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration +} from './tracking-lifecycle' +import { + acceptSessionTabsRuntimeId, + getSessionTabsRuntimeIdFromResponse +} from './publisher-identity-fences' +import { applyWebSessionTabsSnapshot } from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { + hostSessionMirrorSettleForPatchlessFrame, + type HostSessionMirrorSettle +} from './mirror-settle' +import { + beginWebRuntimeWakeTerminalRespawn, + endWebRuntimeWakeTerminalRespawn, + shouldSkipWebRuntimeWakeTerminalRespawn +} from '../web-runtime-wake-terminal-respawn' +import { createWebRuntimeSessionTerminal } from '../web-runtime-session' +import { toRuntimeWorktreeSelector } from '../runtime-worktree-selector' +import type { SessionTabsStreamEvent } from './state' + +type Ref = { current: T } + +export type ActiveSubscriptionArgs = { + activeWorktreeId: string | null + activeWorktreeRuntimeEnvironmentId: string | null | undefined + activeWorktreeRuntimeConnectionGeneration: number + activeWorktreeRuntimePairingRevision: number | undefined + workspaceSessionReady: boolean + visibilitySnapshotReceipt: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ) => void + > + visibilitySnapshotApply: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ) => boolean + > + visibilitySnapshotRecord: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ) => void + > +} + +/** Install the selected-worktree stream, which resumes immediately on visibility changes. */ +export function installActiveSessionTabsSubscription({ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimePairingRevision, + workspaceSessionReady, + visibilitySnapshotReceipt, + visibilitySnapshotApply, + visibilitySnapshotRecord +}: ActiveSubscriptionArgs): (() => void) | undefined { + const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() + if ( + !shouldSyncRuntimeSessionTabs({ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + workspaceSessionReady + }) || + !environmentId || + !activeWorktreeId + ) { + return undefined + } + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) + let requestedInitialTerminal = false + let requestedRespawnAfterWake = false + + const applyActiveSnapshot = async ( + event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, + response: RuntimeRpcResponse, + isCurrent: () => boolean, + receivedFrame: number, + runtimeId?: string + ): Promise => { + const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + event, + environmentId, + { + expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision, + getCurrentState: () => useAppStore.getState() + } + ) + if ( + !isCurrent() || + !recovered || + !shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + recovered, + receivedFrame, + runtimeId + ) || + !visibilitySnapshotApply.current(environmentId, recovered, receivedFrame, runtimeId) + ) { + return null + } + if (event.type === 'snapshot' || isRuntimeSubscriptionReplayResponse(response)) { + acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) + } + const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type } + const decision = decideWebSessionTabsSnapshot(recovered, environmentId, runtimeId) + const syncState = useAppStore.getState() + const localTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] + const localTerminalCount = localTabs.length + const hasLiveLocalPty = localTabs.some( + (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 + ) + const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({ + event: recoveredEvent, + activeWorktreeId, + requestedInitialTerminal, + snapshotIsFresh: decision.apply, + localTerminalCount + }) + const respawn = shouldRespawnWebRuntimeTerminalAfterWake({ + event: recoveredEvent, + activeWorktreeId, + requestedRespawnAfterWake, + snapshotIsFresh: decision.apply, + localTerminalCount, + hasLiveLocalPty, + skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + }) + let settle: HostSessionMirrorSettle | null = decision.apply + ? null + : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { + connectionGeneration: activeWorktreeRuntimeConnectionGeneration, + pairingRevision: activeWorktreeRuntimePairingRevision, + trackingGeneration: expectedTrackingGeneration + }) + if (decision.apply) { + const replayed = isRuntimeSubscriptionReplayResponse(response) + settle = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration: activeWorktreeRuntimeConnectionGeneration, + expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision, + expectedTrackingGeneration + } + ] + }, + recovered, + event.type === 'updated' && !replayed + ) + visibilitySnapshotRecord.current(environmentId, recovered, receivedFrame, runtimeId) + } + try { + if (isCurrent() && bootstrap) { + requestedInitialTerminal = true + await createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true + }) + } else if (isCurrent() && respawn && beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)) { + requestedRespawnAfterWake = true + await createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true, + selectWorktree: false + }).finally(() => endWebRuntimeWakeTerminalRespawn(activeWorktreeId)) + } + } catch (error) { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] snapshot follow-up failed:', error) + } + } + return settle + } + + return installWindowVisibilitySubscriptionParking([ + { + subscribe: (isCurrent) => + window.api.runtimeEnvironments.subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribe', + params: { worktree: toRuntimeWorktreeSelector(activeWorktreeId) }, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision + }, + { + onResponse: (response) => { + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== + activeWorktreeRuntimePairingRevision + ) { + return + } + if (response.ok === false) { + console.warn('[web-session-tabs-sync] subscription failed:', response.error.message) + return + } + const event = response.result as SessionTabsStreamEvent + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + const runtimeId = getSessionTabsRuntimeIdFromResponse(response) + if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId)) { + return + } + const frame = recordReceivedWebSessionTabsSnapshot( + environmentId, + event, + undefined, + runtimeId + ) + visibilitySnapshotReceipt.current(environmentId, event, frame, runtimeId) + const finish = beginWebSessionTabsSnapshotRecovery( + environmentId, + event.worktree, + frame + ) + void applyActiveSnapshot(event, response, isCurrent, frame, runtimeId) + .catch((error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] active snapshot recovery failed:', error) + } + return null + }) + .then((settle) => { + finish() + if (isCurrent()) { + settle?.() + } + }) + }, + onError: (error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] subscription error:', error.message) + } + } + } + ), + onSubscribeError: (error) => { + console.warn( + '[web-session-tabs-sync] failed to subscribe:', + error instanceof Error ? error.message : String(error) + ) + }, + onUnsubscribeError: (error) => { + console.warn('[web-session-tabs-sync] failed to unsubscribe:', error) + } + } + ]) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts new file mode 100644 index 00000000000..ddc1b4b4d1f --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -0,0 +1,195 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner' +import { isWebTerminalSurfaceTabId, toWebTerminalSurfaceTabId } from '../web-runtime-session' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + MirroredTerminalTab, + TerminalSurface, + WebSessionTabsBatchContext, + WebSessionTabsSyncState +} from './state' +import { + isClientOwnedAgentStatus, + isFencedClientAgentStatus, + hostAgentStatusPiercesClientAuthority, + isMirroredAgentPaneKeyForTabs, + batchAgentPaneKeysForTabs, + remapHostAgentStatus, + updateBatchAgentPaneKey +} from './agent-status-primitives' +import { + agentStatusEntryEqual, + isAgentStatusFresh, + isMirroredCommandCodeTurnBump, + writableWebSessionTabsRecord +} from './state-equality-core' + +export function buildMirroredAgentStatusPatch( + state: WebSessionTabsSyncState, + currentTerminalTabs: readonly TerminalTab[], + terminalSurfaceTabs: readonly TerminalSurface[], + mirroredTerminalTabs: readonly MirroredTerminalTab[], + now: number, + batchContext?: WebSessionTabsBatchContext +): Pick | null { + const mirroredTabIds = new Set() + for (const tab of currentTerminalTabs) { + if (isWebTerminalSurfaceTabId(tab.id)) { + mirroredTabIds.add(tab.id) + } + } + for (const surface of terminalSurfaceTabs) { + mirroredTabIds.add(toWebTerminalSurfaceTabId(surface.parentTabId)) + } + + if (mirroredTabIds.size === 0) { + return null + } + + let retainedSurfaceByHostTabAndPrunedLeafId: + | Map> + | undefined + for (const entry of mirroredTerminalTabs) { + if (entry.retainedSurfaceByPrunedLeafId) { + retainedSurfaceByHostTabAndPrunedLeafId ??= new Map() + retainedSurfaceByHostTabAndPrunedLeafId.set( + entry.hostTabId, + entry.retainedSurfaceByPrunedLeafId + ) + } + } + const nextByPaneKey = new Map() + for (const surface of terminalSurfaceTabs) { + const retainedSurface = retainedSurfaceByHostTabAndPrunedLeafId + ?.get(surface.parentTabId) + ?.get(surface.leafId) + const entry = remapHostAgentStatus(surface, retainedSurface) + if (!entry) { + continue + } + const existing = nextByPaneKey.get(entry.paneKey) ?? state.agentStatusByPaneKey[entry.paneKey] + // Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot. + const hostIdentityPredatesCurrentTurn = + existing !== undefined && + entry.state === 'done' && + existing.state !== 'done' && + existing.stateStartedAt > entry.stateStartedAt + // Why: cross-machine wall clocks are not comparable, so the host frame could + // outrank live client status forever; a proven client writer keeps its own + // state (still adopting the host's identity fields below) unless the host + // carries a state class the client's bytes can never see. + const clientOwnsEntry = + isFencedClientAgentStatus(entry.paneKey, existing, now) && + !hostAgentStatusPiercesClientAuthority(entry) + const nextEntry = + existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt) + ? { + ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), + ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' + ? { workingMode: entry.workingMode } + : {}), + paneKey: entry.paneKey, + worktreeId: entry.worktreeId ?? existing.worktreeId, + tabId: entry.tabId, + providerSession: + existing.providerSession ?? + (hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession), + // Why: hook-only content the byte pipeline can never see, and every OSC + // write blanks it, so a fenced pane's message line stayed empty forever + // (#12906). Host-first unlike providerSession: only the host can mint one. + lastAssistantMessage: + (hostIdentityPredatesCurrentTurn ? undefined : entry.lastAssistantMessage) ?? + existing.lastAssistantMessage, + lastAssistantMessageIsToolOutput: + hostIdentityPredatesCurrentTurn || entry.lastAssistantMessage === undefined + ? existing.lastAssistantMessageIsToolOutput + : entry.lastAssistantMessageIsToolOutput + } + : entry + nextByPaneKey.set(entry.paneKey, nextEntry) + } + + let nextAgentStatusByPaneKey = state.agentStatusByPaneKey + let changed = false + let aggregateRelevantChange = false + let sortRelevantChange = false + + for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { + if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { + continue + } + if (nextByPaneKey.has(paneKey)) { + continue + } + // Why: the host surface carrying no status is not proof the agent stopped — + // hook-only hosts publish nothing for OSC-driven panes. Keep a live entry + // this renderer owns; it decays through the normal freshness boundary. + // Ownership, not freshness, is the gate here: with no competing host value + // there is nothing to arbitrate, and a client asleep past the stale + // boundary would otherwise erase every pane it owns on the first snapshot + // after wake (STA-3107) instead of decaying it like a local pane. + if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) { + continue + } + if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) + } + delete nextAgentStatusByPaneKey[paneKey] + updateBatchAgentPaneKey(paneKey, false, batchContext) + changed = true + aggregateRelevantChange = true + sortRelevantChange = true + } + + for (const [paneKey, entry] of nextByPaneKey) { + const existing = nextAgentStatusByPaneKey[paneKey] + if (agentStatusEntryEqual(existing, entry)) { + continue + } + if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) + } + nextAgentStatusByPaneKey[paneKey] = entry + updateBatchAgentPaneKey(paneKey, true, batchContext) + changed = true + const entryAttributionChanged = + existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId + const entryFreshnessChanged = + !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) + const doneAttentionChanged = + existing?.state === 'done' && + entry.state === 'done' && + agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) + const workingModeChanged = existing?.workingMode !== entry.workingMode + const entrySortRelevantChange = + !existing || + existing.state !== entry.state || + !isAgentStatusFresh(existing, now) || + entryFreshnessChanged || + entryAttributionChanged || + doneAttentionChanged || + isMirroredCommandCodeTurnBump(existing, entry) + aggregateRelevantChange = + aggregateRelevantChange || entrySortRelevantChange || workingModeChanged + sortRelevantChange = sortRelevantChange || entrySortRelevantChange + } + + if (!changed) { + return null + } + + return { + agentStatusByPaneKey: nextAgentStatusByPaneKey, + agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch, + sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts new file mode 100644 index 00000000000..30cb1ac7632 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -0,0 +1,198 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey, makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' +import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner' +import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import type { TerminalSurface, WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' +import type { RetiredTerminalTabSweepState } from '../../store/slices/retired-terminal-tab-state-sweep' +import { buildRetiredTerminalTabStateSweepPatch } from '../../store/slices/retired-terminal-tab-state-sweep' +import { isMirroredTerminalSurfaceId } from './terminal-surfaces' +import { isAgentStatusFresh } from './state-equality-core' + +export function toMirroredPaneKey( + surface: TerminalSurface, + leafId = surface.leafId +): string | null { + if (!isTerminalLeafId(leafId)) { + return null + } + return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), leafId) +} + +/** Normalises and mirrors agent status updates from the host payload, preserving ownership metadata. */ +export function remapHostAgentStatus( + surface: TerminalSurface, + retainedSurface?: TerminalSurface +): AgentStatusEntry | null { + if (!surface.agentStatus) { + return null + } + const paneKey = toMirroredPaneKey(surface, retainedSurface?.leafId) + if (!paneKey) { + return null + } + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent, + hookAgent: surface.agentStatus.agentType + }) + return { + ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, { + ownerIsLaunch: ownerRecord?.ownerIsLaunch === true + }), + paneKey, + tabId: toWebTerminalSurfaceTabId(surface.parentTabId) + } +} + +export function isMirroredAgentPaneKeyForTabs( + paneKey: string, + tabIds: ReadonlySet +): boolean { + const parsed = parsePaneKey(paneKey) + return parsed !== null && tabIds.has(parsed.tabId) +} + +/** Host states the client's byte pipeline cannot observe: permission blocks and + * interactive question cards reach the host over its HTTP agent hook, never + * through PTY bytes, so they must pierce the client-authority fence. */ +export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean { + return entry.state === 'blocked' || entry.interactivePrompt != null +} + +/** True while this renderer's own byte-derived status owns the pane: it claimed + * the pane at transport creation and wrote status from bytes. The claim is + * released on pane teardown, which is how the host takes the pane back. */ +export function isClientOwnedAgentStatus( + paneKey: string, + existing: AgentStatusEntry | undefined +): existing is AgentStatusEntry { + return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey) +} + +/** Owned AND still fresh — the arbitration rule for a pane the host also has an + * opinion about: an OSC-silent dead agent hands that contest back to the host. */ +export function isFencedClientAgentStatus( + paneKey: string, + existing: AgentStatusEntry | undefined, + now: number +): existing is AgentStatusEntry { + return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) +} + +export function batchAgentPaneKeysForTabs( + state: WebSessionTabsSyncState, + tabIds: ReadonlySet, + batchContext?: WebSessionTabsBatchContext +): string[] { + if (!batchContext) { + return Object.keys(state.agentStatusByPaneKey) + } + if (!batchContext.agentPaneKeysByTabId) { + batchContext.agentPaneKeysByTabId = new Map() + for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + continue + } + const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() + paneKeys.add(paneKey) + batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) + } + } + return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) +} + +export function updateBatchAgentPaneKey( + paneKey: string, + present: boolean, + batchContext?: WebSessionTabsBatchContext +): void { + const tabId = parsePaneKey(paneKey)?.tabId + const index = batchContext?.agentPaneKeysByTabId + if (!tabId || !index) { + return + } + if (present) { + const paneKeys = index.get(tabId) ?? new Set() + paneKeys.add(paneKey) + index.set(tabId, paneKeys) + return + } + const paneKeys = index.get(tabId) + paneKeys?.delete(paneKey) + if (paneKeys?.size === 0) { + index.delete(tabId) + } +} + +// Why: the closed-tab marker has no TTL, and setAgentStatus hard-drops writes for a +// marked tab id — for a stable mirrored id that returns, that is a permanent silent +// blackhole unless presence in a snapshot lifts it. +export function buildRemirroredClosedTabMarkerLiftPatch( + recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'], + mirroredTerminalIds: ReadonlySet +): Partial | null { + let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null + for (const tabId of mirroredTerminalIds) { + if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) { + next ??= { ...recentlyClosedAgentStatusTabIds } + delete next[tabId] + } + } + return next ? { recentlyClosedAgentStatusTabIds: next } : null +} + +/** + * A host retraction owes the retracted tab closeTab's renderer-state sweep: without it, + * client-owned rows (STA-3107-exempt in the mirror's delete loop) and retention promotions + * outlive the tab forever (STA-4593). + */ +export function buildRetractedMirroredTabSweepPatch( + state: WebSessionTabsSyncState, + worktreeId: string, + nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], + agentStatusPatch: Pick< + WebSessionTabsSyncState, + 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' + > | null, + removedTerminalResourceIds: readonly string[], + batchContext?: WebSessionTabsBatchContext +): Partial | null { + // Why: only a mirrored id the host stopped publishing is a retraction — a local or provisional + // tab in this list is being renamed into its mirror, and a rename must keep its rows. + const retractedTabIds = removedTerminalResourceIds.filter(isMirroredTerminalSurfaceId) + if (retractedTabIds.length === 0) { + return null + } + const sweepState: RetiredTerminalTabSweepState = { + acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, + agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, + agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, + agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, + paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, + recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, + recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, + retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, + sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, + // Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has", + // so it must see the post-removal tab list, not the one the snapshot replaced. + tabsByWorktree: nextTabsByWorktree + } + const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId) + if (!sweep?.agentStatusByPaneKey || !batchContext) { + return sweep ?? null + } + // Why: the batch republishes its own record copy at the end, which would undo the sweep. + const mutableState = state as unknown as Record + mutableState.agentStatusByPaneKey = sweep.agentStatusByPaneKey + batchContext.changedRecords.add('agentStatusByPaneKey') + for (const paneKey of Object.keys(sweepState.agentStatusByPaneKey)) { + if (!(paneKey in sweep.agentStatusByPaneKey)) { + updateBatchAgentPaneKey(paneKey, false, batchContext) + } + } + return sweep +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts new file mode 100644 index 00000000000..68c06e05c9d --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts @@ -0,0 +1,288 @@ +import type { applyWorktreeRecordUpdates } from './apply-worktree-records' +import { withWorktreeEntry, sameStringArray } from './state-equality-core' +import { toVisibleTabType } from './state-equality-files' +import { + collectLayoutGroupIds, + appendTabGroupLayout, + pruneTabGroupLayout, + tabGroupLayoutEqual +} from './layout-helpers' + +type ActiveStateContext = ReturnType + +/** Resolve active ids, visible type, layout, and tab-bar order after reconciliation. */ +export function applyActiveStateUpdates(context: ActiveStateContext) { + const { + state, + snapshot, + options, + worktreeId, + targetGroupId, + nextGroups, + clientOwnedPlacement, + nextUnifiedTabs, + nextTerminalTabs, + nextWorktreeOpenFileIds, + nextActiveUnifiedTabId, + nextTabBarOrder, + navigationIntentTab, + honorSnapshotActiveFocus, + intentMirroredAgent, + activeMirroredAgentTabId, + currentVisibleUnifiedTabId, + currentVisibleStructuredTabId, + currentActiveTerminalStillExists, + currentActiveBrowserStillExists, + currentActiveEditorStillExists, + intentEditorFileId, + nextActiveTerminalId, + nextActiveBrowserWorkspaceId, + nextActiveEditorFileId, + intentTerminalId, + intentBrowserWorkspaceId + } = context + + const nextActiveGroupId = clientOwnedPlacement + ? clientOwnedPlacement.activeGroupId + : (nextGroups?.find((group) => group.activeTabId === nextActiveUnifiedTabId)?.id ?? + nextGroups?.find((group) => group.id === snapshot.activeGroupId)?.id ?? + nextGroups?.[0]?.id ?? + null) + const nextActiveGroupIdByWorktree = + nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId + ? withWorktreeEntry( + state, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId ?? targetGroupId, + (current, next) => current === next, + context.batchContext + ) + : state.activeGroupIdByWorktree + + const nextLayoutByWorktree = (() => { + if (!nextGroups) { + return state.layoutByWorktree + } + if (clientOwnedPlacement) { + const clientLayout = + clientOwnedPlacement.layout ?? + (nextActiveGroupId ? { type: 'leaf' as const, groupId: nextActiveGroupId } : null) + if (!clientLayout || tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], clientLayout)) { + return state.layoutByWorktree + } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + clientLayout, + (current, next) => current === next, + context.batchContext + ) + } + if (options?.preserveLocalLayout) { + return state.layoutByWorktree + } + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) + const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId } + const hostLayoutGroupIds = collectLayoutGroupIds(hostLayout ?? undefined) + const hostGroupIds = new Set(snapshot.tabGroups?.map((group) => group.id) ?? []) + const extraGroupIds = new Set( + nextGroups + .map((group) => group.id) + .filter((groupId) => + hostLayout + ? !hostLayoutGroupIds.has(groupId) + : snapshot.tabGroups && snapshot.tabGroups.length > 0 + ? !hostGroupIds.has(groupId) + : false + ) + ) + const localExtraLayout = pruneTabGroupLayout(state.layoutByWorktree[worktreeId], extraGroupIds) + const hostBaseLayout = + hostLayout ?? (snapshot.tabGroups && snapshot.tabGroups.length > 0 ? defaultLeafLayout : null) + const fallbackLayout = + appendTabGroupLayout(hostBaseLayout, localExtraLayout) ?? + (snapshot.tabGroups && snapshot.tabGroups.length > 0 + ? defaultLeafLayout + : state.layoutByWorktree[worktreeId] + ? null + : defaultLeafLayout) + if ( + !fallbackLayout || + tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout) + ) { + return state.layoutByWorktree + } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + fallbackLayout, + (current, next) => current === next, + context.batchContext + ) + })() + + const nextTabBarOrderByWorktree = withWorktreeEntry( + state, + 'tabBarOrderByWorktree', + worktreeId, + nextTabBarOrder.length > 0 ? nextTabBarOrder : null, + (a, b) => sameStringArray(a ?? [], b ?? []), + context.batchContext + ) + const nextActiveTabIdByWorktree = + (state.activeTabIdByWorktree[worktreeId] ?? null) !== + (intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId) + ? withWorktreeEntry( + state, + 'activeTabIdByWorktree', + worktreeId, + intentMirroredAgent?.unifiedTab.id ?? + currentVisibleStructuredTabId ?? + nextActiveTerminalId, + (current, next) => (current ?? null) === next, + context.batchContext, + false + ) + : state.activeTabIdByWorktree + const nextActiveBrowserTabIdByWorktree = + (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId + ? withWorktreeEntry( + state, + 'activeBrowserTabIdByWorktree', + worktreeId, + nextActiveBrowserWorkspaceId, + (current, next) => (current ?? null) === next, + context.batchContext, + false + ) + : state.activeBrowserTabIdByWorktree + const nextActiveFileIdByWorktree = + (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId + ? withWorktreeEntry( + state, + 'activeFileIdByWorktree', + worktreeId, + nextActiveEditorFileId, + (current, next) => (current ?? null) === next, + context.batchContext, + false + ) + : state.activeFileIdByWorktree + + const isActiveWorktree = state.activeWorktreeId === worktreeId + const focusIntentVisibleTabType = + navigationIntentTab?.type === 'agent-session' && intentMirroredAgent + ? ('agent-session' as const) + : navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId + ? ('browser' as const) + : navigationIntentTab?.type === 'terminal' && intentTerminalId + ? ('terminal' as const) + : intentEditorFileId + ? ('editor' as const) + : null + const snapshotVisibleTabType = + snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId + ? ('agent-session' as const) + : snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId + ? ('browser' as const) + : snapshot.activeTabType === 'terminal' && nextActiveTerminalId + ? ('terminal' as const) + : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && + nextActiveEditorFileId + ? ('editor' as const) + : null + const currentVisibleTabType = + state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null) + const currentVisibleTabTypeStillValid = + currentVisibleStructuredTabId !== null + ? ('agent-session' as const) + : currentVisibleTabType === 'agent-session' && + currentVisibleUnifiedTabId && + nextUnifiedTabs?.some( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? ('agent-session' as const) + : currentVisibleTabType === 'browser' && currentActiveBrowserStillExists + ? ('browser' as const) + : currentVisibleTabType === 'editor' && currentActiveEditorStillExists + ? ('editor' as const) + : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists + ? ('terminal' as const) + : null + const activeUnifiedTab = + nextActiveUnifiedTabId && nextUnifiedTabs + ? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null) + : null + const fallbackVisibleTabType = + activeUnifiedTab !== null + ? toVisibleTabType(activeUnifiedTab) + : nextActiveTerminalId + ? ('terminal' as const) + : nextActiveBrowserWorkspaceId + ? ('browser' as const) + : nextActiveEditorFileId + ? ('editor' as const) + : ('terminal' as const) + const nextVisibleTabType = honorSnapshotActiveFocus + ? (focusIntentVisibleTabType ?? + currentVisibleTabTypeStillValid ?? + snapshotVisibleTabType ?? + fallbackVisibleTabType) + : (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType) + + const currentActiveTerminalStillValid = + state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : null + const currentActiveEditorStillValid = + state.activeFileId && nextWorktreeOpenFileIds.has(state.activeFileId) + ? state.activeFileId + : null + const nextActiveTabId = isActiveWorktree + ? (intentMirroredAgent?.unifiedTab.id ?? + (snapshot.activeTabType === 'terminal' + ? nextActiveTerminalId + : (currentActiveTerminalStillValid ?? nextActiveTerminalId))) + : state.activeTabId + const nextActiveBrowserTabId = isActiveWorktree + ? nextActiveBrowserWorkspaceId + : state.activeBrowserTabId + const nextActiveFileId = isActiveWorktree + ? snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? nextActiveEditorFileId + : (currentActiveEditorStillValid ?? nextActiveEditorFileId) + : state.activeFileId + const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType + const nextActiveTabTypeByWorktree = + state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType + ? withWorktreeEntry( + state, + 'activeTabTypeByWorktree', + worktreeId, + nextVisibleTabType, + (current, next) => current === next, + context.batchContext + ) + : state.activeTabTypeByWorktree + + return { + ...context, + nextActiveGroupId, + nextActiveGroupIdByWorktree, + nextLayoutByWorktree, + nextTabBarOrderByWorktree, + nextActiveTabIdByWorktree, + nextActiveBrowserTabIdByWorktree, + nextActiveFileIdByWorktree, + nextVisibleTabType, + nextActiveTabId, + nextActiveBrowserTabId, + nextActiveFileId, + nextActiveTabType, + nextActiveTabTypeByWorktree + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts new file mode 100644 index 00000000000..56fe3285913 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts @@ -0,0 +1,134 @@ +import type { applyTerminalRecordUpdates } from './apply-terminal-records' +import { + browserCertificateFailureEqual, + optionalRuntimeBrowserPlacementsEqual, + sameBrowserPages +} from './state-equality-tabs' +import { writableWebSessionTabsRecord } from './state-equality-core' +import { markWebSessionBrowserPlacementAdopted } from '../web-session-browser-placement' + +type BrowserRecordContext = ReturnType + +/** Reconcile browser page records, remote handles, and certificate failures. */ +export function applyBrowserRecordUpdates(context: BrowserRecordContext) { + const { + state, + batchContext, + environmentId, + worktreeId, + mirroredBrowserTabs, + removedBrowserWorkspaceIds, + retainedBrowserTabs, + nextBrowserTabs + } = context + + let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace + let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId + let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId + + if (removedBrowserWorkspaceIds.size > 0) { + const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? []) + const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id)) + for (const workspace of retainedBrowserTabs) { + for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) { + nextBrowserPageIds.add(page.id) + } + } + for (const removedWorkspaceId of removedBrowserWorkspaceIds) { + const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] + if ( + !nextBrowserWorkspaceIds.has(removedWorkspaceId) && + nextBrowserPagesByWorkspace[removedWorkspaceId] + ) { + nextBrowserPagesByWorkspace = + nextBrowserPagesByWorkspace === state.browserPagesByWorkspace + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) + : nextBrowserPagesByWorkspace + delete nextBrowserPagesByWorkspace[removedWorkspaceId] + } + for (const page of pages) { + if (nextBrowserPageIds.has(page.id)) { + continue + } + if (nextBrowserCertificateFailuresByPageId[page.id]) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? writableWebSessionTabsRecord( + state, + 'browserCertificateFailuresByPageId', + batchContext + ) + : nextBrowserCertificateFailuresByPageId + delete nextBrowserCertificateFailuresByPageId[page.id] + } + if (nextRemoteBrowserPageHandlesByPageId[page.id]) { + nextRemoteBrowserPageHandlesByPageId = + nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId + ? writableWebSessionTabsRecord( + state, + 'remoteBrowserPageHandlesByPageId', + batchContext + ) + : nextRemoteBrowserPageHandlesByPageId + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } + } + } + } + + for (const { page, certificateFailure, remotePageId, placement } of mirroredBrowserTabs) { + const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] + if (!sameBrowserPages(current, [page])) { + nextBrowserPagesByWorkspace = + nextBrowserPagesByWorkspace === state.browserPagesByWorkspace + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) + : nextBrowserPagesByWorkspace + nextBrowserPagesByWorkspace[page.workspaceId] = [page] + } + const currentHandle = nextRemoteBrowserPageHandlesByPageId[page.id] + if ( + currentHandle?.environmentId !== environmentId || + currentHandle.remotePageId !== remotePageId || + // A host snapshot is the adoption boundary for a staged handle. + currentHandle.staged === true || + // Restored markers are spent when the host republishes the page. + currentHandle.restoredFromSession === true || + !optionalRuntimeBrowserPlacementsEqual(currentHandle.placement, placement) + ) { + nextRemoteBrowserPageHandlesByPageId = + nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId + ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) + : nextRemoteBrowserPageHandlesByPageId + nextRemoteBrowserPageHandlesByPageId[page.id] = { + environmentId, + remotePageId, + ...(placement ? { placement } : {}) + } + } + markWebSessionBrowserPlacementAdopted({ environmentId, worktreeId, remotePageId }) + if ( + placement?.kind !== 'client' && + !browserCertificateFailureEqual( + nextBrowserCertificateFailuresByPageId[page.id], + certificateFailure + ) + ) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) + : nextBrowserCertificateFailuresByPageId + if (certificateFailure) { + nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure + } else { + delete nextBrowserCertificateFailuresByPageId[page.id] + } + } + } + + return { + ...context, + nextBrowserPagesByWorkspace, + nextRemoteBrowserPageHandlesByPageId, + nextBrowserCertificateFailuresByPageId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts new file mode 100644 index 00000000000..e15e6369c41 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -0,0 +1,146 @@ +import type { WebSessionTabsSyncState } from './state' +import type { applyActiveStateUpdates } from './apply-active-state' +import { buildMirroredAgentStatusPatch } from './agent-status-patch' +import { + buildRemirroredClosedTabMarkerLiftPatch, + buildRetractedMirroredTabSweepPatch +} from './agent-status-primitives' +import { isWebSessionTabsWorktreeRemovalFrame } from './tracking' + +type FinalPatchContext = ReturnType + +/** Build the minimal store patch after all reconciliation stages have completed. */ +export function buildWebSessionTabsFinalPatch( + context: FinalPatchContext +): WebSessionTabsSyncState | Partial { + const { + state, + snapshot, + worktreeId, + now, + batchContext, + currentTerminalTabs, + terminalSurfaceTabs, + mirroredTerminalTabs, + nextTabsByWorktree, + removedTerminalResourceIds, + mirroredTerminalIds, + nextOpenFiles, + nextBrowserTabsByWorktree, + nextUnifiedTabsByWorktree, + nextGroupsByWorktree, + nextActiveGroupIdByWorktree, + nextLayoutByWorktree, + nextTabBarOrderByWorktree, + nextPtyIdsByTabId, + nextTerminalLayoutsByTabId, + nextUnreadTerminalTabs, + pendingStartupByTabId, + nextPendingStartupByTabId, + automaticAgentResumeClaimsByTabId, + nextAutomaticAgentResumeClaimsByTabId, + nextBrowserPagesByWorkspace, + nextRemoteBrowserPageHandlesByPageId, + nextBrowserCertificateFailuresByPageId, + nextActiveTabIdByWorktree, + nextActiveBrowserTabIdByWorktree, + nextActiveFileIdByWorktree, + nextActiveTabId, + nextActiveBrowserTabId, + nextActiveFileId, + nextActiveTabType, + nextActiveTabTypeByWorktree + } = context + + const agentStatusPatch = buildMirroredAgentStatusPatch( + state, + currentTerminalTabs, + terminalSurfaceTabs, + mirroredTerminalTabs, + now, + batchContext + ) + // A tombstone clears all environments' view of a worktree; it is not a terminal retraction. + const retractedTabSweepPatch = isWebSessionTabsWorktreeRemovalFrame(snapshot) + ? null + : buildRetractedMirroredTabSweepPatch( + state, + worktreeId, + nextTabsByWorktree, + agentStatusPatch, + removedTerminalResourceIds, + batchContext + ) + const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( + retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? + state.recentlyClosedAgentStatusTabIds, + mirroredTerminalIds + ) + + const patch: Partial = { + ...agentStatusPatch, + ...retractedTabSweepPatch, + ...remirroredClosedTabLiftPatch, + ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), + ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), + ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree + ? { browserTabsByWorktree: nextBrowserTabsByWorktree } + : {}), + ...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree + ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } + : {}), + ...(nextGroupsByWorktree !== state.groupsByWorktree + ? { groupsByWorktree: nextGroupsByWorktree } + : {}), + ...(nextActiveGroupIdByWorktree !== state.activeGroupIdByWorktree + ? { activeGroupIdByWorktree: nextActiveGroupIdByWorktree } + : {}), + ...(nextLayoutByWorktree !== state.layoutByWorktree + ? { layoutByWorktree: nextLayoutByWorktree } + : {}), + ...(nextTabBarOrderByWorktree !== state.tabBarOrderByWorktree + ? { tabBarOrderByWorktree: nextTabBarOrderByWorktree } + : {}), + ...(nextPtyIdsByTabId !== state.ptyIdsByTabId ? { ptyIdsByTabId: nextPtyIdsByTabId } : {}), + ...(nextTerminalLayoutsByTabId !== state.terminalLayoutsByTabId + ? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId } + : {}), + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextPendingStartupByTabId !== pendingStartupByTabId + ? { pendingStartupByTabId: nextPendingStartupByTabId } + : {}), + ...(nextAutomaticAgentResumeClaimsByTabId !== automaticAgentResumeClaimsByTabId + ? { automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId } + : {}), + ...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace + ? { browserPagesByWorkspace: nextBrowserPagesByWorkspace } + : {}), + ...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId + ? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } + : {}), + ...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId + ? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId } + : {}), + ...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree + ? { activeTabIdByWorktree: nextActiveTabIdByWorktree } + : {}), + ...(nextActiveBrowserTabIdByWorktree !== state.activeBrowserTabIdByWorktree + ? { activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree } + : {}), + ...(nextActiveFileIdByWorktree !== state.activeFileIdByWorktree + ? { activeFileIdByWorktree: nextActiveFileIdByWorktree } + : {}), + ...(nextActiveTabId !== state.activeTabId ? { activeTabId: nextActiveTabId } : {}), + ...(nextActiveBrowserTabId !== state.activeBrowserTabId + ? { activeBrowserTabId: nextActiveBrowserTabId } + : {}), + ...(nextActiveFileId !== state.activeFileId ? { activeFileId: nextActiveFileId } : {}), + ...(nextActiveTabType !== state.activeTabType ? { activeTabType: nextActiveTabType } : {}), + ...(nextActiveTabTypeByWorktree !== state.activeTabTypeByWorktree + ? { activeTabTypeByWorktree: nextActiveTabTypeByWorktree } + : {}) + } + return Object.keys(patch).length === 0 ? state : patch +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts new file mode 100644 index 00000000000..1671fc5a975 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts @@ -0,0 +1,212 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { + WebSessionTabsBatchContext, + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState +} from './state' +import { + reconcileWebSessionCloseIntents, + isWebSessionCloseIntentPending +} from '../web-session-close-intent' +import { + peekWebSessionFocusIntent, + resolveWebSessionVisibleTabId, + clearWebSessionFocusIntent +} from '../web-session-focus-intent' +import { + resolveWebAgentSessionHandoff, + isWebAgentSessionHandoffPostCreateSnapshotConfirmed, + clearWebAgentSessionHandoff +} from '../web-agent-session-handoff' +import { toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { + isReadyTerminalTab, + isTerminalSurfaceTab, + isMirroredTerminalSurfaceId, + shouldReplaceTerminalTab +} from './terminal-surfaces' +import { buildMirroredTerminalTabs } from './terminal-build' + +export function prepareWebSessionTabsSnapshotBase( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + worktreeId: string, + now: number, + batchContext: WebSessionTabsBatchContext | undefined, + options: WebSessionTabsSnapshotApplyOptions | undefined +) { + const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => + tab.type === 'terminal' ? tab.parentTabId : tab.id + reconcileWebSessionCloseIntents( + { environmentId }, + worktreeId, + new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab))) + ) + const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) => + isWebSessionCloseIntentPending({ environmentId }, worktreeId, snapshotHostTabId(tab), now) + ) + ? { + ...rawSnapshot, + tabs: rawSnapshot.tabs.filter( + (tab) => + !isWebSessionCloseIntentPending( + { environmentId }, + worktreeId, + snapshotHostTabId(tab), + now + ) + ) + } + : rawSnapshot + // Why: only a caller-recorded create intent may focus its arriving tab; unsolicited server-active must not steal focus (#5435). + const focusIntent = peekWebSessionFocusIntent({ environmentId }, worktreeId) + const focusIntentHostTabId = focusIntent?.hostTabId ?? null + const matchingFocusIntentTab = + focusIntentHostTabId === null + ? null + : focusIntent?.leafId + ? (snapshot.tabs.find( + (tab) => + tab.type === 'terminal' && + tab.leafId === focusIntent.leafId && + (tab.id === focusIntentHostTabId || tab.parentTabId === focusIntentHostTabId) + ) ?? null) + : (snapshot.tabs.find( + (tab) => + tab.id === focusIntentHostTabId || + (tab.type === 'terminal' && tab.parentTabId === focusIntentHostTabId) || + (tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId) + ) ?? null) + const expectedCurrentLocalTabId = focusIntent?.expectedCurrentLocalTabId + const currentVisibleLocalTabId = resolveWebSessionVisibleTabId(state, worktreeId) + const callerFocusIntentTab = + matchingFocusIntentTab && + (expectedCurrentLocalTabId === undefined || + expectedCurrentLocalTabId === currentVisibleLocalTabId) + ? matchingFocusIntentTab + : null + const followIntentTab = + snapshot.navigationIntent === 'follow' + ? (snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? null) + : null + const navigationIntentTab = callerFocusIntentTab ?? followIntentTab + const honorSnapshotActiveFocus = navigationIntentTab !== null + if (matchingFocusIntentTab) { + clearWebSessionFocusIntent({ environmentId }, worktreeId) + } + const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) + const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' + const terminalSurfaceTabs = reconcilesNonAgentTabs + ? snapshot.tabs.filter(isTerminalSurfaceTab) + : [] + const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) + const nextRemotePtyIds = new Set( + readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) + ) + const nextMirroredTerminalIds = new Set( + terminalSurfaceTabs.map((tab) => toWebTerminalSurfaceTabId(tab.parentTabId)) + ) + const nextHostTerminalTabIds = new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId)) + const provisionalHandoffHostTabIds = new Map() + for (const tab of currentTerminalTabs) { + if (isMirroredTerminalSurfaceId(tab.id)) { + continue + } + if (nextHostTerminalTabIds.has(tab.id)) { + provisionalHandoffHostTabIds.set(tab.id, tab.id) + continue + } + const handoff = { + environmentId, + worktreeId, + provisionalTabId: tab.id + } + const hostTabId = resolveWebAgentSessionHandoff(handoff) + if ( + hostTabId !== null && + (nextHostTerminalTabIds.has(hostTabId) || + isWebAgentSessionHandoffPostCreateSnapshotConfirmed(handoff)) + ) { + provisionalHandoffHostTabIds.set(tab.id, hostTabId) + } + } + const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) + const retainedTerminalTabs = reconcilesNonAgentTabs + ? currentTerminalTabs.filter( + (tab) => + !shouldReplaceTerminalTab( + tab, + environmentId, + nextRemotePtyIds, + nextMirroredTerminalIds, + exactProvisionalHandoffs + ) + ) + : currentTerminalTabs + const mirroredTerminalTabs = buildMirroredTerminalTabs( + snapshot, + environmentId, + existingTerminalById, + state.terminalLayoutsByTabId, + retainedTerminalTabs.length, + now, + callerFocusIntentTab?.type === 'terminal' + ? { + parentTabId: callerFocusIntentTab.parentTabId, + leafId: callerFocusIntentTab.leafId + } + : undefined, + options?.terminalPtyMode + ) + const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab) + const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id)) + const nextTerminalTabs = + retainedTerminalTabs.length + mirroredTerminalTabEntries.length > 0 + ? [...retainedTerminalTabs, ...mirroredTerminalTabEntries] + : null + const mirroredTerminalIds = new Set(mirroredTerminalTabEntries.map((tab) => tab.id)) + const removedTerminalIds = new Set( + currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const removedTerminalResourceIds = [...removedTerminalIds].filter( + (tabId) => !mirroredTerminalIds.has(tabId) + ) + for (const provisionalTabId of exactProvisionalHandoffs) { + clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) + } + + return { + state, + rawSnapshot, + snapshot, + environmentId, + worktreeId, + now, + batchContext, + options, + focusIntent, + matchingFocusIntentTab, + callerFocusIntentTab, + navigationIntentTab, + honorSnapshotActiveFocus, + currentTerminalTabs, + existingTerminalById, + reconcilesNonAgentTabs, + terminalSurfaceTabs, + readyTerminalTabs, + nextRemotePtyIds, + nextMirroredTerminalIds, + provisionalHandoffHostTabIds, + exactProvisionalHandoffs, + retainedTerminalTabs, + mirroredTerminalTabs, + mirroredTerminalTabEntries, + nextTerminalTabs, + mirroredTerminalIds, + removedTerminalIds, + removedTerminalResourceIds + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts new file mode 100644 index 00000000000..6263d9d8467 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -0,0 +1,227 @@ +import { buildWebSessionExistingTabIndex } from '../web-session-existing-tab-index' +import { chooseTargetGroupId, buildHostGroupIdByTabId } from './layout-helpers' +import { + buildMirroredBrowserTabs, + browserWorkspaceHasRemoteEnvironmentPage, + browserWorkspaceHasClientHostedEnvironmentPage +} from './browser-helpers' +import { buildMirroredEditorTabs } from './tab-builders' +import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' +import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority' +import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' +import type { OpenFile } from '../../store/slices/editor' +import { + advanceWebSessionOpenFilesIndex, + firstOpenFileByIdForWorktree, + sameOpenFiles, + webSessionOpenFilesForWorktree +} from './state-equality-files' + +export function prepareWebSessionTabsSnapshotBrowser( + base: ReturnType +) { + const { + state, + snapshot, + environmentId, + worktreeId, + now, + mirroredTerminalTabEntries, + mirroredTerminalIds, + removedTerminalIds, + reconcilesNonAgentTabs, + batchContext + } = base + const targetGroupId = chooseTargetGroupId(state, snapshot) + const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) + const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) + const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] + const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) + const mirroredBrowserTabs = buildMirroredBrowserTabs( + snapshot, + environmentId, + state, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length, + now + ) + const mirroredBrowserWorkspaceIds = new Set( + mirroredBrowserTabs.map((entry) => entry.workspace.id) + ) + const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] + const removedBrowserWorkspaceIds = new Set( + (reconcilesNonAgentTabs ? currentBrowserTabs : []) + .filter((tab) => { + if (mirroredBrowserWorkspaceIds.has(tab.id)) { + return true + } + if (!browserWorkspaceHasRemoteEnvironmentPage(state, tab, environmentId)) { + return false + } + // Why: a staged tab holds a handle before its create RPC answers, and a restored tab holds + // one rebuilt from disk before the relaunched host has republished the page. Neither + // handle is evidence the host ever saw this snapshot's worktree, so absence proves + // nothing — culling here would erase an optimistic tab mid-create or a restored tab a + // recovering host is still about to hand back. + if ( + (state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return handle?.staged === true || handle?.restoredFromSession === true + }) + ) { + return false + } + // Why: a runtime with nothing published for this worktree, or one that has restarted and not + // yet taken its client-hosted pages back, answers with a frame that looks exactly like + // "everything was closed". A page this desktop is still hosting outlives the runtime + // process, so its own guest is the better evidence — hold the row and let adoption publish + // it, rather than deleting a tab that is still rendering. The unreconciled flag is + // host-bounded; the unpublished-worktree frame is not, and its hold lasts until the runtime + // publishes that worktree at all. + if ( + !hostSnapshotAffirmsClientHostedPages(snapshot) && + browserWorkspaceHasClientHostedEnvironmentPage(state, tab, environmentId) + ) { + return false + } + return !(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return ( + handle?.environmentId === environmentId && + nextRemoteBrowserPageIds.has(handle.remotePageId) + ) + }) + }) + .map((tab) => tab.id) + ) + const retainedBrowserTabs = currentBrowserTabs.filter( + (tab) => !removedBrowserWorkspaceIds.has(tab.id) + ) + const nextBrowserTabs = + retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 + ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] + : null + const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] + const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) + const mirroredEditorTabs = buildMirroredEditorTabs( + snapshot, + environmentId, + firstOpenFileByIdForWorktree(worktreeOpenFiles), + existingTabIndex, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, + now + ) + const mirroredAgentTabs = buildMirroredAgentTabs( + snapshot, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length, + currentUnifiedTabs, + now + ) + const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) + const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) + const removedEditorFileIds = new Set( + (reconcilesNonAgentTabs ? worktreeOpenFiles : []) + .filter( + (file) => + file.runtimeEnvironmentId === environmentId && + (file.mode === 'edit' || file.mode === 'markdown-preview') && + // Why: only cull host-mirrored tabs; locally opened files have no host counterpart, so their omission isn't a close signal. + file.mirroredFromRuntimeSession === true && + !mirroredEditorFileIds.has(file.id) + ) + .map((file) => file.id) + ) + const isReplacedOpenFile = (file: OpenFile): boolean => + file.runtimeEnvironmentId === environmentId && + (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) + const replacedOpenFileCount = worktreeOpenFiles.filter(isReplacedOpenFile).length + // Why: both consumers below ask only about this worktree, so the surviving ids answer + // them in worktree scope instead of walking every open file in the app. + const nextWorktreeOpenFileIds = new Set( + worktreeOpenFiles.filter((file) => !isReplacedOpenFile(file)).map((file) => file.id) + ) + for (const fileId of mirroredEditorFileIds) { + nextWorktreeOpenFileIds.add(fileId) + } + const mirroredOpenFiles = mirroredEditorTabs.map((entry) => entry.file) + const nextOpenFiles = (() => { + // Why: with nothing to drop or mirror, rebuilding reproduces the array exactly, so + // skip the global rebuild the equality check below would have thrown away anyway. + if (replacedOpenFileCount === 0 && mirroredOpenFiles.length === 0) { + return state.openFiles + } + const retained = state.openFiles.filter( + (file) => + !( + file.worktreeId === worktreeId && + file.runtimeEnvironmentId === environmentId && + (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) + ) + ) + const next = [...retained, ...mirroredOpenFiles] + return sameOpenFiles(state.openFiles, next) ? state.openFiles : next + })() + advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) + const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { + if (tab.contentType === 'agent-session') { + return false + } + if (tab.contentType === 'browser') { + return ( + !removedBrowserWorkspaceIds.has(tab.entityId) && + !mirroredBrowserWorkspaceIds.has(tab.entityId) + ) + } + if (tab.contentType === 'editor') { + return ( + !removedEditorFileIds.has(tab.entityId) && + !mirroredEditorFileIds.has(tab.entityId) && + !mirroredEditorHostTabIds.has(tab.id) + ) + } + if (tab.contentType !== 'terminal') { + return true + } + if (removedTerminalIds.has(tab.entityId) || removedTerminalIds.has(tab.id)) { + return false + } + return !mirroredTerminalIds.has(tab.entityId) && !mirroredTerminalIds.has(tab.id) + }) + const existingViewModeByTabId = new Map( + currentUnifiedTabs + .filter((tab) => tab.contentType === 'terminal' && tab.viewMode) + .map((tab) => [tab.id, tab.viewMode] as const) + ) + return { + ...base, + targetGroupId, + hostGroupIdByTabId, + currentUnifiedTabs, + existingTabIndex, + readyBrowserTabs, + nextRemoteBrowserPageIds, + mirroredBrowserTabs, + mirroredBrowserWorkspaceIds, + currentBrowserTabs, + removedBrowserWorkspaceIds, + retainedBrowserTabs, + nextBrowserTabs, + readyEditorTabs, + worktreeOpenFiles, + mirroredEditorTabs, + mirroredAgentTabs, + mirroredEditorFileIds, + mirroredEditorHostTabIds, + removedEditorFileIds, + nextWorktreeOpenFileIds, + nextOpenFiles, + retainedUnifiedTabs, + existingViewModeByTabId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts new file mode 100644 index 00000000000..db5904fe9e6 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts @@ -0,0 +1,233 @@ +import type { TabGroup } from '../../../../shared/tab-types' +import type { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' +import { peekWebSessionTerminalPlacementGroup } from '../web-session-terminal-placement' +import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' +import { reconcileClientOwnedTabPlacement } from '../web-session-client-owned-tab-placement' +import { buildMirroredHostGroups, retainClientPlacedMirroredTabs } from './layout-groups' +import { sanitizeRecentTabIds, pushRecentTabId } from './state-equality-core' + +export function prepareWebSessionTabsSnapshotGroups( + base: ReturnType +) { + const { + state, + snapshot, + environmentId, + worktreeId, + now, + options, + terminalSurfaceTabs, + mirroredBrowserTabs, + mirroredEditorTabs, + mirroredUnifiedIds, + mirroredUnifiedTabs, + nextUnifiedTabs, + validUnifiedTabIds, + nextActiveUnifiedTabId, + retainedUnifiedTabs, + targetGroupId, + hostToLocalTabId, + provisionalHandoffHostTabIds, + existingTabIndex, + honorSnapshotActiveFocus, + intentUnifiedTabId, + reservedEmptyPreviewFallbackTabId + } = base + const currentGroups = state.groupsByWorktree[worktreeId] ?? [] + const clientGroupIdByLocalTabId = new Map( + mirroredBrowserTabs.flatMap((entry) => + entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : [] + ) + ) + // Why: once this worktree has client groups, placement is client-owned — snapshots may only + // append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order, + // host actives, and host layout apply only on first adoption (no client groups yet). + const clientOwnedPlacement = (() => { + // Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement + // is client-owned even before any local group record exists — first adoption on an + // empty worktree must repair a rendered-leaf-without-record or materialize a rendered + // group instead of publishing the tab into a group no local leaf will ever show. + if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) { + return null + } + // Why: an entity-identical replacement (provisional terminal → mirrored surface, local + // editor → host editor tab) is a rename — its position and focus must carry over. + const rekeyedTabIds = new Map() + for (const [provisionalTabId, hostTabId] of provisionalHandoffHostTabIds) { + const mirroredId = toWebTerminalSurfaceTabId(hostTabId) + if (mirroredId !== provisionalTabId) { + rekeyedTabIds.set(provisionalTabId, mirroredId) + } + } + for (const entry of mirroredEditorTabs) { + const existing = existingTabIndex.getEditorUnifiedTab(entry.file.id, entry.hostTabId) + if (existing && existing.id !== entry.unifiedTab.id) { + rekeyedTabIds.set(existing.id, entry.unifiedTab.id) + } + } + const knownGroupTabIds = new Set( + currentGroups.flatMap((group) => + group.tabOrder.map((tabId) => rekeyedTabIds.get(tabId) ?? tabId) + ) + ) + // Why: a pending record is this client's own create intent — authoritative even when the + // provisional tab was provisionally adopted elsewhere or the target group record lags its leaf. + // The entry's own clientGroupId comes first, so a group the user moved the row into after the + // create was recorded wins over the group the create asked for. + const placementMoves = mirroredBrowserTabs.flatMap((entry) => { + const recordedGroupId = peekWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId, + remotePageId: entry.remotePageId + }) + if (!recordedGroupId) { + return [] + } + return [{ tabId: entry.unifiedTab.id, groupId: entry.clientGroupId ?? recordedGroupId }] + }) + for (const parentTabId of new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId))) { + const recordedGroupId = peekWebSessionTerminalPlacementGroup({ + environmentId, + worktreeId, + hostTabId: parentTabId + }) + if (recordedGroupId) { + placementMoves.push({ + tabId: toWebTerminalSurfaceTabId(parentTabId), + groupId: recordedGroupId + }) + } + } + const adoptedTabs = mirroredUnifiedTabs + .filter((tab) => !knownGroupTabIds.has(tab.id)) + .map((tab) => ({ + tabId: tab.id, + groupId: clientGroupIdByLocalTabId.get(tab.id) ?? tab.groupId + })) + return reconcileClientOwnedTabPlacement({ + currentGroups, + worktreeId, + validUnifiedTabIds, + adoptedTabs, + placementMoves, + rekeyedTabIds, + intentTabId: honorSnapshotActiveFocus ? (intentUnifiedTabId ?? null) : null, + reservedEmptyGroupFallbackTabId: reservedEmptyPreviewFallbackTabId, + currentActiveGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, + currentLayout: state.layoutByWorktree[worktreeId] ?? null, + isGroupReserved: (groupId) => + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId }) + }) + })() + const nextGroups = (() => { + if (clientOwnedPlacement) { + return clientOwnedPlacement.groups + } + if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) { + return null + } + if (snapshot.tabGroups && snapshot.tabGroups.length > 0) { + return buildMirroredHostGroups({ + currentGroups, + hostGroups: snapshot.tabGroups, + hostToLocalTabId, + mirroredUnifiedIds, + nextActiveUnifiedTabId, + now, + validUnifiedTabIds, + environmentId, + worktreeId, + clientGroupIdByLocalTabId + }) + } + const strippedGroups = retainClientPlacedMirroredTabs({ + groups: currentGroups, + mirroredUnifiedIds, + validUnifiedTabIds, + clientGroupIdByLocalTabId, + nextActiveUnifiedTabId + }) + const target = strippedGroups.find((group) => group.id === targetGroupId) ?? { + id: targetGroupId, + worktreeId, + activeTabId: null, + tabOrder: [], + recentTabIds: [] + } + const targetOrder = [ + ...target.tabOrder.filter((tabId) => validUnifiedTabIds.has(tabId)), + ...mirroredUnifiedTabs + .filter((tab) => !clientGroupIdByLocalTabId.has(tab.id)) + .map((tab) => tab.id) + ] + const targetActiveTabId = + nextActiveUnifiedTabId && targetOrder.includes(nextActiveUnifiedTabId) + ? nextActiveUnifiedTabId + : target.activeTabId && targetOrder.includes(target.activeTabId) + ? target.activeTabId + : (targetOrder[0] ?? null) + const updatedTarget: TabGroup = { + ...target, + worktreeId, + tabOrder: targetOrder, + activeTabId: targetActiveTabId, + recentTabIds: targetActiveTabId + ? pushRecentTabId(sanitizeRecentTabIds(target.recentTabIds, targetOrder), targetActiveTabId) + : [] + } + const merged = strippedGroups.some((group) => group.id === targetGroupId) + ? strippedGroups.map((group) => (group.id === targetGroupId ? updatedTarget : group)) + : [...strippedGroups, updatedTarget] + return merged.filter( + (group) => + group.id === targetGroupId || + group.tabOrder.length > 0 || + isWebSessionBrowserPlacementGroupReserved({ + worktreeId, + groupId: group.id + }) + ) + })() + + const nextTabBarOrder = (() => { + const current = state.tabBarOrderByWorktree[worktreeId] ?? [] + const validTabBarIds = new Set([ + ...retainedUnifiedTabs.map((tab) => tab.id), + ...mirroredUnifiedTabs.map((tab) => tab.id) + ]) + const hostTabBarOrder = + snapshot.tabGroups?.flatMap((group) => + group.tabOrder + .map((tabId) => hostToLocalTabId.get(tabId)) + .filter((tabId): tabId is string => tabId !== undefined && validTabBarIds.has(tabId)) + ) ?? [] + const next: string[] = [] + const seen = new Set() + const push = (tabId: string): void => { + if (validTabBarIds.has(tabId) && !seen.has(tabId)) { + seen.add(tabId) + next.push(tabId) + } + } + // Why: snapshots can arrive after the client staged local browser tabs, so preserve visible order and only append new host tabs. + for (const tabId of current) { + push(tabId) + } + const hostOrMirroredOrder = + hostTabBarOrder.length > 0 ? hostTabBarOrder : mirroredUnifiedTabs.map((tab) => tab.id) + for (const tabId of hostOrMirroredOrder) { + push(tabId) + } + return next + })() + return { + ...base, + currentGroups, + clientGroupIdByLocalTabId, + clientOwnedPlacement, + nextGroups, + nextTabBarOrder + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts new file mode 100644 index 00000000000..888f458c314 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts @@ -0,0 +1,278 @@ +import type { prepareWebSessionTabsSnapshotBrowser } from './apply-preparation-browser' +import { buildTerminalUnifiedTab } from './tab-builders' +import { isAgentSessionTab } from './terminal-surfaces' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { + resolveWebSessionSiblingVisibleTabId, + resolveWebSessionVisibleTabId +} from '../web-session-focus-intent' +import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' +import { buildHostToLocalTabIdMap, updateHostSessionTabIdMappings } from './layout-groups' + +export function prepareWebSessionTabsSnapshotUnified( + base: ReturnType +) { + const { + state, + snapshot, + environmentId, + worktreeId, + navigationIntentTab, + honorSnapshotActiveFocus, + terminalSurfaceTabs, + mirroredTerminalTabs, + mirroredTerminalTabEntries, + nextTerminalTabs, + mirroredBrowserTabs, + mirroredEditorTabs, + mirroredAgentTabs, + readyBrowserTabs, + readyEditorTabs, + nextBrowserTabs, + nextWorktreeOpenFileIds, + retainedUnifiedTabs, + existingViewModeByTabId, + hostGroupIdByTabId, + targetGroupId + } = base + const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) => + buildTerminalUnifiedTab( + entry.tab, + hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId, + environmentId, + entry.tab.viewMode ?? existingViewModeByTabId.get(entry.tab.id) + ) + ) + const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab) + const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab) + const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab) + const mirroredUnifiedTabs = [ + ...mirroredTerminalUnifiedTabs, + ...mirroredBrowserUnifiedTabs, + ...mirroredEditorUnifiedTabs, + ...mirroredAgentUnifiedTabs + ] + const nextUnifiedTabs = + retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0 + ? [...retainedUnifiedTabs, ...mirroredUnifiedTabs] + : null + const validUnifiedTabIds = new Set(nextUnifiedTabs?.map((tab) => tab.id) ?? []) + const activeHostTerminalId = + terminalSurfaceTabs.find((tab) => tab.id === snapshot.activeTabId)?.id ?? + terminalSurfaceTabs.find((tab) => tab.isActive)?.id ?? + null + const activeHostTerminalParentId = + terminalSurfaceTabs.find((tab) => tab.id === activeHostTerminalId)?.parentTabId ?? + terminalSurfaceTabs.find((tab) => tab.isActive)?.parentTabId ?? + null + const activeMirroredTerminalId = activeHostTerminalId + ? toWebTerminalSurfaceTabId(activeHostTerminalParentId ?? activeHostTerminalId) + : null + const activeHostBrowser = + readyBrowserTabs.find((tab) => tab.id === snapshot.activeTabId) ?? + readyBrowserTabs.find((tab) => tab.isActive) ?? + null + const activeMirroredBrowser = activeHostBrowser + ? (mirroredBrowserTabs.find( + (entry) => entry.remotePageId === activeHostBrowser.browserPageId + ) ?? null) + : null + const activeMirroredBrowserTabId = activeMirroredBrowser?.unifiedTab.id ?? null + const activeMirroredBrowserWorkspaceId = activeMirroredBrowser?.workspace.id ?? null + const activeHostEditor = + readyEditorTabs.find((tab) => tab.id === snapshot.activeTabId) ?? + readyEditorTabs.find((tab) => tab.isActive) ?? + null + const activeMirroredEditor = activeHostEditor + ? (mirroredEditorTabs.find((entry) => entry.hostTabId === activeHostEditor.id) ?? null) + : null + const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null + const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null + const activeHostAgent = + snapshot.tabs + .filter(isAgentSessionTab) + .find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null + const activeMirroredAgentTabId = activeHostAgent + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ?? + null) + : null + const intentMirroredTerminalId = + navigationIntentTab?.type === 'terminal' + ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) + : null + const intentMirroredBrowser = + navigationIntentTab?.type === 'browser' + ? (mirroredBrowserTabs.find( + (entry) => + entry.hostTabId === navigationIntentTab.id || + entry.remotePageId === navigationIntentTab.browserPageId + ) ?? null) + : null + const intentMirroredEditor = + navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null + const intentMirroredAgent = + navigationIntentTab?.type === 'agent-session' + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null + const currentActiveTerminalStillExists = + state.activeTabIdByWorktree[worktreeId] && + (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) + ? state.activeTabIdByWorktree[worktreeId] + : null + // Why: caller intent targets the requested tab even when an older host leaves its own active tab unchanged. + const intentTerminalId = + honorSnapshotActiveFocus && navigationIntentTab?.type === 'terminal' + ? intentMirroredTerminalId + : null + const nextActiveTerminalId = + intentTerminalId ?? + currentActiveTerminalStillExists ?? + (snapshot.activeTabType === 'terminal' + ? (activeMirroredTerminalId ?? mirroredTerminalTabEntries[0]?.id) + : mirroredTerminalTabEntries[0]?.id) ?? + null + const currentActiveBrowserStillExists = + state.activeBrowserTabIdByWorktree[worktreeId] && + (nextBrowserTabs ?? []).some((tab) => tab.id === state.activeBrowserTabIdByWorktree[worktreeId]) + ? state.activeBrowserTabIdByWorktree[worktreeId] + : null + const intentBrowserWorkspaceId = + honorSnapshotActiveFocus && navigationIntentTab?.type === 'browser' + ? (intentMirroredBrowser?.workspace.id ?? null) + : null + const nextActiveBrowserWorkspaceId = + intentBrowserWorkspaceId ?? + currentActiveBrowserStillExists ?? + (snapshot.activeTabType === 'browser' + ? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id) + : mirroredBrowserTabs[0]?.workspace.id) ?? + null + const activeEditorFileIdForWorktree = state.activeFileIdByWorktree[worktreeId] + const currentActiveEditorStillExists = + activeEditorFileIdForWorktree && nextWorktreeOpenFileIds.has(activeEditorFileIdForWorktree) + ? activeEditorFileIdForWorktree + : null + const intentEditorFileId = honorSnapshotActiveFocus + ? (intentMirroredEditor?.file.id ?? null) + : null + const nextActiveEditorFileId = + intentEditorFileId ?? + currentActiveEditorStillExists ?? + (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id) + : mirroredEditorTabs[0]?.file.id) ?? + null + const currentVisibleUnifiedTabId = resolveWebSessionVisibleTabId( + state, + worktreeId, + nextUnifiedTabs ?? [] + ) + const currentVisibleStructuredTabId = + currentVisibleUnifiedTabId && + nextUnifiedTabs?.find( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? currentVisibleUnifiedTabId + : null + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + // Why: Open Preview to the Side can activate an empty reserved group before the host + // browser lands. A snapshot that still has the host terminal active must not treat + // that emptiness as a terminal focus change. + const reservedEmptyPreviewFallbackTabId = + currentVisibleUnifiedTabId == null && + activeGroupId != null && + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId }) + ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? []) + : null + // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. + const intentUnifiedTabId = honorSnapshotActiveFocus + ? navigationIntentTab?.type === 'browser' + ? (intentMirroredBrowser?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'terminal' + ? intentTerminalId + : navigationIntentTab?.type === 'agent-session' + ? (intentMirroredAgent?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (intentMirroredEditor?.unifiedTab.id ?? null) + : null + : null + const nextActiveUnifiedTabId = + intentUnifiedTabId ?? + currentVisibleUnifiedTabId ?? + reservedEmptyPreviewFallbackTabId ?? + (snapshot.activeTabType === 'agent-session' + ? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId) + : snapshot.activeTabType === 'browser' + ? (activeMirroredBrowserTabId ?? + mirroredBrowserTabs[0]?.unifiedTab.id ?? + state.activeTabIdByWorktree[worktreeId] ?? + nextActiveTerminalId) + : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? (activeMirroredEditorTabId ?? + mirroredEditorTabs[0]?.unifiedTab.id ?? + state.activeTabIdByWorktree[worktreeId] ?? + nextActiveTerminalId) + : nextActiveTerminalId) + const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id)) + const hostToLocalTabId = buildHostToLocalTabIdMap({ + terminalSurfaces: terminalSurfaceTabs, + terminalTabs: mirroredTerminalTabEntries, + browserTabs: mirroredBrowserTabs, + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs + }) + updateHostSessionTabIdMappings({ + environmentId, + worktreeId, + terminalSurfaces: terminalSurfaceTabs, + terminalTabs: mirroredTerminalTabEntries, + browserTabs: mirroredBrowserTabs, + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs + }) + + return { + ...base, + mirroredTerminalUnifiedTabs, + mirroredBrowserUnifiedTabs, + mirroredEditorUnifiedTabs, + mirroredAgentUnifiedTabs, + mirroredUnifiedTabs, + nextUnifiedTabs, + validUnifiedTabIds, + activeHostTerminalId, + activeMirroredTerminalId, + activeHostBrowser, + activeMirroredBrowser, + activeMirroredBrowserTabId, + activeMirroredBrowserWorkspaceId, + activeHostEditor, + activeMirroredEditor, + activeMirroredEditorFileId, + activeMirroredEditorTabId, + activeHostAgent, + activeMirroredAgentTabId, + intentMirroredTerminalId, + intentMirroredBrowser, + intentMirroredEditor, + intentMirroredAgent, + currentActiveTerminalStillExists, + intentTerminalId, + nextActiveTerminalId, + currentActiveBrowserStillExists, + intentBrowserWorkspaceId, + nextActiveBrowserWorkspaceId, + currentActiveEditorStillExists, + intentEditorFileId, + nextActiveEditorFileId, + currentVisibleUnifiedTabId, + currentVisibleStructuredTabId, + reservedEmptyPreviewFallbackTabId, + intentUnifiedTabId, + nextActiveUnifiedTabId, + mirroredUnifiedIds, + hostToLocalTabId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts new file mode 100644 index 00000000000..41013a8f323 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts @@ -0,0 +1,52 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { + WebSessionTabsBatchContext, + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState +} from './state' +import { suppressE2eWebRuntimeBrowserSnapshot } from '../web-runtime-browser-creation-e2e-fault' +import { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' +import { prepareWebSessionTabsSnapshotBrowser } from './apply-preparation-browser' +import { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified' +import { prepareWebSessionTabsSnapshotGroups } from './apply-preparation-groups' +import { applyTerminalRecordUpdates } from './apply-terminal-records' +import { applyBrowserRecordUpdates } from './apply-browser-records' +import { applyWorktreeRecordUpdates } from './apply-worktree-records' +import { applyActiveStateUpdates } from './apply-active-state' +import { buildWebSessionTabsFinalPatch } from './apply-final-patch' + +/** Reconcile one host frame through the staged terminal/browser/layout pipeline. */ +export function applyWebSessionTabsSnapshotWithContext( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now(), + batchContext?: WebSessionTabsBatchContext, + options?: WebSessionTabsSnapshotApplyOptions +): WebSessionTabsSyncState | Partial { + if ( + suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot) || + rawSnapshot.worktree === FLOATING_TERMINAL_WORKTREE_ID + ) { + return state + } + const worktreeId = rawSnapshot.worktree + const base = prepareWebSessionTabsSnapshotBase( + state, + rawSnapshot, + environmentId, + worktreeId, + now, + batchContext, + options + ) + const browser = prepareWebSessionTabsSnapshotBrowser(base) + const unified = prepareWebSessionTabsSnapshotUnified(browser) + const groups = prepareWebSessionTabsSnapshotGroups(unified) + const terminalRecords = applyTerminalRecordUpdates(groups) + const browserRecords = applyBrowserRecordUpdates(terminalRecords) + const worktreeRecords = applyWorktreeRecordUpdates(browserRecords) + const activeState = applyActiveStateUpdates(worktreeRecords) + return buildWebSessionTabsFinalPatch(activeState) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts new file mode 100644 index 00000000000..087237abb32 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts @@ -0,0 +1,116 @@ +import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' +import type { prepareWebSessionTabsSnapshotGroups } from './apply-preparation-groups' +import { sameStringArray, writableWebSessionTabsRecord } from './state-equality-core' + +type TerminalRecordContext = ReturnType + +/** Reconcile PTY ownership, layouts, unread markers, and provisional startup records. */ +export function applyTerminalRecordUpdates(context: TerminalRecordContext) { + const { + state, + batchContext, + mirroredTerminalTabs, + removedTerminalResourceIds, + removedTerminalIds, + exactProvisionalHandoffs + } = context + + let nextPtyIdsByTabId = state.ptyIdsByTabId + for (const removedId of removedTerminalResourceIds) { + if (!nextPtyIdsByTabId[removedId]) { + continue + } + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + delete nextPtyIdsByTabId[removedId] + } + for (const { tab, ptyIds } of mirroredTerminalTabs) { + if (ptyIds.length === 0) { + if (nextPtyIdsByTabId[tab.id]) { + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + delete nextPtyIdsByTabId[tab.id] + } + continue + } + const current = nextPtyIdsByTabId[tab.id] ?? [] + if (sameStringArray(current, ptyIds)) { + continue + } + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + nextPtyIdsByTabId[tab.id] = ptyIds + } + + let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId + for (const removedId of removedTerminalResourceIds) { + if (!nextTerminalLayoutsByTabId[removedId]) { + continue + } + nextTerminalLayoutsByTabId = + nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) + : nextTerminalLayoutsByTabId + delete nextTerminalLayoutsByTabId[removedId] + } + for (const { tab, layout } of mirroredTerminalTabs) { + if (terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { + continue + } + nextTerminalLayoutsByTabId = + nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) + : nextTerminalLayoutsByTabId + nextTerminalLayoutsByTabId[tab.id] = layout + } + + let nextUnreadTerminalTabs = state.unreadTerminalTabs + for (const removedId of removedTerminalIds) { + if (!nextUnreadTerminalTabs[removedId]) { + continue + } + nextUnreadTerminalTabs = + nextUnreadTerminalTabs === state.unreadTerminalTabs + ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) + : nextUnreadTerminalTabs + delete nextUnreadTerminalTabs[removedId] + } + + const pendingStartupByTabId = state.pendingStartupByTabId ?? {} + let nextPendingStartupByTabId = pendingStartupByTabId + const automaticAgentResumeClaimsByTabId = state.automaticAgentResumeClaimsByTabId ?? {} + let nextAutomaticAgentResumeClaimsByTabId = automaticAgentResumeClaimsByTabId + for (const removedId of exactProvisionalHandoffs) { + if (nextPendingStartupByTabId[removedId]) { + nextPendingStartupByTabId = + nextPendingStartupByTabId === pendingStartupByTabId + ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) + : nextPendingStartupByTabId + delete nextPendingStartupByTabId[removedId] + } + if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { + nextAutomaticAgentResumeClaimsByTabId = + nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId + ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) + : nextAutomaticAgentResumeClaimsByTabId + delete nextAutomaticAgentResumeClaimsByTabId[removedId] + } + } + + return { + ...context, + nextPtyIdsByTabId, + nextTerminalLayoutsByTabId, + nextUnreadTerminalTabs, + pendingStartupByTabId, + nextPendingStartupByTabId, + automaticAgentResumeClaimsByTabId, + nextAutomaticAgentResumeClaimsByTabId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts new file mode 100644 index 00000000000..dda0a14d322 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts @@ -0,0 +1,92 @@ +import type { applyBrowserRecordUpdates } from './apply-browser-records' +import { withWorktreeEntry } from './state-equality-core' +import { sameGroups, sameUnifiedTabs } from './state-equality-files' +import { sameBrowserTabs as sameBrowserWorkspaces, sameTerminalTabs } from './state-equality-tabs' + +type WorktreeRecordContext = ReturnType + +/** Commit worktree-scoped tab collections and retain client-owned placement metadata. */ +export function applyWorktreeRecordUpdates(context: WorktreeRecordContext) { + const { + state, + batchContext, + worktreeId, + nextTerminalTabs, + nextBrowserTabs, + nextUnifiedTabs, + nextGroups, + clientOwnedPlacement, + retainedUnifiedTabs, + mirroredUnifiedTabs, + mirroredTerminalIds, + nextTabBarOrder + } = context + + const nextTabsByWorktree = withWorktreeEntry( + state, + 'tabsByWorktree', + worktreeId, + nextTerminalTabs, + sameTerminalTabs, + batchContext + ) + const nextBrowserTabsByWorktree = withWorktreeEntry( + state, + 'browserTabsByWorktree', + worktreeId, + nextBrowserTabs, + sameBrowserWorkspaces, + batchContext + ) + + // Group membership is the source of truth for a client-owned placement. + const placedUnifiedTabs = (() => { + if (!clientOwnedPlacement?.groups || !nextUnifiedTabs) { + return nextUnifiedTabs + } + const groupIdByTabId = new Map( + clientOwnedPlacement.groups.flatMap((group) => + group.tabOrder.map((tabId) => [tabId, group.id] as const) + ) + ) + let changed = false + const placed = nextUnifiedTabs.map((tab) => { + const groupId = groupIdByTabId.get(tab.id) + if (!groupId || groupId === tab.groupId) { + return tab + } + changed = true + return { ...tab, groupId } + }) + return changed ? placed : nextUnifiedTabs + })() + const nextUnifiedTabsByWorktree = withWorktreeEntry( + state, + 'unifiedTabsByWorktree', + worktreeId, + placedUnifiedTabs, + sameUnifiedTabs, + batchContext + ) + const nextGroupsByWorktree = withWorktreeEntry( + state, + 'groupsByWorktree', + worktreeId, + nextGroups, + sameGroups, + batchContext + ) + + return { + ...context, + nextTabsByWorktree, + nextBrowserTabsByWorktree, + placedUnifiedTabs, + nextUnifiedTabsByWorktree, + nextGroupsByWorktree, + mirroredTerminalIds, + retainedUnifiedTabs, + mirroredUnifiedTabs, + nextTabBarOrder + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts new file mode 100644 index 00000000000..8597547cc0c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts @@ -0,0 +1,237 @@ +import type { + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state' +import { readBrowserClientHostId } from '../browser-client-host-identity' +import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' +import { browserPageEqual } from './state-equality-tabs' +import { collectLayoutGroupIds } from './layout-helpers' +import { buildBrowserUnifiedTab } from './tab-builders' +import { isReadyBrowserTab } from './terminal-surfaces' + +export function findBrowserWorkspaceForRemotePage( + state: WebSessionTabsSyncState, + worktreeId: string, + environmentId: string, + remotePageId: string +): { workspace: BrowserWorkspace; page: BrowserPage; unifiedTab: Tab | null } | null { + const workspaces = state.browserTabsByWorktree[worktreeId] ?? [] + for (const workspace of workspaces) { + const pages = state.browserPagesByWorkspace[workspace.id] ?? [] + for (const page of pages) { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + if (handle?.environmentId === environmentId && handle.remotePageId === remotePageId) { + return { + workspace, + page, + unifiedTab: + (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'browser' && tab.entityId === workspace.id + ) ?? null + } + } + } + } + return null +} + +export function browserWorkspaceHasRemoteEnvironmentPage( + state: WebSessionTabsSyncState, + workspace: BrowserWorkspace, + environmentId: string +): boolean { + return (state.browserPagesByWorkspace[workspace.id] ?? []).some( + (page) => state.remoteBrowserPageHandlesByPageId[page.id]?.environmentId === environmentId + ) +} + +/** A page of this environment whose guest runs on this desktop, so it survives the host process. */ +export function browserWorkspaceHasClientHostedEnvironmentPage( + state: WebSessionTabsSyncState, + workspace: BrowserWorkspace, + environmentId: string +): boolean { + return (state.browserPagesByWorkspace[workspace.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return ( + handle?.environmentId === environmentId && + (handle.placement?.kind === 'client' || + handle.stagedClientHosted === true || + handle.restoredClientHosted === true) + ) + }) +} + +/** + * The host publishes `title || url || 'Browser'` (see the runtime's browser tab projection), so a + * page that has not produced a real title yet arrives as its own url — or as the bare default. + * Neither says anything the local row does not already say better, so while the page still sits at + * the url this client gave it, the local title stands and only a real navigation replaces it. + * + * Scope: the pre-adoption/staged window, every placement the host actually drives, and the mirror + * of a page some other client hosts. Rows whose guest is ours never reach here — see + * resolveMirroredBrowserPageContent. + */ +export function resolveMirroredBrowserTitle( + tab: RuntimeMobileSessionBrowserTab, + existingPage: BrowserPage | undefined +): string { + const published = tab.title.trim() + const publishedIsHostFallback = + published === '' || published === tab.url.trim() || published === 'Browser' + if (existingPage && publishedIsHostFallback && tab.url === existingPage.url) { + return existingPage.title + } + return published || 'Browser' +} + +/** + * Whether the guest running this page is one of ours. `placement.kind === 'client'` says only that + * *some* client hosts it: a second desktop, or the web client that installs no page renderer at + * all, mirrors the same row with nothing of its own to see it with. The placement names the lease + * holder, so the comparison is exact, and it holds from the moment the host mints the placement — + * before our guest has attached — which is the window the title would otherwise flicker through. + */ +export function clientHostsMirroredBrowserPage(tab: RuntimeMobileSessionBrowserTab): boolean { + if (tab.placement?.kind !== 'client') { + return false + } + const hostClientId = readBrowserClientHostId() + return hostClientId !== null && tab.placement.browserHostClientId === hostClientId +} + +/** + * A page hosted by this client lives in our own guest webview; the host only ever learns its + * content second-hand through a fire-and-forget metadata publish, so its snapshot is a stale echo + * at best and the registry's `'Browser'`/create-time defaults at worst. Once a local row exists it + * is the truth. + * + * The url is part of the payload and not just a symptom: rewinding it to the host's create-time + * value is what breaks the staged-title hold's url-equality arm on the following snapshot. + * + * Every other row keeps taking the host values — a client that hosts no guest has no second + * opinion to offer, and freezing its mirror would strand it on whatever its first snapshot said. + */ +export function resolveMirroredBrowserPageContent( + tab: RuntimeMobileSessionBrowserTab, + existingPage: BrowserPage | undefined +): Pick { + if (clientHostsMirroredBrowserPage(tab) && existingPage) { + return { + url: existingPage.url, + title: existingPage.title, + loading: existingPage.loading, + canGoBack: existingPage.canGoBack, + canGoForward: existingPage.canGoForward + } + } + return { + url: tab.url, + title: resolveMirroredBrowserTitle(tab, existingPage), + loading: tab.loading, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward + } +} + +export function buildMirroredBrowserTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + state: WebSessionTabsSyncState, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + now: number +): MirroredBrowserTab[] { + const renderedGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) + const clientGroupIds = new Set( + (state.groupsByWorktree[snapshot.worktree] ?? []).map((group) => group.id) + ) + return snapshot.tabs.filter(isReadyBrowserTab).map((tab, index) => { + const existing = findBrowserWorkspaceForRemotePage( + state, + snapshot.worktree, + environmentId, + tab.browserPageId + ) + const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId + const pageId = existing?.page.id ?? tab.browserPageId + const createdAt = existing?.page.createdAt ?? now + sortOffset + index + const recordedClientGroupId = peekWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: snapshot.worktree, + remotePageId: tab.browserPageId + }) + const hostGroupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId + const existingClientGroupId = + existing?.unifiedTab?.groupId !== hostGroupId ? existing?.unifiedTab?.groupId : undefined + // Why: a staged row was placed by this client, so wherever it sits now is the user's own + // choice — including a split made after the create recorded its intent. Rows the client never + // staged carry no such truth: a pre-response snapshot may have parked them in the host group, + // and there the record is what repairs them. + const preferredClientGroupId = + existing && state.remoteBrowserPageHandlesByPageId[existing.page.id]?.staged === true + ? (existing.unifiedTab?.groupId ?? recordedClientGroupId) + : (recordedClientGroupId ?? existingClientGroupId) + const clientGroupId = + preferredClientGroupId && + clientGroupIds.has(preferredClientGroupId) && + (renderedGroupIds.size === 0 || renderedGroupIds.has(preferredClientGroupId)) + ? preferredClientGroupId + : undefined + const groupId = clientGroupId ?? hostGroupId + const content = resolveMirroredBrowserPageContent(tab, existing?.page) + const nextPage: BrowserPage = { + id: pageId, + workspaceId, + worktreeId: snapshot.worktree, + ...content, + faviconUrl: existing?.page.faviconUrl ?? null, + // Why: a client-hosted page's load failure is observed by the local guest webview and the + // host publishes no loadError for it, so the host snapshot must not clear the local record. + loadError: + (tab.placement?.kind === 'client' ? existing?.page.loadError : tab.loadError) ?? null, + createdAt, + browserRuntimeEnvironmentId: environmentId, + viewportPresetId: existing?.page.viewportPresetId ?? null + } + // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace + // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. + const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage + const workspace: BrowserWorkspace = { + id: workspaceId, + worktreeId: snapshot.worktree, + label: existing?.workspace.label, + sessionProfileId: existing?.workspace.sessionProfileId ?? null, + activePageId: page.id, + pageIds: [page.id], + url: page.url, + title: page.title, + loading: page.loading, + faviconUrl: page.faviconUrl, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward, + loadError: page.loadError, + createdAt + } + return { + workspace, + page, + certificateFailure: tab.certificateFailure ?? null, + remotePageId: tab.browserPageId, + ...(tab.placement ? { placement: tab.placement } : {}), + unifiedTab: buildBrowserUnifiedTab( + workspace, + tab, + existing?.unifiedTab ?? null, + groupId, + environmentId + ), + hostTabId: tab.id, + ...(clientGroupId ? { clientGroupId } : {}) + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts new file mode 100644 index 00000000000..e964d39676e --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -0,0 +1,165 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay' +import { useAppStore } from '../../store' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { + beginWebSessionTabsSnapshotRecovery, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { decideWebSessionTabsSnapshot } from './tracking-decisions' +import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle' +import { + acceptSessionTabsRuntimeId, + getSessionTabsRuntimeIdFromResponse +} from './publisher-identity-fences' +import { applyWebSessionTabsSnapshot } from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { + hostSessionMirrorSettleForPatchlessFrame, + type HostSessionMirrorSettle +} from './mirror-settle' +import { handleGlobalSessionInventoryEvent } from './global-session-inventory-event' +import type { VisibilityResumeCoordinator } from './visibility-resume-coordinator' + +export type GlobalSessionEventArgs = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number + visibilityGeneration: number + isCurrent: () => boolean + event: unknown + response: RuntimeRpcResponse + awaitingVisibilityResumeInventory: { value: boolean } + coordinator: VisibilityResumeCoordinator +} + +/** Apply one event from the all-worktrees stream, including visibility inventory fencing. */ +export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { + const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + visibilityGeneration, + isCurrent, + response, + awaitingVisibilityResumeInventory, + coordinator + } = args + if (!isCurrent()) { + return + } + if (response.ok === false) { + console.warn('[web-session-tabs-sync] global subscription failed:', response.error.message) + return + } + const runtimeId = getSessionTabsRuntimeIdFromResponse(response) + if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId)) { + return + } + const event = args.event as + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean } + | { type: 'end' } + const replayed = isRuntimeSubscriptionReplayResponse(response) + if (event.type === 'snapshots') { + handleGlobalSessionInventoryEvent({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + visibilityGeneration, + isCurrent, + event, + replayed, + runtimeId, + awaitingVisibilityResumeInventory, + coordinator + }) + return + } + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + const receivedFrame = recordReceivedWebSessionTabsSnapshot( + environmentId, + event, + undefined, + runtimeId + ) + coordinator.recordSnapshotReceipt(environmentId, event, receivedFrame, runtimeId) + const finishRecovery = beginWebSessionTabsSnapshotRecovery( + environmentId, + event.worktree, + receivedFrame + ) + let settleHydration: HostSessionMirrorSettle | null = null + void recoverWebSessionTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId, { + expectedEnvironmentPairingRevision, + getCurrentState: () => useAppStore.getState() + }) + .then((recovered) => { + if ( + !isCurrent() || + !recovered || + !shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + recovered, + receivedFrame, + runtimeId + ) || + !coordinator.shouldApplySnapshot(environmentId, recovered, receivedFrame, runtimeId) + ) { + return + } + if (replayed) { + acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) + } + const decision = decideWebSessionTabsSnapshot(recovered, environmentId, runtimeId) + if (decision.apply) { + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, + recovered, + event.type === 'updated' && !replayed + ) + coordinator.recordSnapshot(environmentId, recovered, receivedFrame, runtimeId) + } else { + settleHydration = hostSessionMirrorSettleForPatchlessFrame( + decision, + environmentId, + recovered.worktree, + { + connectionGeneration: expectedEnvironmentConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + } + ) + } + }) + .catch((error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) + } + }) + .finally(() => { + finishRecovery() + if (isCurrent()) { + settleHydration?.() + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts new file mode 100644 index 00000000000..f8f93f0894d --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -0,0 +1,187 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { useAppStore } from '../../store' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' +import { + beginWebSessionTabsSnapshotRecovery, + recordReceivedWebSessionTabsInventory, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { + decideWebSessionTabsSnapshot, + WEB_SESSION_TABS_FRAME_OUTRANKED +} from './tracking-decisions' +import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle' +import { applyWebSessionTabsSnapshots } from './snapshot-api' +import { + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree +} from './state' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { isHostMirroredWorktree } from './visibility-types' +import type { VisibilityResumeCoordinator } from './visibility-resume-coordinator' + +export type GlobalSessionInventoryEventArgs = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number + visibilityGeneration: number + isCurrent: () => boolean + event: { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean } + replayed: boolean + runtimeId?: string + awaitingVisibilityResumeInventory: { value: boolean } + coordinator: VisibilityResumeCoordinator +} + +/** Apply a full-inventory frame from the all-worktrees stream, fencing visibility omissions. */ +export function handleGlobalSessionInventoryEvent({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + visibilityGeneration, + isCurrent, + event, + replayed, + runtimeId, + awaitingVisibilityResumeInventory, + coordinator +}: GlobalSessionInventoryEventArgs): void { + const skipUnchangedResumeWork = awaitingVisibilityResumeInventory.value && !replayed + awaitingVisibilityResumeInventory.value = false + const unchanged = event.snapshots.map((snapshot) => { + const key = `${environmentId}:${snapshot.worktree}` + const freshness = latestSessionTabsSnapshotByWorktree.get(key) + return Boolean( + skipUnchangedResumeWork && + !replayableSessionTabsSnapshotByWorktree.has(key) && + freshness?.publicationEpoch === snapshot.publicationEpoch && + freshness.snapshotVersion === snapshot.snapshotVersion + ) + }) + const receivedFrames = event.snapshots.map((snapshot) => { + const frame = recordReceivedWebSessionTabsSnapshot( + environmentId, + snapshot, + undefined, + runtimeId + ) + coordinator.recordSnapshotReceipt(environmentId, snapshot, frame, runtimeId) + return frame + }) + const inventoryFrame = recordReceivedWebSessionTabsInventory(environmentId) + const missing = coordinator.recordInventoryReceipt( + environmentId, + visibilityGeneration, + inventoryFrame, + event.snapshots, + runtimeId + ) + const finishRecoveries = event.snapshots.map((snapshot, index) => + unchanged[index] + ? null + : beginWebSessionTabsSnapshotRecovery( + environmentId, + snapshot.worktree, + receivedFrames[index]! + ) + ) + let settleHydration: (() => void) | null = null + void Promise.all( + event.snapshots.map((snapshot, index) => + unchanged[index] + ? Promise.resolve(snapshot) + : recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId, + { + expectedEnvironmentPairingRevision, + getCurrentState: () => useAppStore.getState() + } + ) + ) + ) + .then((recovered) => { + if (!isCurrent()) { + return + } + const applicable = recovered.flatMap((snapshot, index) => + snapshot !== null && + shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + snapshot, + receivedFrames[index]!, + runtimeId + ) && + coordinator.shouldApplySnapshot(environmentId, snapshot, receivedFrames[index]!, runtimeId) + ? [{ index, snapshot }] + : [] + ) + if (visibilityGeneration > 0 || replayed) { + for (const { index, snapshot } of applicable) { + if (!unchanged[index]) { + acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) + } + } + } + const decisions = applicable.map(({ index, snapshot }) => + unchanged[index] + ? WEB_SESSION_TABS_FRAME_OUTRANKED + : decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId) + ) + const freshSnapshots = applicable.flatMap(({ snapshot }, index) => + decisions[index]!.apply ? [snapshot] : [] + ) + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + { + frames: applicable.map(({ snapshot }, index) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision: decisions[index]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + })), + fullInventory: { + environmentId, + authoritative: event.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + publishedSnapshotCount: event.snapshots.filter((snapshot) => + isHostMirroredWorktree(snapshot.worktree) + ).length + } + }, + freshSnapshots + ) + const freshSet = new Set(freshSnapshots) + for (const { index, snapshot } of applicable) { + if (unchanged[index]) { + queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + } + if (unchanged[index] || freshSet.has(snapshot)) { + coordinator.recordSnapshot(environmentId, snapshot, receivedFrames[index]!, runtimeId) + } + } + coordinator.recordInventory(environmentId, visibilityGeneration, inventoryFrame, missing) + }) + .catch((error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) + } + }) + .finally(() => { + for (const finishRecovery of finishRecoveries) { + finishRecovery?.() + } + if (isCurrent()) { + settleHydration?.() + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts new file mode 100644 index 00000000000..52de44f7300 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts @@ -0,0 +1,227 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { clearWebSessionCloseIntentsForOwner } from '../web-session-close-intent' +import { clearWebSessionFocusIntentsForOwner } from '../web-session-focus-intent' +import { clearWebSessionReorderIntentsForOwner } from '../web-session-reorder-intent' +import { + installWindowVisibilitySubscriptionParking, + type WindowVisibilitySubscriptionSpec +} from '../window-visibility-subscription-parking' +import { shouldSyncAllRuntimeSessionTabs } from './tracking-decisions' +import { + getWebSessionTabsTrackingGeneration, + clearWebSessionTabsTrackingForEnvironment +} from './tracking-lifecycle' +import { loadInitialWebSessionTabs } from './load-initial' +import { handleGlobalSessionEvent } from './global-session-events' +import { VisibilityResumeCoordinator } from './visibility-resume-coordinator' +import type { VisibilityResumeOmission, SessionTabsStreamEvent } from './state' +import type { MirroredRuntimeEnvironment } from './visibility-resume-types' +import { WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './state' + +type Ref = { current: T } + +export type GlobalSubscriptionRefs = { + activeRuntimeEnvironmentId: Ref + activeRuntimeWorktreeKey: Ref + visibilityResumeOmissions: Ref> + snapshotReceipt: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ) => void + > + snapshotApply: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ) => boolean + > + snapshotRecord: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ) => void + > + ownerRevisions: Ref> +} + +export type GlobalSubscriptionInstallArgs = { + runtimeSessionMirrorEnvironmentKey: string | null + workspaceSessionReady: boolean + refs: GlobalSubscriptionRefs +} + +function parseEnvironments(value: string | null): MirroredRuntimeEnvironment[] { + if (!value) { + return [] + } + return value + .split('\u0000') + .map((entry) => { + const [environmentId = '', , rawGeneration = '0', rawRevision = ''] = entry.split('\u0001') + return { + environmentId, + expectedEnvironmentConnectionGeneration: Number(rawGeneration), + expectedEnvironmentPairingRevision: rawRevision === '' ? undefined : Number(rawRevision) + } + }) + .filter(({ environmentId }) => environmentId.trim()) +} + +/** Install all-worktree subscriptions and their visibility-resume coordinator. */ +export function installGlobalSessionTabsSubscriptions({ + runtimeSessionMirrorEnvironmentKey, + workspaceSessionReady, + refs +}: GlobalSubscriptionInstallArgs): (() => void) | undefined { + const environments = parseEnvironments(runtimeSessionMirrorEnvironmentKey) + const ownerRevisions = new Map( + (workspaceSessionReady ? environments : []).map( + ({ environmentId, expectedEnvironmentPairingRevision }) => + [environmentId, expectedEnvironmentPairingRevision] as const + ) + ) + const previousOwnerRevisions = refs.ownerRevisions.current + for (const [environmentId, previousRevision] of previousOwnerRevisions) { + if ( + !ownerRevisions.has(environmentId) || + ownerRevisions.get(environmentId) !== previousRevision + ) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } + } + refs.ownerRevisions.current = ownerRevisions + for (const [key, omission] of refs.visibilityResumeOmissions.current) { + const previousRevision = previousOwnerRevisions.get(omission.environmentId) + if ( + !ownerRevisions.has(omission.environmentId) || + (previousOwnerRevisions.has(omission.environmentId) && + ownerRevisions.get(omission.environmentId) !== previousRevision) + ) { + refs.visibilityResumeOmissions.current.delete(key) + } + } + if (!workspaceSessionReady || environments.length === 0) { + return undefined + } + + const subscriptionSpecs: WindowVisibilitySubscriptionSpec[] = [] + const environmentIdBySpec: string[] = [] + const coordinator = new VisibilityResumeCoordinator({ + environments, + environmentIdBySubscriptionSpec: environmentIdBySpec, + omissions: refs.visibilityResumeOmissions.current, + activeRuntimeWorktreeKey: () => refs.activeRuntimeWorktreeKey.current + }) + refs.snapshotReceipt.current = coordinator.recordSnapshotReceipt.bind(coordinator) + refs.snapshotApply.current = coordinator.shouldApplySnapshot.bind(coordinator) + refs.snapshotRecord.current = coordinator.recordSnapshot.bind(coordinator) + + for (const environment of environments) { + const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision + } = environment + if ( + !shouldSyncAllRuntimeSessionTabs({ + activeRuntimeEnvironmentId: environmentId, + workspaceSessionReady + }) + ) { + continue + } + let requestedInitialLoad = false + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) + environmentIdBySpec.push(environmentId) + subscriptionSpecs.push({ + subscribe: (isCurrent, { visibilityGeneration }) => { + const awaitingVisibilityResumeInventory = { value: visibilityGeneration > 0 } + if (!requestedInitialLoad) { + requestedInitialLoad = true + loadInitialWebSessionTabs({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent + }) + } + return window.api.runtimeEnvironments.subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribeAll', + params: {}, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision + }, + { + onResponse: (response) => { + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return + } + handleGlobalSessionEvent({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + visibilityGeneration, + isCurrent, + event: response.ok + ? response.result + : ({ type: 'end' } satisfies SessionTabsStreamEvent), + response, + awaitingVisibilityResumeInventory, + coordinator + }) + }, + onError: (error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] global subscription error:', error.message) + } + } + } + ) + }, + onSubscribeError: (error) => { + console.warn( + '[web-session-tabs-sync] failed to subscribe globally:', + error instanceof Error ? error.message : String(error) + ) + }, + onUnsubscribeError: (error) => { + console.warn('[web-session-tabs-sync] failed to unsubscribe globally:', error) + } + }) + } + + const dispose = installWindowVisibilitySubscriptionParking(subscriptionSpecs, { + getVisibilityResumePriority: (index) => + environmentIdBySpec[index] === refs.activeRuntimeEnvironmentId.current ? 0 : 1, + visibilityResumeStaggerMs: WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS, + onVisibilityResume: ({ visibilityGeneration, restartingSpecIndexes }) => + coordinator.beginVisibilityResume(visibilityGeneration, restartingSpecIndexes) + }) + return () => { + refs.snapshotReceipt.current = () => {} + refs.snapshotApply.current = () => true + refs.snapshotRecord.current = () => {} + dispose() + for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { + const owner = { environmentId, pairingRevision: expectedEnvironmentPairingRevision } + clearWebSessionCloseIntentsForOwner(owner) + clearWebSessionFocusIntentsForOwner(owner) + clearWebSessionReorderIntentsForOwner(owner) + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts new file mode 100644 index 00000000000..ad5a1a0b5ef --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts @@ -0,0 +1,217 @@ +import type { RuntimeMobileSessionTabGroup } from '../../../../shared/runtime-types' +import type { TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + MirroredAgentTab, + MirroredBrowserTab, + MirroredEditorTab, + TerminalSurface +} from './state' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { clearHostSessionTabIdMappings, setHostSessionTabIdMapping } from './tracking-mappings' +import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' +import { resolveWebSessionReorderedOrder } from '../web-session-reorder-intent' +import { mapHostRecentTabIds } from './layout-helpers' +import { pushRecentTabId, sanitizeRecentTabIds } from './state-equality-core' + +export function buildHostToLocalTabIdMap({ + terminalSurfaces, + terminalTabs, + browserTabs, + editorTabs, + agentTabs +}: { + terminalSurfaces: readonly TerminalSurface[] + terminalTabs: readonly TerminalTab[] + browserTabs: readonly MirroredBrowserTab[] + editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] +}): Map { + const hostToLocal = new Map() + const terminalIds = new Set(terminalTabs.map((tab) => tab.id)) + for (const surface of terminalSurfaces) { + const localId = toWebTerminalSurfaceTabId(surface.parentTabId) + if (terminalIds.has(localId)) { + hostToLocal.set(surface.parentTabId, localId) + hostToLocal.set(surface.id, localId) + } + } + for (const entry of browserTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + hostToLocal.set(entry.unifiedTab.id, entry.unifiedTab.id) + } + for (const entry of editorTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + } + for (const entry of agentTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + } + return hostToLocal +} + +export function updateHostSessionTabIdMappings(args: { + environmentId: string + worktreeId: string + terminalSurfaces: readonly TerminalSurface[] + terminalTabs: readonly TerminalTab[] + browserTabs: readonly MirroredBrowserTab[] + editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] +}): void { + clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) + + const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) + for (const surface of args.terminalSurfaces) { + const localId = toWebTerminalSurfaceTabId(surface.parentTabId) + if (mirroredTerminalIds.has(localId)) { + setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) + } + } + for (const entry of args.browserTabs) { + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + } + for (const entry of args.editorTabs) { + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + } + for (const entry of args.agentTabs) { + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + } +} + +export function retainClientPlacedMirroredTabs(args: { + groups: readonly TabGroup[] + mirroredUnifiedIds: ReadonlySet + validUnifiedTabIds: ReadonlySet + clientGroupIdByLocalTabId: ReadonlyMap + nextActiveUnifiedTabId: string | null +}): TabGroup[] { + return args.groups.map((group) => { + const retainedTabOrder = group.tabOrder.filter( + (tabId) => + args.validUnifiedTabIds.has(tabId) && + (!args.mirroredUnifiedIds.has(tabId) || + args.clientGroupIdByLocalTabId.get(tabId) === group.id) + ) + const placedTabIds = [...args.clientGroupIdByLocalTabId] + .filter( + ([tabId, groupId]) => + groupId === group.id && + args.validUnifiedTabIds.has(tabId) && + !retainedTabOrder.includes(tabId) + ) + .map(([tabId]) => tabId) + const tabOrder = [...retainedTabOrder, ...placedTabIds] + const activeTabId = + args.nextActiveUnifiedTabId && tabOrder.includes(args.nextActiveUnifiedTabId) + ? args.nextActiveUnifiedTabId + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: activeTabId + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId) + : [] + } + }) +} + +export function buildMirroredHostGroups({ + currentGroups, + hostGroups, + hostToLocalTabId, + mirroredUnifiedIds, + nextActiveUnifiedTabId, + now, + validUnifiedTabIds, + environmentId, + worktreeId, + clientGroupIdByLocalTabId +}: { + currentGroups: readonly TabGroup[] + hostGroups: readonly RuntimeMobileSessionTabGroup[] + hostToLocalTabId: ReadonlyMap + mirroredUnifiedIds: ReadonlySet + nextActiveUnifiedTabId: string | null + now: number + validUnifiedTabIds: ReadonlySet + environmentId: string + worktreeId: string + clientGroupIdByLocalTabId: ReadonlyMap +}): TabGroup[] | null { + const strippedGroups = retainClientPlacedMirroredTabs({ + groups: currentGroups, + mirroredUnifiedIds, + validUnifiedTabIds, + clientGroupIdByLocalTabId, + nextActiveUnifiedTabId + }) + const groupsById = new Map(strippedGroups.map((group) => [group.id, group])) + const orderedGroups: TabGroup[] = [] + const seen = new Set() + + for (const hostGroup of hostGroups) { + const existing = groupsById.get(hostGroup.id) + const localHostOrder = hostGroup.tabOrder + .map((tabId) => hostToLocalTabId.get(tabId)) + .filter( + (tabId): tabId is string => + tabId !== undefined && + validUnifiedTabIds.has(tabId) && + !clientGroupIdByLocalTabId.has(tabId) + ) + const localHostOrderIds = new Set(localHostOrder) + const hostTabOrder = [ + ...(existing?.tabOrder.filter((tabId) => !localHostOrderIds.has(tabId)) ?? []), + ...localHostOrder + ] + // Why: a pending client reorder wins over a stale pre-move host order until the host echoes the move (or membership changes). + const tabOrder = resolveWebSessionReorderedOrder( + { environmentId }, + worktreeId, + hostGroup.id, + hostTabOrder, + now + ) + if (tabOrder.length === 0) { + continue + } + const activeFromHost = + hostGroup.activeTabId !== null ? (hostToLocalTabId.get(hostGroup.activeTabId) ?? null) : null + const activeTabId = + nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId) + ? nextActiveUnifiedTabId + : activeFromHost && tabOrder.includes(activeFromHost) + ? activeFromHost + : existing?.activeTabId && tabOrder.includes(existing.activeTabId) + ? existing.activeTabId + : (tabOrder[0] ?? null) + orderedGroups.push({ + id: hostGroup.id, + worktreeId, + tabOrder, + activeTabId, + recentTabIds: activeTabId + ? pushRecentTabId( + mapHostRecentTabIds(hostGroup.recentTabIds, hostToLocalTabId, tabOrder), + activeTabId + ) + : [] + }) + seen.add(hostGroup.id) + } + + for (const group of strippedGroups) { + if ( + !seen.has(group.id) && + (group.tabOrder.length > 0 || + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: group.id })) + ) { + orderedGroups.push(group) + } + } + + return orderedGroups.length > 0 ? orderedGroups : null +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts new file mode 100644 index 00000000000..c2b9a526290 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts @@ -0,0 +1,162 @@ +import type { + RuntimeMobileSessionTabGroup, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import { WEB_SESSION_GROUP_PREFIX, type WebSessionTabsSyncState } from './state' +import { sanitizeRecentTabIds } from './state-equality-core' + +export function chooseTargetGroupId( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult +): string { + const groups = state.groupsByWorktree[snapshot.worktree] ?? [] + const layoutGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) + const inRenderedLayout = (groupId: string | null | undefined): boolean => + Boolean(groupId && (layoutGroupIds.size === 0 || layoutGroupIds.has(groupId))) + const preferred = + groups.find((group) => group.id === snapshot.activeGroupId && inRenderedLayout(group.id)) ?? + groups.find( + (group) => + group.id === state.activeGroupIdByWorktree[snapshot.worktree] && inRenderedLayout(group.id) + ) ?? + groups.find((group) => inRenderedLayout(group.id)) + // Why: host snapshots can reference desktop-only group ids; the rendered group is the only safe CSS anchor for mirrored panes. + const firstRenderedLayoutGroupId = layoutGroupIds.values().next().value as string | undefined + return ( + preferred?.id ?? + firstRenderedLayoutGroupId ?? + snapshot.activeGroupId ?? + `${WEB_SESSION_GROUP_PREFIX}${snapshot.worktree}` + ) +} + +export function collectLayoutGroupIds(layout: TabGroupLayoutNode | undefined): Set { + const result = new Set() + const visit = (node: TabGroupLayoutNode | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + result.add(node.groupId) + return + } + visit(node.first) + visit(node.second) + } + visit(layout) + return result +} + +export function buildHostGroupIdByTabId( + hostGroups: readonly RuntimeMobileSessionTabGroup[] | undefined +): Map { + const result = new Map() + for (const group of hostGroups ?? []) { + for (const tabId of group.tabOrder) { + result.set(tabId, group.id) + } + if (group.activeTabId) { + result.set(group.activeTabId, group.id) + } + } + return result +} + +export function pruneTabGroupLayout( + layout: TabGroupLayoutNode | null | undefined, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return validGroupIds.has(layout.groupId) ? layout : null + } + const first = pruneTabGroupLayout(layout.first, validGroupIds) + const second = pruneTabGroupLayout(layout.second, validGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + +export function dropTabGroupLayoutGroups( + layout: TabGroupLayoutNode | null, + excludedGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return excludedGroupIds.has(layout.groupId) ? null : layout + } + const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds) + const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + +export function appendTabGroupLayout( + first: TabGroupLayoutNode | null, + second: TabGroupLayoutNode | null +): TabGroupLayoutNode | null { + if (!first) { + return second + } + if (!second) { + return first + } + // Why: a group already placed by `first` must not gain a second leaf — two + // leaves for one group render the same tab strip in two columns, and each + // later snapshot appends another copy. + const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first)) + if (!appended) { + return first + } + return { + type: 'split', + direction: 'horizontal', + first, + second: appended + } +} + +export function tabGroupLayoutEqual( + a: TabGroupLayoutNode | null | undefined, + b: TabGroupLayoutNode | null | undefined +): boolean { + if (!a || !b) { + return !a && !b + } + if (a.type !== b.type) { + return false + } + if (a.type === 'leaf') { + return b.type === 'leaf' && a.groupId === b.groupId + } + return ( + b.type === 'split' && + a.direction === b.direction && + a.ratio === b.ratio && + tabGroupLayoutEqual(a.first, b.first) && + tabGroupLayoutEqual(a.second, b.second) + ) +} + +export function mapHostRecentTabIds( + recentTabIds: readonly string[] | undefined, + hostToLocalTabId: ReadonlyMap, + tabOrder: readonly string[] +): string[] { + if (!recentTabIds || recentTabIds.length === 0) { + return [] + } + const valid = new Set(tabOrder) + return sanitizeRecentTabIds( + recentTabIds.map((tabId) => hostToLocalTabId.get(tabId) ?? '').filter(Boolean), + [...valid] + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts new file mode 100644 index 00000000000..58ff7761d4c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -0,0 +1,191 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { useAppStore } from '../../store' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { + beginWebSessionTabsSnapshotRecovery, + isSessionTabsListAllResult, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { decideWebSessionTabsSnapshot } from './tracking-decisions' +import { + acceptSessionTabsRuntimeId, + getSessionTabsRuntimeIdFromResponse, + recordReceivedWebSessionTabsEnvironmentFrame +} from './publisher-identity-fences' +import { + latestReceivedSessionTabsFrameByEnvironment, + latestReceivedSessionTabsInventoryFrameByEnvironment, + nextReceivedSessionTabsFrame +} from './state' +import { applyWebSessionTabsSnapshots } from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { isHostMirroredWorktree } from './visibility-types' + +export type InitialSessionTabsLoadArgs = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision: number | undefined + expectedTrackingGeneration: number + isCurrent: () => boolean +} + +/** Perform the one-shot inventory fetch used to seed the mirror deterministically. */ +export function loadInitialWebSessionTabs({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent +}: InitialSessionTabsLoadArgs): void { + // Why: listAll is bootstrap fallback; a stream received after this boundary owns the result. + const requestReceivedFrame = nextReceivedSessionTabsFrame() + let settleHydration: (() => void) | null = null + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method: 'session.tabs.listAll', + params: {}, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision + }) + .then(async (response: RuntimeRpcResponse) => { + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return + } + if (response.ok === false) { + console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) + return + } + const result = response.result + if (!isSessionTabsListAllResult(result)) { + console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') + return + } + const runtimeId = getSessionTabsRuntimeIdFromResponse(response) + const latestReceivedFrame = + latestReceivedSessionTabsFrameByEnvironment.get(environmentId) ?? 0 + if ( + runtimeId && + latestReceivedFrame <= requestReceivedFrame && + !acceptSessionTabsRuntimeId(environmentId, runtimeId, requestReceivedFrame) + ) { + return + } + recordReceivedWebSessionTabsEnvironmentFrame(environmentId, requestReceivedFrame) + const receivedFrames = result.snapshots.map((snapshot) => + recordReceivedWebSessionTabsSnapshot( + environmentId, + snapshot, + requestReceivedFrame, + runtimeId, + 'bootstrap' + ) + ) + const finishRecoveries = result.snapshots.map((snapshot, index) => + beginWebSessionTabsSnapshotRecovery( + environmentId, + snapshot.worktree, + receivedFrames[index]! + ) + ) + try { + const recovered = await Promise.all( + result.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId, + { + expectedEnvironmentPairingRevision, + getCurrentState: () => useAppStore.getState() + } + ) + ) + ) + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return + } + const initialInventorySuperseded = + (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) > + requestReceivedFrame + const applicable = recovered.filter( + (snapshot, index): snapshot is RuntimeMobileSessionTabsResult => + snapshot !== null && + !initialInventorySuperseded && + shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + snapshot, + receivedFrames[index]!, + runtimeId + ) + ) + const decisions = applicable.map((snapshot) => + decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId) + ) + const freshSnapshots = applicable.filter((_snapshot, index) => decisions[index]!.apply) + const initialInventoryStillCurrent = + latestReceivedSessionTabsFrameByEnvironment.get(environmentId) === requestReceivedFrame && + (latestReceivedSessionTabsInventoryFrameByEnvironment.get(environmentId) ?? 0) <= + requestReceivedFrame + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + { + frames: applicable.map((snapshot, index) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision: decisions[index]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + })), + ...(initialInventoryStillCurrent + ? { + fullInventory: { + environmentId, + authoritative: result.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + // Why: a workspace the mirror never writes is not part of the + // inventory the environment-wide verdict has to account for. + publishedSnapshotCount: result.snapshots.filter((snapshot) => + isHostMirroredWorktree(snapshot.worktree) + ).length + } + } + : {}) + }, + applicable + ) + } finally { + for (const finishRecovery of finishRecoveries) { + finishRecovery() + } + } + }) + .catch((error) => { + if (isCurrent()) { + console.warn( + '[web-session-tabs-sync] failed to load initial session tabs:', + error instanceof Error ? error.message : String(error) + ) + } + }) + .finally(() => { + if ( + isCurrent() && + getRuntimeEnvironmentRevision(environmentId) === expectedEnvironmentPairingRevision + ) { + settleHydration?.() + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts new file mode 100644 index 00000000000..8da8b63f2cd --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts @@ -0,0 +1,155 @@ +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import { probeHostLiveTerminals } from '../host-live-terminal-probe' +import { + markHostSessionMirrorHydrated, + markHostSessionMirrorWorktreeHydrated +} from '../host-session-mirror-hydration' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { getWebSessionTabsTrackingGeneration } from './tracking-lifecycle' +import type { WebSessionTabsSnapshotDecision } from './tracking-decisions' + +/** Proof that the store patch committed and its host evidence may be settled. */ +export type HostSessionMirrorSettle = () => void + +export type HostSessionMirrorPatchFrame = { + environmentId: string + worktreeId: string + decision: WebSessionTabsSnapshotDecision + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number +} + +export type HostSessionMirrorPatchVerdict = { + frames: readonly HostSessionMirrorPatchFrame[] + fullInventory?: { + environmentId: string + publishedSnapshotCount: number + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } +} + +type HostSessionMirrorSettleFence = { + environmentId: string + connectionGeneration: number + pairingRevision?: number + trackingGeneration: number +} + +function captureHostSessionMirrorSettleFence( + environmentId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettleFence { + return { + environmentId, + connectionGeneration: + expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), + pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), + trackingGeneration: + expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) + } +} + +function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { + return ( + getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && + getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && + getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration + ) +} + +function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( + fence: HostSessionMirrorSettleFence +): void { + void probeHostLiveTerminals( + fence.environmentId, + undefined, + fence.connectionGeneration, + fence.pairingRevision + ).then((verdict) => { + // A reconnect while probing invalidates the answer for this connection. + if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorHydrated(fence.environmentId) + } + }) +} + +export function createHostSessionMirrorSettle( + verdict: HostSessionMirrorPatchVerdict +): HostSessionMirrorSettle { + const fenceByEnvironment = new Map() + for (const frame of verdict.frames) { + fenceByEnvironment.set( + frame.environmentId, + captureHostSessionMirrorSettleFence(frame.environmentId, { + connectionGeneration: frame.expectedEnvironmentConnectionGeneration, + pairingRevision: frame.expectedEnvironmentPairingRevision, + trackingGeneration: frame.expectedTrackingGeneration + }) + ) + } + if (verdict.fullInventory) { + fenceByEnvironment.set( + verdict.fullInventory.environmentId, + captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { + connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, + pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, + trackingGeneration: verdict.fullInventory.expectedTrackingGeneration + }) + ) + } + return () => { + const { frames, fullInventory } = verdict + const settles = frames.filter(({ decision }) => decision.settlesHostMirror) + if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { + const fence = fenceByEnvironment.get(fullInventory.environmentId) + if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { + return + } + if (fullInventory.publishedSnapshotCount === 0) { + if (fullInventory.authoritative) { + markHostSessionMirrorHydrated(fullInventory.environmentId) + } else { + settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) + } + return + } + markHostSessionMirrorHydrated(fullInventory.environmentId) + return + } + for (const { environmentId, worktreeId } of settles) { + const fence = fenceByEnvironment.get(environmentId) + if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } + } +} + +export function hostSessionMirrorSettleForPatchlessFrame( + decision: WebSessionTabsSnapshotDecision, + environmentId: string, + worktreeId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettle | null { + if (!decision.settlesHostMirror) { + return null + } + const fence = captureHostSessionMirrorSettleFence(environmentId, expected) + return () => { + if (hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts new file mode 100644 index 00000000000..2fe1f45ab0c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/publisher-identity-fences.ts @@ -0,0 +1,139 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import { + latestReceivedSessionTabsFrameByEnvironment, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRuntimeHistoryByEnvironment, + type RetiredValueHistory, + type SessionTabsPublicationEpochHistory, + type SessionTabsRuntimeHistory +} from './state' + +const SESSION_TABS_RETIRED_EPOCH_LIMIT = 8 +const SESSION_TABS_RETIRED_RUNTIME_ID_LIMIT = 8 + +export function hasRetiredValue(history: RetiredValueHistory | undefined, value: string): boolean { + return history?.retired.includes(value) ?? false +} + +export function noteRetiredValue( + history: RetiredValueHistory | undefined, + value: string, + retiredLimit: number +): RetiredValueHistory { + if (!history) { + return { current: value, retired: [] } + } + if (history.current === value) { + return history + } + if (history.current && !history.retired.includes(history.current)) { + history.retired.push(history.current) + if (history.retired.length > retiredLimit) { + history.retired.splice(0, history.retired.length - retiredLimit) + } + } + history.current = value + return history +} + +function normalizeSessionTabsRuntimeId(runtimeId: unknown): string | undefined { + if (typeof runtimeId !== 'string') { + return undefined + } + const trimmed = runtimeId.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export function getSessionTabsRuntimeIdFromResponse( + response: RuntimeRpcResponse +): string | undefined { + return response.ok ? normalizeSessionTabsRuntimeId(response._meta?.runtimeId) : undefined +} + +export function recordReceivedWebSessionTabsEnvironmentFrame( + environmentId: string, + receivedFrame: number +): void { + const current = latestReceivedSessionTabsFrameByEnvironment.get(environmentId) ?? 0 + if (receivedFrame > current) { + latestReceivedSessionTabsFrameByEnvironment.set(environmentId, receivedFrame) + } +} + +export function isRetiredSessionTabsRuntimeId(environmentId: string, runtimeId: string): boolean { + return hasRetiredValue(sessionTabsRuntimeHistoryByEnvironment.get(environmentId), runtimeId) +} + +function noteSessionTabsRuntimeId( + environmentId: string, + runtimeId: string +): SessionTabsRuntimeHistory { + const history = noteRetiredValue( + sessionTabsRuntimeHistoryByEnvironment.get(environmentId), + runtimeId, + SESSION_TABS_RETIRED_RUNTIME_ID_LIMIT + ) + sessionTabsRuntimeHistoryByEnvironment.set(environmentId, history) + return history +} + +export function isCurrentSessionTabsRuntimeId(environmentId: string, runtimeId: string): boolean { + const history = sessionTabsRuntimeHistoryByEnvironment.get(environmentId) + return history === undefined || history.current === runtimeId +} + +export function isCurrentSessionTabsRuntimeFrame( + environmentId: string, + runtimeId?: string +): boolean { + return ( + runtimeId === undefined || + (!isRetiredSessionTabsRuntimeId(environmentId, runtimeId) && + isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) + ) +} + +/** Returns false for a runtime identity already superseded on this environment. */ +export function acceptSessionTabsRuntimeId( + environmentId: string, + runtimeId: string, + receivedFrame?: number +): boolean { + const history = sessionTabsRuntimeHistoryByEnvironment.get(environmentId) + const latestReceivedFrame = latestReceivedSessionTabsFrameByEnvironment.get(environmentId) ?? 0 + // A late bootstrap response may carry the predecessor process id. Do not + // let that older frame retire the runtime that already published newer data. + if ( + receivedFrame !== undefined && + receivedFrame < latestReceivedFrame && + history !== undefined && + history.current !== runtimeId + ) { + return false + } + if (isRetiredSessionTabsRuntimeId(environmentId, runtimeId)) { + return false + } + noteSessionTabsRuntimeId(environmentId, runtimeId) + return true +} + +export function isRetiredSessionTabsPublicationEpoch( + key: string, + publicationEpoch: string +): boolean { + return hasRetiredValue(sessionTabsPublicationEpochHistoryByWorktree.get(key), publicationEpoch) +} + +export function noteSessionTabsPublicationEpoch( + key: string, + publicationEpoch: string +): SessionTabsPublicationEpochHistory { + const history = noteRetiredValue( + sessionTabsPublicationEpochHistoryByWorktree.get(key), + publicationEpoch, + SESSION_TABS_RETIRED_EPOCH_LIMIT + ) + sessionTabsPublicationEpochHistoryByWorktree.set(key, history) + return history +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts new file mode 100644 index 00000000000..575bb8bfee8 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts @@ -0,0 +1,130 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { + WebSessionTabsBatchContext, + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState +} from './state' +import { applyWebSessionTabsSnapshotWithContext } from './apply-snapshot' +import { + decideWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot, + type WebSessionTabsSnapshotDecision +} from './tracking-decisions' + +export function applyWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now(), + options?: WebSessionTabsSnapshotApplyOptions +): WebSessionTabsSyncState | Partial { + return applyWebSessionTabsSnapshotWithContext( + state, + snapshot, + environmentId, + now, + undefined, + options + ) +} + +export function applyWebSessionTabsSnapshots( + state: WebSessionTabsSyncState, + snapshots: readonly RuntimeMobileSessionTabsResult[], + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + const nextState = { ...state } + const batchContext: WebSessionTabsBatchContext = { + agentPaneKeysByTabId: null, + changedRecords: new Set(), + openFilesIndex: null + } + let mergedPatch: Partial = {} + for (const snapshot of snapshots) { + const patch = applyWebSessionTabsSnapshotWithContext( + nextState, + snapshot, + environmentId, + now, + batchContext + ) + if (patch === nextState) { + continue + } + mergedPatch = { ...mergedPatch, ...patch } + Object.assign(nextState, patch) + } + const mutableMergedPatch = mergedPatch as Record + const mutableNextState = nextState as unknown as Record + for (const recordKey of batchContext.changedRecords) { + mutableMergedPatch[recordKey] = mutableNextState[recordKey] + } + return Object.keys(mergedPatch).length === 0 ? state : mergedPatch +} + +export function applyFreshWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + return shouldApplyWebSessionTabsSnapshot(snapshot, environmentId) + ? applyWebSessionTabsSnapshot(state, snapshot, environmentId, now) + : state +} + +export function applyFreshWebSessionTabsSnapshots( + state: WebSessionTabsSyncState, + snapshots: readonly RuntimeMobileSessionTabsResult[], + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + const fresh = snapshots.filter((snapshot) => + shouldApplyWebSessionTabsSnapshot(snapshot, environmentId) + ) + return fresh.length === 0 ? state : applyWebSessionTabsSnapshots(state, fresh, environmentId, now) +} + +export type WebSessionTabsSnapshotOperation = { + environmentId: string + snapshot: RuntimeMobileSessionTabsResult + runtimeId?: string +} + +export type DecidedWebSessionTabsSnapshotOperation = WebSessionTabsSnapshotOperation & { + decision: WebSessionTabsSnapshotDecision +} + +export function decideWebSessionTabsSnapshotOperations( + operations: readonly WebSessionTabsSnapshotOperation[] +): DecidedWebSessionTabsSnapshotOperation[] { + return operations.map((operation) => ({ + ...operation, + decision: decideWebSessionTabsSnapshot( + operation.snapshot, + operation.environmentId, + operation.runtimeId + ) + })) +} + +export function applyWebSessionTabsSnapshotOperations( + state: WebSessionTabsSyncState, + operations: readonly DecidedWebSessionTabsSnapshotOperation[] +): WebSessionTabsSyncState | Partial { + let nextState = state + let mergedPatch: Partial = {} + for (const { environmentId, snapshot, decision } of operations) { + if (!decision.apply) { + continue + } + const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId) + if (patch === nextState) { + continue + } + mergedPatch = { ...mergedPatch, ...patch } + nextState = { ...nextState, ...patch } + } + return Object.keys(mergedPatch).length === 0 ? state : mergedPatch +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts new file mode 100644 index 00000000000..f382ffad900 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -0,0 +1,155 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import type { + WebSessionTabsBatchContext, + WebSessionTabsBatchRecordKey, + WebSessionTabsSyncState +} from './state' + +export function sameStringArray(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((value, index) => value === b[index]) +} + +export function sameAgentStateHistory( + a: AgentStatusEntry['stateHistory'], + b: AgentStatusEntry['stateHistory'] +): boolean { + if (a.length !== b.length) { + return false + } + return a.every( + (entry, index) => + entry.state === b[index]?.state && + entry.prompt === b[index]?.prompt && + entry.startedAt === b[index]?.startedAt && + entry.interrupted === b[index]?.interrupted + ) +} + +export function agentStatusEntryEqual( + a: AgentStatusEntry | undefined, + b: AgentStatusEntry +): boolean { + if (!a) { + return false + } + return ( + a.state === b.state && + a.workingMode === b.workingMode && + a.prompt === b.prompt && + a.updatedAt === b.updatedAt && + a.stateStartedAt === b.stateStartedAt && + a.agentType === b.agentType && + a.paneKey === b.paneKey && + a.worktreeId === b.worktreeId && + a.tabId === b.tabId && + a.terminalTitle === b.terminalTitle && + a.toolName === b.toolName && + a.toolInput === b.toolInput && + a.interactivePrompt === b.interactivePrompt && + a.lastAssistantMessage === b.lastAssistantMessage && + a.lastAssistantMessageIsToolOutput === b.lastAssistantMessageIsToolOutput && + a.interrupted === b.interrupted && + a.promptInteractionKey === b.promptInteractionKey && + a.restoredUnconfirmed === b.restoredUnconfirmed && + agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && + sameAgentStateHistory(a.stateHistory, b.stateHistory) + ) +} + +export function isAgentStatusFresh( + entry: Pick, + now: number +): boolean { + return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS +} + +export function isMirroredCommandCodeTurnBump( + existing: AgentStatusEntry | undefined, + entry: AgentStatusEntry +): boolean { + return ( + existing?.agentType === 'command-code' && + entry.agentType === 'command-code' && + existing.state === 'working' && + entry.state === 'working' && + entry.stateStartedAt > existing.stateStartedAt + ) +} + +export function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] { + if (!recent || recent.length === 0) { + return [] + } + const valid = new Set(tabOrder) + const seen = new Set() + const reversed: string[] = [] + for (let i = recent.length - 1; i >= 0; i -= 1) { + const id = recent[i] + if (!valid.has(id) || seen.has(id)) { + continue + } + seen.add(id) + reversed.push(id) + } + return reversed.toReversed() +} + +export function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] { + const base = recent ?? [] + if (base.length > 0 && base.at(-1) === tabId) { + return base + } + return [...base.filter((id) => id !== tabId), tabId] +} + +export function writableWebSessionTabsRecord( + state: WebSessionTabsSyncState, + recordKey: K, + batchContext?: WebSessionTabsBatchContext +): NonNullable { + const record = (state[recordKey] ?? {}) as NonNullable + if (!batchContext) { + return { ...record } as NonNullable + } + // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. + if (batchContext.changedRecords.has(recordKey)) { + return record + } + const next = { ...record } as NonNullable + const mutableState = state as unknown as Record< + WebSessionTabsBatchRecordKey, + Record + > + mutableState[recordKey] = next as Record + batchContext.changedRecords.add(recordKey) + return next +} + +export function withWorktreeEntry( + state: WebSessionTabsSyncState, + recordKey: WebSessionTabsBatchRecordKey, + key: string, + value: T | null, + equal: (a: T | undefined, b: T | null) => boolean, + batchContext?: WebSessionTabsBatchContext, + deleteNull = true +): Record { + const record = (state[recordKey] ?? {}) as Record + if (equal(record[key], value)) { + return record + } + const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record + if (value === null && deleteNull) { + delete next[key] + } else { + next[key] = value as T + } + return next +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts new file mode 100644 index 00000000000..41054c25f07 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts @@ -0,0 +1,154 @@ +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { OpenFile } from '../../store/slices/editor' +import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' +import { sameStringArray } from './state-equality-core' + +export function openFileEqual(a: OpenFile, b: OpenFile): boolean { + return ( + a.id === b.id && + a.filePath === b.filePath && + a.relativePath === b.relativePath && + a.worktreeId === b.worktreeId && + a.language === b.language && + a.isDirty === b.isDirty && + a.runtimeEnvironmentId === b.runtimeEnvironmentId && + a.markdownPreviewSourceFileId === b.markdownPreviewSourceFileId && + a.markdownPreviewAnchor === b.markdownPreviewAnchor && + a.isPreview === b.isPreview && + a.isUntitled === b.isUntitled && + a.deleteUntouchedOnClose === b.deleteUntouchedOnClose && + a.externalMutation === b.externalMutation && + a.mirroredFromRuntimeSession === b.mirroredFromRuntimeSession && + a.mode === b.mode + ) +} + +export function sameOpenFiles(a: readonly OpenFile[], b: readonly OpenFile[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((file, index) => openFileEqual(file, b[index]!)) +} + +/** This worktree's open files — the only scope a snapshot reconciles, so a batch can + * answer from here instead of walking every open file in the app. */ +export function webSessionOpenFilesForWorktree( + state: WebSessionTabsSyncState, + worktreeId: string, + batchContext?: WebSessionTabsBatchContext +): readonly OpenFile[] { + if (!batchContext) { + return state.openFiles.filter((file) => file.worktreeId === worktreeId) + } + let index = batchContext.openFilesIndex + if (!index || index.source !== state.openFiles) { + const byWorktree = new Map() + for (const file of state.openFiles) { + const bucket = byWorktree.get(file.worktreeId) ?? [] + bucket.push(file) + byWorktree.set(file.worktreeId, bucket) + } + index = { source: state.openFiles, byWorktree } + batchContext.openFilesIndex = index + } + return index.byWorktree.get(worktreeId) ?? [] +} + +/** Retargets the index at the array a snapshot just produced, re-bucketing only the + * worktree that changed. Rebuilding it wholesale would cost the entire array again on + * every snapshot, which is the cost this index exists to avoid. */ +export function advanceWebSessionOpenFilesIndex( + batchContext: WebSessionTabsBatchContext | undefined, + nextOpenFiles: readonly OpenFile[], + worktreeId: string +): void { + const index = batchContext?.openFilesIndex + if (!index || index.source === nextOpenFiles) { + return + } + const bucket: OpenFile[] = [] + for (const file of nextOpenFiles) { + if (file.worktreeId === worktreeId) { + bucket.push(file) + } + } + index.byWorktree.set(worktreeId, bucket) + index.source = nextOpenFiles +} + +/** Mirrors `openFiles.find()` first-wins lookup, which duplicate ids make observable. */ +export function firstOpenFileByIdForWorktree(files: readonly OpenFile[]): Map { + const byId = new Map() + for (const file of files) { + if (!byId.has(file.id)) { + byId.set(file.id, file) + } + } + return byId +} + +export function tabEqual(a: Tab, b: Tab): boolean { + return ( + a.id === b.id && + a.entityId === b.entityId && + a.groupId === b.groupId && + a.worktreeId === b.worktreeId && + a.executionHostId === b.executionHostId && + a.contentType === b.contentType && + a.agentSessionAgent === b.agentSessionAgent && + a.label === b.label && + // Why: the generated label is the visible tab title; ignoring it let the + // equality bail keep a unified tab that disagreed with its terminal tab. + a.generatedLabel === b.generatedLabel && + a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && + a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && + a.aiVaultTitle?.title === b.aiVaultTitle?.title && + a.customLabel === b.customLabel && + a.color === b.color && + a.sortOrder === b.sortOrder && + a.createdAt === b.createdAt && + a.isPreview === b.isPreview && + a.isPinned === b.isPinned + ) +} + +export function sameUnifiedTabs(a: readonly Tab[] | undefined, b: readonly Tab[] | null): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((tab, index) => tabEqual(tab, right[index]!)) +} + +export function groupEqual(a: TabGroup, b: TabGroup): boolean { + return ( + a.id === b.id && + a.worktreeId === b.worktreeId && + a.activeTabId === b.activeTabId && + sameStringArray(a.tabOrder, b.tabOrder) && + sameStringArray(a.recentTabIds ?? [], b.recentTabIds ?? []) + ) +} + +export function sameGroups( + a: readonly TabGroup[] | undefined, + b: readonly TabGroup[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((group, index) => groupEqual(group, right[index]!)) +} + +export function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] { + if (tab.contentType === 'agent-session') { + return 'agent-session' + } + if (tab.contentType === 'browser' || tab.contentType === 'terminal') { + return tab.contentType + } + return 'editor' +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts new file mode 100644 index 00000000000..929c1d661ce --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts @@ -0,0 +1,142 @@ +import type { + BrowserCertificateFailure, + BrowserPage, + BrowserWorkspace +} from '../../../../shared/browser-workspace-types' +import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import { sameStringArray } from './state-equality-core' + +export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { + return ( + a.id === b.id && + a.ptyId === b.ptyId && + a.worktreeId === b.worktreeId && + a.title === b.title && + a.defaultTitle === b.defaultTitle && + a.quickCommandLabel === b.quickCommandLabel && + a.startupCwd === b.startupCwd && + a.generatedTitle === b.generatedTitle && + a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && + a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && + a.aiVaultTitle?.title === b.aiVaultTitle?.title && + a.customTitle === b.customTitle && + a.color === b.color && + a.sortOrder === b.sortOrder && + a.createdAt === b.createdAt && + a.generation === b.generation && + a.shellOverride === b.shellOverride && + a.launchAgent === b.launchAgent && + a.pendingActivationSpawn === b.pendingActivationSpawn + ) +} + +export function sameTerminalTabs( + a: readonly TerminalTab[] | undefined, + b: readonly TerminalTab[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((tab, index) => terminalTabEqual(tab, right[index]!)) +} + +export function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { + return ( + a.id === b.id && + a.workspaceId === b.workspaceId && + a.worktreeId === b.worktreeId && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.faviconUrl === b.faviconUrl && + a.canGoBack === b.canGoBack && + a.canGoForward === b.canGoForward && + a.loadError?.code === b.loadError?.code && + a.loadError?.description === b.loadError?.description && + a.loadError?.validatedUrl === b.loadError?.validatedUrl && + a.createdAt === b.createdAt && + a.browserRuntimeEnvironmentId === b.browserRuntimeEnvironmentId && + a.viewportPresetId === b.viewportPresetId + ) +} + +export function optionalRuntimeBrowserPlacementsEqual( + left: RuntimeBrowserPlacement | undefined, + right: RuntimeBrowserPlacement | undefined +): boolean { + return left === undefined + ? right === undefined + : right !== undefined && sameRuntimeBrowserPlacement(left, right) +} + +export function browserCertificateFailureEqual( + a: BrowserCertificateFailure | null | undefined, + b: BrowserCertificateFailure | null | undefined +): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + return Boolean( + left && + right && + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) +} + +export function sameBrowserPages( + a: readonly BrowserPage[] | undefined, + b: readonly BrowserPage[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((page, index) => browserPageEqual(page, right[index]!)) +} + +export function browserWorkspaceEqual(a: BrowserWorkspace, b: BrowserWorkspace): boolean { + return ( + a.id === b.id && + a.worktreeId === b.worktreeId && + a.label === b.label && + a.sessionProfileId === b.sessionProfileId && + a.activePageId === b.activePageId && + sameStringArray(a.pageIds ?? [], b.pageIds ?? []) && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.faviconUrl === b.faviconUrl && + a.canGoBack === b.canGoBack && + a.canGoForward === b.canGoForward && + a.loadError?.code === b.loadError?.code && + a.loadError?.description === b.loadError?.description && + a.loadError?.validatedUrl === b.loadError?.validatedUrl && + a.createdAt === b.createdAt + ) +} + +export function sameBrowserTabs( + a: readonly BrowserWorkspace[] | undefined, + b: readonly BrowserWorkspace[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((tab, index) => browserWorkspaceEqual(tab, right[index]!)) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts new file mode 100644 index 00000000000..bb693160e4a --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -0,0 +1,239 @@ +import type { AppState } from '../../store' +import type { + RuntimeMobileSessionAgentTab, + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionFileTab, + RuntimeMobileSessionMarkdownTab, + RuntimeMobileSessionTabGroup, + RuntimeMobileSessionTabsRemovedResult, + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTerminalClientTab +} from '../../../../shared/runtime-types' +import type { + BrowserCertificateFailure, + BrowserPage, + BrowserWorkspace +} from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { OpenFile } from '../../store/slices/editor' +import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' + +export const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' +export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 +export const VISIBILITY_INVENTORY_REMOVAL_EPOCH = 'visibility-inventory-removal' +export const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 + +export type SessionTabsStreamEvent = + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean } + | { type: 'end' } + +export type SessionTabsListAllResult = { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean +} + +export type SnapshotFreshness = { + publicationEpoch: string + snapshotVersion: number +} + +export type ReceivedSessionTabsSnapshot = SnapshotFreshness & { + receivedFrame: number + runtimeId?: string +} + +/** + * Runtime ids identify a host process, unlike publication epochs which may be + * minted by several publishers. Retain a bounded predecessor set so a frame + * queued by a restarted host cannot be mistaken for a fresh publication. + */ +export type RetiredValueHistory = { + current: string | null + retired: string[] +} + +export type SessionTabsRuntimeHistory = RetiredValueHistory + +/** + * A host restart changes the publication epoch, but frames from the previous + * epoch can still be queued on a sibling subscription. Keep a small history + * of epochs that have already been superseded so those delayed frames cannot + * roll the mirror back after the replacement epoch is accepted. + */ +export type SessionTabsPublicationEpochHistory = RetiredValueHistory +export type SessionTabsRecoveryState = { pendingCount: number } +export type SessionTabsRemovalFence = { + receivedFrame: number + recoveryState: SessionTabsRecoveryState + pendingCount: number +} + +export type WebSessionTabsSnapshotApplyOptions = { + contentScope?: 'all' | 'agent-session' + preserveLocalLayout?: boolean + terminalPtyMode?: 'local' | 'remote' +} + +export type TrackedWebSessionTabsWorktree = { + worktree: string + freshness: SnapshotFreshness +} + +export type VisibilityResumeOmission = { + baseline: SnapshotFreshness + environmentId: string + inventoryReceivedFrame: number + superseded: boolean + visibilityGeneration: number +} + +export const latestSessionTabsSnapshotByWorktree = new Map() +export const replayableSessionTabsSnapshotByWorktree = new Map() +export const latestReceivedSessionTabsSnapshotByWorktree = new Map< + string, + ReceivedSessionTabsSnapshot +>() +export const sessionTabsRuntimeHistoryByEnvironment = new Map() +export const sessionTabsPublicationEpochHistoryByWorktree = new Map< + string, + SessionTabsPublicationEpochHistory +>() +export const latestReceivedSessionTabsFrameByEnvironment = new Map() +export const latestReceivedSessionTabsInventoryFrameByEnvironment = new Map() +export const latestSessionTabsRemovalFenceByWorktree = new Map() +export const sessionTabsRecoveryStateByWorktree = new Map() +export const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() +export const sessionTabsEnvironmentsByWorktree = new Map>() +export const sessionTabsTrackingGenerationByEnvironment = new Map() +export const lastHostTerminalTabCountByWorktree = new Map() +export const hostSessionTabIdByLocalKey = new Map() +export const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< + string, + Map> +>() +export const hostWorkingClientBoundaryByPaneKey = new Map< + string, + { + hostStateStartedAt: number + hostPrompt: string + clientStateStartedAt: number + stamped: boolean + } +>() + +export let receivedSessionTabsFrameSequence = 0 +export function nextReceivedSessionTabsFrame(): number { + receivedSessionTabsFrameSequence += 1 + return receivedSessionTabsFrameSequence +} +export function resetReceivedSessionTabsFrameSequence(): void { + receivedSessionTabsFrameSequence = 0 +} + +export type TerminalSurface = RuntimeMobileSessionTerminalClientTab +export type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } +export type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } +export type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab + +export type MirroredAgentTab = { hostTabId: string; unifiedTab: Tab } +export type MirroredTerminalTab = { + tab: TerminalTab + hostTabId: string + ptyIds: string[] + layout: TerminalLayoutSnapshot + retainedSurfaceByPrunedLeafId?: ReadonlyMap +} +export type MirroredBrowserTab = { + workspace: BrowserWorkspace + page: BrowserPage + certificateFailure: BrowserCertificateFailure | null + remotePageId: string + placement?: RuntimeBrowserPlacement + unifiedTab: Tab + hostTabId: string + clientGroupId?: string +} +export type MirroredEditorTab = { file: OpenFile; unifiedTab: Tab; hostTabId: string } + +export type WebSessionTabsSyncState = Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + | 'activeWorktreeId' + | 'agentStatusByPaneKey' + | 'agentStatusEpoch' + | 'browserPagesByWorkspace' + | 'browserCertificateFailuresByPageId' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + | 'sortEpoch' +> & + Partial< + Pick< + AppState, + | 'acknowledgedAgentsByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'migrationUnsupportedByPtyId' + | 'paneForegroundAgentByPaneKey' + | 'pendingStartupByTabId' + | 'recentlyClosedAgentStatusTabIds' + | 'recentlyRetiredAgentStatusPaneKeys' + | 'retainedAgentsByPaneKey' + | 'retentionSuppressedPaneKeys' + > + > + +export type WebSessionTabsBatchRecordKey = + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'agentStatusByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'browserCertificateFailuresByPageId' + | 'browserPagesByWorkspace' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'pendingStartupByTabId' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + +export type WebSessionOpenFilesIndex = { + source: readonly OpenFile[] + byWorktree: Map +} +export type WebSessionTabsBatchContext = { + agentPaneKeysByTabId: Map> | null + changedRecords: Set + openFilesIndex: WebSessionOpenFilesIndex | null +} + +export type AgentTab = RuntimeMobileSessionAgentTab +export type TabGroupSnapshot = RuntimeMobileSessionTabGroup +export type RemovedTabsResult = RuntimeMobileSessionTabsRemovedResult diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts new file mode 100644 index 00000000000..db2758d4bbe --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -0,0 +1,173 @@ +import type { AppState } from '../../store' +import { useAppStore } from '../../store' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { pickParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { normalizeTurnCompletedAtField } from '../../../../shared/agent-status-field-normalization' +import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' +import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications' +import { remapHostAgentStatus } from './agent-status-primitives' +import { agentStatusEntryEqual } from './state-equality-core' +import { + HOST_WORKING_CLIENT_BOUNDARY_LIMIT, + hostWorkingClientBoundaryByPaneKey, + type WebSessionTabsSyncState +} from './state' +import { + createHostSessionMirrorSettle, + type HostSessionMirrorPatchVerdict, + type HostSessionMirrorSettle +} from './mirror-settle' + +/** Commit a reconciliation patch and return a receipt for its host evidence. */ +export function applyWebSessionTabsStorePatch( + buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, + hostMirrorVerdict: HostSessionMirrorPatchVerdict, + agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], + allowCompletionNotification = false +): HostSessionMirrorSettle { + let mirroredAgentStatusChanged = false + // Zustand commits before notifying subscribers, so a completed producer has landed even if a subscriber throws. + let patchCommitted = false + const acceptedNotificationStatuses: { + paneKey: string + worktreeId: string + seedOnly?: true + payload: ReturnType & { + stateStartedAt: number + localStateStartedAt?: number + } + }[] = [] + + const runStorePatch = ( + state: AppState + ): WebSessionTabsSyncState | Partial => { + const patch = buildPatch(state) + mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') + if (agentStatusSnapshots) { + const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey + const snapshots = Array.isArray(agentStatusSnapshots) + ? agentStatusSnapshots + : [agentStatusSnapshots] + for (const snapshot of snapshots) { + for (const surface of snapshot.tabs) { + if (surface.type !== 'terminal') { + continue + } + const remapped = remapHostAgentStatus(surface) + const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined + const turnCompletedAt = remapped + ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) + : undefined + if (remapped?.state === 'done' && turnCompletedAt === undefined) { + hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) + } + // Client OSC owns display state; the host hook stream carries background-turn stamps. + const clientOwnedNotification = Boolean( + remapped && + isClientAuthoritativeAgentStatusPane(remapped.paneKey) && + (remapped.state === 'working' || turnCompletedAt !== undefined) + ) + if ( + !remapped || + (!clientOwnedNotification && + (!accepted || + accepted === state.agentStatusByPaneKey[remapped.paneKey] || + !agentStatusEntryEqual(accepted, remapped))) + ) { + continue + } + const notificationStatus = clientOwnedNotification ? remapped : accepted + if (!notificationStatus) { + continue + } + const currentClientStateStartedAt = clientOwnedNotification + ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt + : undefined + let localStateStartedAt = currentClientStateStartedAt + if ( + clientOwnedNotification && + notificationStatus.state === 'working' && + currentClientStateStartedAt !== undefined + ) { + if (turnCompletedAt === undefined) { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + !retainedBoundary || + retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || + retainedBoundary.hostPrompt !== notificationStatus.prompt || + retainedBoundary.stamped + ) { + hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) + hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { + hostStateStartedAt: notificationStatus.stateStartedAt, + hostPrompt: notificationStatus.prompt, + clientStateStartedAt: currentClientStateStartedAt, + stamped: false + }) + if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { + const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value + if (oldestPaneKey !== undefined) { + hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) + } + } + } + } else { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && + retainedBoundary.hostPrompt === notificationStatus.prompt + ) { + localStateStartedAt = retainedBoundary.clientStateStartedAt + retainedBoundary.stamped = true + } + } + } + if (!allowCompletionNotification && notificationStatus.state !== 'working') { + continue + } + acceptedNotificationStatuses.push({ + paneKey: notificationStatus.paneKey, + worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, + ...(!allowCompletionNotification ? { seedOnly: true as const } : {}), + payload: { + ...pickParsedAgentStatusPayload({ + ...notificationStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }), + stateStartedAt: notificationStatus.stateStartedAt, + ...(clientOwnedNotification ? { localStateStartedAt } : {}) + } + }) + } + } + } + patchCommitted = true + return patch + } + + try { + useAppStore.setState(runStorePatch) + } catch (error) { + if (!patchCommitted) { + throw error + } + console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) + } + + const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) + try { + if (mirroredAgentStatusChanged) { + useAppStore.getState().scheduleAgentStatusFreshness() + } + for (const status of acceptedNotificationStatuses) { + observeAgentHookCompletionForNotification(status) + } + } catch (error) { + console.warn('[web-session-tabs-sync] post-patch bookkeeping failed:', error) + } + return settleHostMirror +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts new file mode 100644 index 00000000000..94234af1276 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts @@ -0,0 +1,144 @@ +import type { + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { OpenFile } from '../../store/slices/editor' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { ReadyEditorSurface, MirroredEditorTab } from './state' +import type { WebSessionExistingTabIndex } from '../web-session-existing-tab-index' +import { isReadyEditorTab, localEditorFileId, editorSourceFileId } from './terminal-surfaces' + +export function buildTerminalUnifiedTab( + tab: TerminalTab, + groupId: string, + environmentId: string, + // Why: viewMode is host-tracked but the client's optimistic toggle must win during the echo window; callers pass the reconciled value. + viewMode?: Tab['viewMode'] +): Tab { + return { + id: tab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + executionHostId: toRuntimeExecutionHostId(environmentId), + contentType: 'terminal', + label: tab.title, + ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt, + isPreview: false, + isPinned: tab.isPinned === true, + ...(viewMode ? { viewMode } : {}) + } +} + +export function buildBrowserUnifiedTab( + tab: BrowserWorkspace, + hostTab: RuntimeMobileSessionBrowserTab, + existingUnifiedTab: Tab | null, + groupId: string, + environmentId: string +): Tab { + return { + id: existingUnifiedTab?.id ?? hostTab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + executionHostId: toRuntimeExecutionHostId(environmentId), + contentType: 'browser', + label: tab.title, + customLabel: null, + color: hostTab.color !== undefined ? hostTab.color : (existingUnifiedTab?.color ?? null), + sortOrder: tab.createdAt, + createdAt: tab.createdAt, + isPreview: false, + isPinned: + hostTab.isPinned !== undefined + ? hostTab.isPinned === true + : existingUnifiedTab?.isPinned === true + } +} + +export function buildEditorUnifiedTab( + file: OpenFile, + tab: ReadyEditorSurface, + hostTabId: string, + existingUnifiedTab: Tab | null, + label: string, + groupId: string, + sortOrder: number, + createdAt: number, + environmentId: string +): Tab { + return { + id: hostTabId, + entityId: file.id, + groupId, + worktreeId: file.worktreeId, + executionHostId: toRuntimeExecutionHostId(environmentId), + contentType: 'editor', + label, + customLabel: null, + color: tab.color !== undefined ? tab.color : (existingUnifiedTab?.color ?? null), + sortOrder, + createdAt, + isPreview: false, + isPinned: + tab.isPinned !== undefined ? tab.isPinned === true : existingUnifiedTab?.isPinned === true + } +} + +export function buildMirroredEditorTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + worktreeOpenFileById: ReadonlyMap, + existingTabIndex: WebSessionExistingTabIndex, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + now: number +): MirroredEditorTab[] { + return snapshot.tabs.filter(isReadyEditorTab).map((tab, index) => { + const fileId = localEditorFileId(tab) + const existingFile = worktreeOpenFileById.get(fileId) + const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id) + const sourceFileId = editorSourceFileId(tab) + const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId + const file: OpenFile = { + ...existingFile, + id: fileId, + filePath: tab.filePath, + relativePath: tab.relativePath, + worktreeId: snapshot.worktree, + language: tab.language, + isDirty: tab.isDirty, + runtimeEnvironmentId: environmentId, + mode: tab.type === 'markdown' ? tab.mode : 'edit', + markdownPreviewSourceFileId: sourceFileId, + // Why: marks this tab host-owned so a later snapshot that omits it can cull it; locally opened tabs lack this flag and survive. + mirroredFromRuntimeSession: true + } + return { + file, + hostTabId: tab.id, + unifiedTab: buildEditorUnifiedTab( + file, + tab, + tab.id, + existingUnifiedTab, + tab.title.trim() || tab.relativePath || 'File', + groupId, + sortOffset + index, + existingUnifiedTab?.createdAt ?? now + sortOffset + index, + environmentId + ) + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts new file mode 100644 index 00000000000..dfc3759009d --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -0,0 +1,191 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' +import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' +import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import type { MirroredTerminalTab, TerminalSurface, ReadyTerminalSurface } from './state' +import { chooseRemoteTerminalLayout, isTerminalSurfaceTab } from './terminal-surfaces' + +function pendingBindingBelongsToEnvironment( + ptyId: string, + environmentId: string, + terminalPtyMode: 'local' | 'remote' +): boolean { + const ownerEnvironmentId = getRemoteRuntimePtyEnvironmentId(ptyId) + return terminalPtyMode === 'local' + ? ownerEnvironmentId === null + : ownerEnvironmentId === environmentId +} + +/** Keep a known pane binding while the host briefly publishes its surface as pending. */ +function retainPendingTerminalBindings( + surfaces: readonly TerminalSurface[], + existingLayout: TerminalLayoutSnapshot | undefined, + ptyIdsByLeafId: Record, + environmentId: string, + terminalPtyMode: 'local' | 'remote' +): Record { + const existingBindings = existingLayout?.ptyIdsByLeafId + if (!existingBindings) { + return ptyIdsByLeafId + } + let retained = ptyIdsByLeafId + for (const surface of surfaces) { + if (surface.status !== 'pending-handle' || Object.hasOwn(retained, surface.leafId)) { + continue + } + const priorPtyId = existingBindings[surface.leafId] + if ( + !priorPtyId || + !pendingBindingBelongsToEnvironment(priorPtyId, environmentId, terminalPtyMode) + ) { + continue + } + if (retained === ptyIdsByLeafId) { + retained = { ...ptyIdsByLeafId } + } + retained[surface.leafId] = priorPtyId + } + return retained +} + +/** Constructs mirrored terminal tabs from the mobile session status payload, normalising Pi-compatible agent titles under launch ownership. */ +export function buildMirroredTerminalTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + existingById: ReadonlyMap, + existingLayoutsByTabId: Readonly>, + sortOffset: number, + now: number, + focusTarget?: { parentTabId: string; leafId: string }, + terminalPtyMode: 'local' | 'remote' = 'remote' +): MirroredTerminalTab[] { + const groups = new Map() + for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) { + const group = groups.get(tab.parentTabId) ?? [] + group.push(tab) + groups.set(tab.parentTabId, group) + } + + return [...groups.entries()].map(([parentTabId, surfaces], index) => { + const localTabId = toWebTerminalSurfaceTabId(parentTabId) + const existingLayout = existingLayoutsByTabId[localTabId] + const requestedActiveLeafId = + focusTarget?.parentTabId === parentTabId ? focusTarget.leafId : undefined + const activeSurface = + (requestedActiveLeafId + ? surfaces.find((surface) => surface.leafId === requestedActiveLeafId) + : undefined) ?? + (existingLayout?.activeLeafId + ? surfaces.find((surface) => surface.leafId === existingLayout.activeLeafId) + : undefined) ?? + surfaces.find((surface) => surface.isActive) ?? + surfaces[0]! + const ptyIdForSurface = (handle: string): string => + terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId) + const freshPtyIdsByLeafId = Object.fromEntries( + surfaces + .filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready') + .map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)]) + ) + const ptyIdsByLeafId = retainPendingTerminalBindings( + surfaces, + existingLayout, + freshPtyIdsByLeafId, + environmentId, + terminalPtyMode + ) + const layout = normalizeTerminalLayoutPtyOwnership( + chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) + ).snapshot + const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {}) + const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId) + let retainedSurfaceByPrunedLeafId: Map | undefined + if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) { + const retainedLeafIdByPtyId = new Map( + layoutPtyEntries.map(([leafId, ptyId]) => [ptyId, leafId]) + ) + const surfaceByLeafId = new Map(surfaces.map((surface) => [surface.leafId, surface])) + retainedSurfaceByPrunedLeafId = new Map() + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { + const retainedLeafId = retainedLeafIdByPtyId.get(ptyId) + if (retainedLeafId && retainedLeafId !== leafId) { + const retainedSurface = surfaceByLeafId.get(retainedLeafId) + if (retainedSurface) { + retainedSurfaceByPrunedLeafId.set(leafId, retainedSurface) + } + } + } + } + const launchAgent = + activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent, + hookAgent: activeSurface.agentStatus?.agentType, + siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus + ?.agentType + }) + const existing = + existingById.get(localTabId) ?? + existingById.get(parentTabId) ?? + surfaces + .map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id))) + .find((tab): tab is TerminalTab => Boolean(tab)) + // Why: a headless host publishes the literal "Terminal" while an idle pane + // has no live PTY. Keep the client's known title until a ready surface reports one. + const hostTitle = activeSurface.title.trim() || surfaces[0]?.title.trim() || '' + const hostTitleIsPlaceholder = + hostTitle === '' || (activeSurface.status === 'pending-handle' && hostTitle === 'Terminal') + const retainedTitle = existing?.title?.trim() || existing?.defaultTitle?.trim() || '' + const title = normalizeCompatibleAgentTitleForOwner( + (hostTitleIsPlaceholder ? retainedTitle || hostTitle : hostTitle) || 'Terminal', + ownerRecord?.agent, + { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } + ) + const quickCommandLabel = + activeSurface.quickCommandLabel?.trim() || + surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || + existing?.quickCommandLabel?.trim() + // Why: startupCwd is host-owned launch metadata; once the host omits it, don't resurrect stale subdirectory intent. + const startupCwd = + activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd + // Why: color/pin echo back through host snapshots, so prefer the client's own record and fall back to host only without a prior tab (avoids echo-window reverts). + const hostColorSurface = surfaces.find((surface) => surface.color != null) + const color = existing ? (existing.color ?? null) : (hostColorSurface?.color ?? null) + const isPinned = existing + ? existing.isPinned === true + : surfaces.some((surface) => surface.isPinned) + // Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab. + const hostViewModeSurface = surfaces.find((surface) => surface.viewMode) + const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode + return { + tab: { + id: localTabId, + ptyId: ptyIdsByLeafId[activeSurface.leafId] ?? null, + worktreeId: snapshot.worktree, + title, + defaultTitle: existing?.defaultTitle ?? title, + // Why: the host transport carries no generated title, so rebuilding the tab + // without this dropped the client's agent-prompt label on every snapshot. + ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), + ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), + ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(startupCwd ? { startupCwd } : {}), + customTitle: existing?.customTitle ?? null, + color, + isPinned, + ...(viewMode ? { viewMode } : {}), + sortOrder: sortOffset + index, + createdAt: existing?.createdAt ?? now + index, + // Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent. + ...(launchAgent ? { launchAgent } : {}) + }, + hostTabId: parentTabId, + ptyIds, + layout, + ...(retainedSurfaceByPrunedLeafId ? { retainedSurfaceByPrunedLeafId } : {}) + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts new file mode 100644 index 00000000000..7480306ed5b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -0,0 +1,197 @@ +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionAgentTab +} from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' +import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' +import { getRemoteRuntimePtyEnvironmentId } from '../runtime-terminal-stream' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + WEB_TERMINAL_SURFACE_TAB_PREFIX, + toWebTerminalSurfaceTabId +} from '../web-runtime-session' +import type { + ReadyBrowserSurface, + ReadyEditorSurface, + ReadyTerminalSurface, + TerminalSurface, + MirroredAgentTab +} from './state' +import type { Tab } from '../../../../shared/tab-types' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' + +export function isReadyTerminalTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is ReadyTerminalSurface { + return tab.type === 'terminal' && tab.status === 'ready' && tab.terminal.trim().length > 0 +} + +export function isTerminalSurfaceTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is TerminalSurface { + return tab.type === 'terminal' +} + +export function isReadyBrowserTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is ReadyBrowserSurface { + return tab.type === 'browser' && typeof tab.browserPageId === 'string' && tab.browserPageId !== '' +} + +export function isReadyEditorTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is ReadyEditorSurface { + return tab.type === 'markdown' || tab.type === 'file' +} + +export function isAgentSessionTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is RuntimeMobileSessionAgentTab { + return tab.type === 'agent-session' +} + +export function buildMirroredAgentTabs( + snapshot: RuntimeMobileSessionTabsResult, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + currentUnifiedTabs: readonly Tab[], + now: number +): MirroredAgentTab[] { + return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => { + const localId = structuredAgentSessionTabId(tab.sessionId) + const existing = currentUnifiedTabs.find( + (candidate) => candidate.contentType === 'agent-session' && candidate.id === localId + ) + return { + hostTabId: tab.id, + unifiedTab: { + id: localId, + entityId: tab.sessionId, + groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId, + worktreeId: snapshot.worktree, + contentType: 'agent-session', + agentSessionAgent: tab.agent, + label: tab.title.trim() || 'Codex Chat', + customLabel: null, + color: tab.color !== undefined ? tab.color : (existing?.color ?? null), + sortOrder: sortOffset + index, + createdAt: existing?.createdAt ?? now + sortOffset + index, + isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true + } + } + }) +} + +export function localEditorFileId(tab: ReadyEditorSurface): string { + if (tab.type === 'markdown' && tab.mode === 'markdown-preview') { + return `markdown-preview::${tab.sourceFilePath}` + } + return tab.filePath +} + +export function editorSourceFileId(tab: ReadyEditorSurface): string | undefined { + return tab.type === 'markdown' && tab.mode === 'markdown-preview' ? tab.sourceFilePath : undefined +} + +export function isRuntimeTerminalTabForEnvironment( + tab: TerminalTab, + environmentId: string +): boolean { + if (!tab.ptyId) { + return false + } + return getRemoteRuntimePtyEnvironmentId(tab.ptyId) === environmentId +} + +export function isMirroredTerminalSurfaceId(tabId: string): boolean { + return ( + tabId.startsWith(WEB_TERMINAL_SURFACE_TAB_PREFIX) || + tabId.includes(HOST_TERMINAL_SURFACE_SEPARATOR) + ) +} + +export function chooseRemoteTerminalLayout( + surfaces: readonly TerminalSurface[], + ptyIdsByLeafId: Record, + existingLayout?: TerminalLayoutSnapshot, + requestedActiveLeafId?: string +): TerminalLayoutSnapshot { + const leafIds = surfaces.map((surface) => surface.leafId) + const knownLeafIds = new Set(leafIds) + const parentLayoutSource = surfaces.find((surface) => surface.parentLayout) + const parentLayout = parentLayoutSource?.parentLayout + ? sanitizeTerminalLayoutPaneTitlesForLabels(parentLayoutSource.parentLayout, [ + parentLayoutSource.title + ]) + : undefined + const activeLeafId = + (requestedActiveLeafId && knownLeafIds.has(requestedActiveLeafId) + ? requestedActiveLeafId + : null) ?? + // Why: host title/status snapshots may still mark an agent pane active after this client selected a different split pane. + (existingLayout?.activeLeafId && knownLeafIds.has(existingLayout.activeLeafId) + ? existingLayout.activeLeafId + : null) ?? + (parentLayout?.activeLeafId && knownLeafIds.has(parentLayout.activeLeafId) + ? parentLayout.activeLeafId + : null) ?? + surfaces.find((surface) => surface.isActive)?.leafId ?? + leafIds[0] ?? + null + const expandedLeafId = + requestedActiveLeafId && + (Boolean(existingLayout?.expandedLeafId) || Boolean(parentLayout?.expandedLeafId)) + ? requestedActiveLeafId + : parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId) + ? parentLayout.expandedLeafId + : null + return { + // Why: host parentLayout is authoritative for split direction; else keep the prior client tree, then degenerate — never re-guess a direction. + root: resolveTerminalLayoutRoot({ + authoritativeRoot: parentLayout?.root, + existingRoot: existingLayout?.root, + leafIds, + onSynthesize: (leafCount) => + console.warn( + `[web-session-tabs-sync] synthesized layout for ${leafCount} leaves; no authoritative or prior tree covered them` + ) + }), + activeLeafId, + expandedLeafId, + ptyIdsByLeafId, + // Why: surface.title is the tab/PTY label, not a pane title; restoring it as one renders a fake title bar. Only host layout titles are real pane titles. + ...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {}) + } +} + +export function shouldReplaceTerminalTab( + tab: TerminalTab, + environmentId: string, + nextRemotePtyIds: ReadonlySet, + nextMirroredTerminalIds: ReadonlySet, + exactProvisionalHandoffs: ReadonlySet +): boolean { + if (exactProvisionalHandoffs.has(tab.id)) { + // Why: agent kind is not session identity; retire only the provisional tab + // whose request or structured response identifies this exact host surface. + return true + } + if (isMirroredTerminalSurfaceId(tab.id)) { + // Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts. + return true + } + if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) { + return true + } + if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) { + return false + } + // Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot. + return ( + tab.ptyId !== null && + (nextRemotePtyIds.has(tab.ptyId) || + nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id))) + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts new file mode 100644 index 00000000000..764459fb3cb --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -0,0 +1,177 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + VISIBILITY_INVENTORY_REMOVAL_EPOCH, + type SessionTabsStreamEvent +} from './state' +import { + acceptSessionTabsRuntimeId, + isRetiredSessionTabsPublicationEpoch, + noteSessionTabsPublicationEpoch +} from './publisher-identity-fences' +import { + sessionTabsFreshnessKey, + rememberHostTerminalTabCount, + trackWebSessionTabsWorktree, + recordAcceptedWebSessionTabsEnvironment +} from './tracking' +import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle' +import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' + +/** A frame's fate, paired with whether that fate is host evidence for the worktree. */ +export type WebSessionTabsSnapshotDecision = { + readonly apply: boolean + readonly settlesHostMirror: boolean +} + +const WEB_SESSION_TABS_FRAME_APPLIED = { + apply: true, + settlesHostMirror: true +} as const satisfies WebSessionTabsSnapshotDecision +export const WEB_SESSION_TABS_FRAME_OUTRANKED = { + apply: false, + settlesHostMirror: true +} as const satisfies WebSessionTabsSnapshotDecision +const WEB_SESSION_TABS_FRAME_UNMIRRORED = { + apply: false, + settlesHostMirror: false +} as const satisfies WebSessionTabsSnapshotDecision + +function isHostMirroredWorktree(worktreeId: string): boolean { + return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID +} + +export function shouldApplyWebSessionTabsSnapshot( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + runtimeId?: string +): boolean { + return decideWebSessionTabsSnapshot(snapshot, environmentId, runtimeId).apply +} + +export function decideWebSessionTabsSnapshot( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + runtimeId?: string +): WebSessionTabsSnapshotDecision { + if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId)) { + return WEB_SESSION_TABS_FRAME_OUTRANKED + } + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + if ((snapshot as { removed?: unknown }).removed === true) { + // Why: removed worktrees can stop publishing, so clean up their tracking now instead of waiting for a replacement snapshot that may never arrive. + // Retain the removal epoch transition before dropping the live freshness + // record; delayed sibling frames from the predecessor stay fenced. + // Inventory omissions use a client-only sentinel epoch; recording that + // sentinel would retire the host epoch and reject the next live frame. + if (snapshot.publicationEpoch !== VISIBILITY_INVENTORY_REMOVAL_EPOCH) { + noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) + } + clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) + queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + return WEB_SESSION_TABS_FRAME_APPLIED + } + if (!isHostMirroredWorktree(snapshot.worktree)) { + // Why: a remote empty same-id snapshot would delete the user's local floating tabs. + return WEB_SESSION_TABS_FRAME_UNMIRRORED + } + const current = latestSessionTabsSnapshotByWorktree.get(key) + if (isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch)) { + return WEB_SESSION_TABS_FRAME_OUTRANKED + } + const replayable = replayableSessionTabsSnapshotByWorktree.get(key) + const isExactCurrentReplay = Boolean( + current && + replayable && + current.publicationEpoch === replayable.publicationEpoch && + current.snapshotVersion === replayable.snapshotVersion && + snapshot.publicationEpoch === replayable.publicationEpoch && + snapshot.snapshotVersion === replayable.snapshotVersion + ) + // Why: reject stale snapshots only within an epoch; host restarts create a new epoch. + if ( + current && + current.publicationEpoch === snapshot.publicationEpoch && + snapshot.snapshotVersion <= current.snapshotVersion && + !isExactCurrentReplay + ) { + return WEB_SESSION_TABS_FRAME_OUTRANKED + } + rememberHostTerminalTabCount(environmentId, snapshot) + replayableSessionTabsSnapshotByWorktree.delete(key) + noteSessionTabsPublicationEpoch(key, snapshot.publicationEpoch) + latestSessionTabsSnapshotByWorktree.set(key, { + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion + }) + trackWebSessionTabsWorktree(environmentId, snapshot.worktree) + recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) + // Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write. + queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + return WEB_SESSION_TABS_FRAME_APPLIED +} + +export function shouldBootstrapInitialWebRuntimeTerminal(args: { + event: SessionTabsStreamEvent + activeWorktreeId: string + requestedInitialTerminal: boolean + snapshotIsFresh: boolean + localTerminalCount: number +}): boolean { + return ( + args.snapshotIsFresh && + args.event.type === 'snapshot' && + args.event.tabs.length === 0 && + args.localTerminalCount === 0 && + !args.requestedInitialTerminal && + args.activeWorktreeId === args.event.worktree + ) +} + +export function shouldRespawnWebRuntimeTerminalAfterWake(args: { + event: SessionTabsStreamEvent + activeWorktreeId: string + requestedRespawnAfterWake: boolean + snapshotIsFresh: boolean + localTerminalCount: number + hasLiveLocalPty: boolean + skipWakeRespawn?: boolean +}): boolean { + if ( + !args.snapshotIsFresh || + args.requestedRespawnAfterWake || + args.skipWakeRespawn === true || + args.localTerminalCount === 0 || + args.hasLiveLocalPty || + (args.event.type !== 'snapshot' && args.event.type !== 'updated') + ) { + return false + } + if (args.activeWorktreeId !== args.event.worktree) { + return false + } + const hostTerminalTabCount = args.event.tabs.filter((tab) => tab.type === 'terminal').length + return hostTerminalTabCount === 0 +} + +export function shouldSyncRuntimeSessionTabs(args: { + activeWorktreeId?: string | null + activeWorktreeRuntimeEnvironmentId?: string | null + workspaceSessionReady: boolean +}): boolean { + const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim() + if (!environmentId || !args.workspaceSessionReady) { + return false + } + return Boolean(args.activeWorktreeId?.trim()) +} + +export function shouldSyncAllRuntimeSessionTabs(args: { + activeRuntimeEnvironmentId: string | null | undefined + workspaceSessionReady: boolean +}): boolean { + const environmentId = args.activeRuntimeEnvironmentId?.trim() + return Boolean(environmentId && args.workspaceSessionReady) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts new file mode 100644 index 00000000000..e352a439c9c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -0,0 +1,218 @@ +import { + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + latestReceivedSessionTabsFrameByEnvironment, + latestReceivedSessionTabsInventoryFrameByEnvironment, + latestSessionTabsRemovalFenceByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRuntimeHistoryByEnvironment, + sessionTabsRecoveryStateByWorktree, + trackedSessionTabsWorktreeIdsByEnvironment, + sessionTabsEnvironmentsByWorktree, + sessionTabsTrackingGenerationByEnvironment, + lastHostTerminalTabCountByWorktree, + hostSessionTabIdByLocalKey, + hostSessionTabMappingKeysByEnvironmentAndWorktree, + hostWorkingClientBoundaryByPaneKey, + resetReceivedSessionTabsFrameSequence +} from './state' +import { + clearWebRuntimeWakeTerminalRespawnForWorktree, + clearAllWebRuntimeWakeTerminalRespawn +} from '../web-runtime-wake-terminal-respawn' +import { clearWebSessionReorderIntentsForWorktree } from '../web-session-reorder-intent' +import { clearWebSessionCloseIntentsForWorktree } from '../web-session-close-intent' +import { + clearWebAgentSessionHandoffsForWorktree, + clearWebAgentSessionHandoffsForEnvironment +} from '../web-agent-session-handoff' +import { + clearWebSessionBrowserPlacementsForWorktree, + clearWebSessionBrowserPlacementsForEnvironment, + resetWebSessionBrowserPlacementsForTests +} from '../web-session-browser-placement' +import { + clearWebSessionTerminalPlacementsForWorktree, + clearWebSessionTerminalPlacementsForEnvironment +} from '../web-session-terminal-placement' +import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration' +import { clearHostSessionTabIdMappings } from './tracking-mappings' +import { + sessionTabsFreshnessKey, + untrackWebSessionTabsWorktree, + removeWebSessionTabsEnvironment +} from './tracking' + +export function getLastKnownHostTerminalTabCount( + environmentId: string, + worktreeId: string +): number { + return ( + lastHostTerminalTabCountByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) ?? 0 + ) +} + +export function getLatestWebSessionTabsPublicationEpoch( + environmentId: string, + worktreeId: string +): string | null { + return ( + latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) + ?.publicationEpoch ?? null + ) +} + +// Why: a replay may repeat the current epoch/version; permit only that exact +// identity once so an older concurrent frame cannot bypass monotonic ordering. +export function acceptReplayedWebSessionTabsSnapshot( + environmentId: string, + worktreeId: string +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const current = latestSessionTabsSnapshotByWorktree.get(key) + if (current) { + replayableSessionTabsSnapshotByWorktree.set(key, current) + } +} +export function resetWebSessionTabsSnapshotFreshnessForTests(): void { + latestSessionTabsSnapshotByWorktree.clear() + replayableSessionTabsSnapshotByWorktree.clear() + latestReceivedSessionTabsSnapshotByWorktree.clear() + sessionTabsRuntimeHistoryByEnvironment.clear() + sessionTabsPublicationEpochHistoryByWorktree.clear() + latestReceivedSessionTabsFrameByEnvironment.clear() + latestReceivedSessionTabsInventoryFrameByEnvironment.clear() + latestSessionTabsRemovalFenceByWorktree.clear() + sessionTabsRecoveryStateByWorktree.clear() + trackedSessionTabsWorktreeIdsByEnvironment.clear() + sessionTabsEnvironmentsByWorktree.clear() + resetReceivedSessionTabsFrameSequence() + lastHostTerminalTabCountByWorktree.clear() + hostSessionTabIdByLocalKey.clear() + hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() + hostWorkingClientBoundaryByPaneKey.clear() + resetWebSessionBrowserPlacementsForTests() +} + +export function _getWebSessionTabsTrackingCountsForTest(): { + freshness: number + hostMappings: number + hostMappingWorktrees: number +} { + let hostMappingWorktrees = 0 + for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { + hostMappingWorktrees += mappingKeysByWorktree.size + } + return { + freshness: latestSessionTabsSnapshotByWorktree.size, + hostMappings: hostSessionTabIdByLocalKey.size, + // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. + hostMappingWorktrees + } +} + +export function _getWebSessionTabsRecoveryTrackingCountsForTest(): { + pendingRecoveries: number + removalFrames: number +} { + return { + pendingRecoveries: sessionTabsRecoveryStateByWorktree.size, + removalFrames: latestSessionTabsRemovalFenceByWorktree.size + } +} + +export function clearWebSessionTabsTrackingForWorktree( + environmentId: string, + worktreeId: string +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + latestSessionTabsSnapshotByWorktree.delete(key) + replayableSessionTabsSnapshotByWorktree.delete(key) + latestReceivedSessionTabsSnapshotByWorktree.delete(key) + // Keep the bounded epoch history as a tombstone fence. A sibling stream can + // still deliver an old frame after this removal has cleared the live view. + untrackWebSessionTabsWorktree(environmentId, worktreeId) + removeWebSessionTabsEnvironment(environmentId, worktreeId) + lastHostTerminalTabCountByWorktree.delete(key) + clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) + clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) + clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) + clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) + clearHostSessionTabIdMappings(environmentId, worktreeId) + clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId) + clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId) +} + +export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { + const trimmedEnvironmentId = environmentId.trim() + if (!trimmedEnvironmentId) { + return + } + const keyPrefix = `${trimmedEnvironmentId}:` + sessionTabsTrackingGenerationByEnvironment.set( + trimmedEnvironmentId, + (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 + ) + for (const key of latestSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + latestSessionTabsSnapshotByWorktree.delete(key) + } + } + for (const key of replayableSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + replayableSessionTabsSnapshotByWorktree.delete(key) + } + } + for (const key of latestReceivedSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + latestReceivedSessionTabsSnapshotByWorktree.delete(key) + } + } + sessionTabsRuntimeHistoryByEnvironment.delete(trimmedEnvironmentId) + for (const key of sessionTabsPublicationEpochHistoryByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + sessionTabsPublicationEpochHistoryByWorktree.delete(key) + } + } + latestReceivedSessionTabsFrameByEnvironment.delete(trimmedEnvironmentId) + latestReceivedSessionTabsInventoryFrameByEnvironment.delete(trimmedEnvironmentId) + for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + latestSessionTabsRemovalFenceByWorktree.delete(key) + } + } + for (const key of sessionTabsRecoveryStateByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + sessionTabsRecoveryStateByWorktree.delete(key) + } + } + trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) + for (const worktreeId of sessionTabsEnvironmentsByWorktree.keys()) { + removeWebSessionTabsEnvironment(trimmedEnvironmentId, worktreeId) + } + for (const key of lastHostTerminalTabCountByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + lastHostTerminalTabCountByWorktree.delete(key) + } + } + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) + if (mappingKeysByWorktree) { + for (const mappingKeys of mappingKeysByWorktree.values()) { + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + } + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) + } + clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) + clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId) + clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId) + clearHostSessionMirrorHydration(trimmedEnvironmentId) + clearAllWebRuntimeWakeTerminalRespawn() +} + +export function getWebSessionTabsTrackingGeneration(environmentId: string): number { + return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts new file mode 100644 index 00000000000..bbd59cede61 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts @@ -0,0 +1,58 @@ +import type { WebSessionTabsSyncState } from './state' +import { + hostSessionTabIdByLocalKey, + hostSessionTabMappingKeysByEnvironmentAndWorktree +} from './state' +import { resolveWebAgentSessionHandoff } from '../web-agent-session-handoff' + +function hostSessionTabMappingKey(args: { + environmentId: string + worktreeId: string + tabId: string +}): string { + return `${args.environmentId}:${args.worktreeId}:${args.tabId}` +} + +export function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { + const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) + const mappingKeys = mappingKeysByWorktree?.get(worktreeId) + if (!mappingKeys) { + return + } + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + mappingKeysByWorktree?.delete(worktreeId) + if (mappingKeysByWorktree?.size === 0) { + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) + } +} + +export function setHostSessionTabIdMapping( + args: { environmentId: string; worktreeId: string; tabId: string }, + hostTabId: string +): void { + const mappingKey = hostSessionTabMappingKey(args) + hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() + const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() + mappingKeys.add(mappingKey) + mappingKeysByWorktree.set(args.worktreeId, mappingKeys) + hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) +} + +export function resolveHostSessionTabIdForWebSessionTab( + _state: WebSessionTabsSyncState, + args: { environmentId: string; worktreeId: string; tabId: string } +): string | null { + return ( + hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? + // Why: structured create returns canonical identity before its confirming snapshot; an immediate user close must already target that host tab. + resolveWebAgentSessionHandoff({ + environmentId: args.environmentId, + worktreeId: args.worktreeId, + provisionalTabId: args.tabId + }) + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts new file mode 100644 index 00000000000..9b9c8cfb03f --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -0,0 +1,327 @@ +import type { + RuntimeMobileSessionTabsRemovedResult, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import { VISIBILITY_INVENTORY_REMOVAL_EPOCH, type SessionTabsListAllResult } from './state' +import { + latestReceivedSessionTabsInventoryFrameByEnvironment, + latestReceivedSessionTabsSnapshotByWorktree, + latestSessionTabsRemovalFenceByWorktree, + latestSessionTabsSnapshotByWorktree, + lastHostTerminalTabCountByWorktree, + sessionTabsEnvironmentsByWorktree, + sessionTabsPublicationEpochHistoryByWorktree, + sessionTabsRecoveryStateByWorktree, + trackedSessionTabsWorktreeIdsByEnvironment, + type SnapshotFreshness, + type TrackedWebSessionTabsWorktree +} from './state' +import { nextReceivedSessionTabsFrame } from './state' +import { + acceptSessionTabsRuntimeId, + isCurrentSessionTabsRuntimeId, + isRetiredSessionTabsPublicationEpoch, + isRetiredSessionTabsRuntimeId, + noteSessionTabsPublicationEpoch, + recordReceivedWebSessionTabsEnvironmentFrame +} from './publisher-identity-fences' + +export function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { + return ( + Boolean(value) && + typeof value === 'object' && + Array.isArray((value as { snapshots?: unknown }).snapshots) + ) +} + +export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string { + return `${environmentId}:${worktreeId}` +} + +export function advancesSessionTabsFreshness( + snapshot: RuntimeMobileSessionTabsResult, + baseline: SnapshotFreshness +): boolean { + return ( + snapshot.publicationEpoch !== baseline.publicationEpoch || + snapshot.snapshotVersion > baseline.snapshotVersion + ) +} + +export function getTrackedWebSessionTabsWorktrees( + environmentId: string +): TrackedWebSessionTabsWorktree[] { + return [...(trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? [])].flatMap( + (worktree) => { + const key = sessionTabsFreshnessKey(environmentId, worktree) + const freshness = latestSessionTabsSnapshotByWorktree.get(key) + return freshness + ? [ + { + worktree, + freshness + } + ] + : [] + } + ) +} + +export function trackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { + const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? new Set() + worktrees.add(worktreeId) + trackedSessionTabsWorktreeIdsByEnvironment.set(environmentId, worktrees) +} + +export function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { + const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) + if (!worktrees) { + return + } + worktrees.delete(worktreeId) + if (worktrees.size === 0) { + trackedSessionTabsWorktreeIdsByEnvironment.delete(environmentId) + } +} + +export function recordReceivedWebSessionTabsSnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number | undefined = undefined, + runtimeId?: string, + source: 'stream' | 'bootstrap' = 'stream' +): number { + const frame = receivedFrame ?? nextReceivedSessionTabsFrame() + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const current = latestReceivedSessionTabsSnapshotByWorktree.get(key) + // A bootstrap listAll reserves its frame before the request starts. If a + // stream frame for this worktree arrived meanwhile, the late list is stale + // evidence and must not advance epoch history. + if (source === 'bootstrap' && current && frame < current.receivedFrame) { + return frame + } + if (runtimeId && !acceptSessionTabsRuntimeId(environmentId, runtimeId, frame)) { + return frame + } + recordReceivedWebSessionTabsEnvironmentFrame(environmentId, frame) + const publicationEpoch = snapshot.publicationEpoch + const history = sessionTabsPublicationEpochHistoryByWorktree.get(key) + const isRetired = history?.retired.includes(publicationEpoch) ?? false + if (isRetired) { + return frame + } + if (!history || history.current !== publicationEpoch) { + noteSessionTabsPublicationEpoch(key, publicationEpoch) + } + // Stream delivery order is the freshest evidence even when a host's version + // counter briefly moves backwards (for example across a visibility resume). + // Bootstrap listAll responses retain version/epoch ordering so a late + // response cannot replace a stream frame received after the request began. + if ( + source === 'stream' || + !current || + current.publicationEpoch !== publicationEpoch || + snapshot.snapshotVersion > current.snapshotVersion || + (snapshot.snapshotVersion === current.snapshotVersion && current.receivedFrame <= frame) + ) { + latestReceivedSessionTabsSnapshotByWorktree.set(key, { + receivedFrame: frame, + publicationEpoch, + snapshotVersion: snapshot.snapshotVersion, + ...(runtimeId ? { runtimeId } : {}) + }) + if ((snapshot as { removed?: unknown }).removed === true) { + recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, frame) + } + } + return frame +} + +export function recordReceivedWebSessionTabsInventory(environmentId: string): number { + const receivedFrame = nextReceivedSessionTabsFrame() + recordReceivedWebSessionTabsEnvironmentFrame(environmentId, receivedFrame) + latestReceivedSessionTabsInventoryFrameByEnvironment.set(environmentId, receivedFrame) + return receivedFrame +} + +export function beginWebSessionTabsSnapshotRecovery( + environmentId: string, + worktreeId: string, + receivedFrame: number +): () => void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 } + recoveryState.pendingCount += 1 + sessionTabsRecoveryStateByWorktree.set(key, recoveryState) + let settled = false + return () => { + if (settled) { + return + } + settled = true + recoveryState.pendingCount -= 1 + if ( + recoveryState.pendingCount === 0 && + sessionTabsRecoveryStateByWorktree.get(key) === recoveryState + ) { + sessionTabsRecoveryStateByWorktree.delete(key) + } + const removalFence = latestSessionTabsRemovalFenceByWorktree.get(key) + if ( + removalFence?.recoveryState === recoveryState && + receivedFrame < removalFence.receivedFrame + ) { + removalFence.pendingCount -= 1 + if (removalFence.pendingCount === 0) { + latestSessionTabsRemovalFenceByWorktree.delete(key) + } + } + } +} + +export function recordReceivedWebSessionTabsRemoval( + environmentId: string, + worktreeId: string, + receivedFrame: number +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const current = latestSessionTabsRemovalFenceByWorktree.get(key) + if (current && current.receivedFrame >= receivedFrame) { + return + } + const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) + if (!recoveryState || recoveryState.pendingCount === 0) { + latestSessionTabsRemovalFenceByWorktree.delete(key) + return + } + latestSessionTabsRemovalFenceByWorktree.set(key, { + receivedFrame, + recoveryState, + pendingCount: recoveryState.pendingCount + }) + // An inventory omission/removal is a new visibility boundary. A later live + // frame may legitimately restart its version counter, while recoveries + // queued before this boundary are fenced by receivedFrame above. + latestReceivedSessionTabsSnapshotByWorktree.delete(key) +} + +export function shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string +): boolean { + if ( + runtimeId && + (isRetiredSessionTabsRuntimeId(environmentId, runtimeId) || + !isCurrentSessionTabsRuntimeId(environmentId, runtimeId)) + ) { + return false + } + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + if (isRetiredSessionTabsPublicationEpoch(key, snapshot.publicationEpoch)) { + return false + } + const removalFrame = latestSessionTabsRemovalFenceByWorktree.get(key)?.receivedFrame + if (removalFrame !== undefined && receivedFrame < removalFrame) { + return false + } + const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key) + if (!latest || latest.receivedFrame === receivedFrame) { + return latest !== undefined + } + if (latest.publicationEpoch !== snapshot.publicationEpoch) { + return receivedFrame > latest.receivedFrame + } + return snapshot.snapshotVersion >= latest.snapshotVersion +} + +export function isTrackedWebSessionTabsOmissionCurrent( + environmentId: string, + trackedWorktree: TrackedWebSessionTabsWorktree +): boolean { + const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree) + const current = latestSessionTabsSnapshotByWorktree.get(key) + return ( + current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch && + current.snapshotVersion === trackedWorktree.freshness.snapshotVersion + ) +} + +export function recordAcceptedWebSessionTabsEnvironment( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): void { + const environments = new Set(sessionTabsEnvironmentsByWorktree.get(snapshot.worktree) ?? []) + if (snapshot.tabs.length > 0) { + environments.add(environmentId) + } else { + environments.delete(environmentId) + } + if (environments.size > 0) { + sessionTabsEnvironmentsByWorktree.set(snapshot.worktree, environments) + } else { + sessionTabsEnvironmentsByWorktree.delete(snapshot.worktree) + } +} + +export function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: string): void { + const environments = new Set(sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) + environments.delete(environmentId) + if (environments.size > 0) { + sessionTabsEnvironmentsByWorktree.set(worktreeId, environments) + } else { + sessionTabsEnvironmentsByWorktree.delete(worktreeId) + } +} + +// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a +// visibility fact, never evidence that the host closed anything. +export function isWebSessionTabsWorktreeRemovalFrame( + snapshot: RuntimeMobileSessionTabsResult +): boolean { + return ( + (snapshot as { removed?: unknown }).removed === true || + snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH + ) +} + +// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever +// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived. +export function buildMissingWebSessionTabsRemovals( + environmentId: string, + trackedWorktrees: readonly TrackedWebSessionTabsWorktree[], + publishedWorktrees: ReadonlySet +): { + trackedWorktree: TrackedWebSessionTabsWorktree + snapshot: RuntimeMobileSessionTabsRemovedResult +}[] { + return trackedWorktrees + .filter( + (trackedWorktree) => + !publishedWorktrees.has(trackedWorktree.worktree) && + isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree) + ) + .map((trackedWorktree) => ({ + trackedWorktree, + snapshot: { + worktree: trackedWorktree.worktree, + publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + })) +} + +export function rememberHostTerminalTabCount( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): void { + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const terminalCount = snapshot.tabs.filter((tab) => tab.type === 'terminal').length + lastHostTerminalTabCountByWorktree.set(key, terminalCount) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts new file mode 100644 index 00000000000..bb3da4fa56f --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts @@ -0,0 +1,114 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useAppStore } from '../../store' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '../../lib/worktree-runtime-owner' +import { useRuntimeSessionMirrorEnvironmentKey } from '../use-runtime-session-mirror-environment-key' +import { sessionTabsFreshnessKey } from './tracking' +import { clearWebSessionTabsTrackingForEnvironment } from './tracking-lifecycle' +import { + installGlobalSessionTabsSubscriptions, + type GlobalSubscriptionRefs +} from './global-session-subscription' +import { installActiveSessionTabsSubscription } from './active-session-subscription' + +/** Mount the paired-runtime tab mirrors and the selected-worktree stream. */ +export function useWebSessionTabsSync(): void { + const activeRuntimeEnvironmentIdRef = useRef(null) + const activeRuntimeWorktreeKeyRef = useRef(null) + const visibilityResumeOmissionsRef = useRef< + GlobalSubscriptionRefs['visibilityResumeOmissions']['current'] + >(new Map()) + const ownerRevisionsRef = useRef(new Map()) + const visibilitySnapshotReceiptRef = useRef( + () => {} + ) + const visibilitySnapshotApplyRef = useRef( + () => true + ) + const visibilitySnapshotRecordRef = useRef( + () => {} + ) + + const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) + const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) + const runtimeSessionMirrorEnvironmentKey = useRuntimeSessionMirrorEnvironmentKey() + const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => + getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + ) + // Keep this subscription dependency: a runtime reconnect can retain the same environment id + // while replacing its runtime instance, which must restart the scoped stream. + const activeWorktreeRuntimeId = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + return environmentId + ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.status?.runtimeId ?? null) + : null + }) + const activeWorktreeRuntimeConnectionGeneration = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + return environmentId + ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.connectionGeneration ?? 0) + : 0 + }) + const activeWorktreeRuntimePairingRevision = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + const environment = state.runtimeEnvironments.find( + (candidate) => candidate.id === environmentId + ) + return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined + }) + + useLayoutEffect(() => { + const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() || null + activeRuntimeEnvironmentIdRef.current = environmentId + activeRuntimeWorktreeKeyRef.current = + environmentId && activeWorktreeId + ? sessionTabsFreshnessKey(environmentId, activeWorktreeId) + : null + }, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId]) + + useEffect( + () => () => { + for (const environmentId of ownerRevisionsRef.current.keys()) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } + ownerRevisionsRef.current.clear() + visibilityResumeOmissionsRef.current.clear() + }, + [] + ) + + useEffect(() => { + return installGlobalSessionTabsSubscriptions({ + runtimeSessionMirrorEnvironmentKey, + workspaceSessionReady, + refs: { + activeRuntimeEnvironmentId: activeRuntimeEnvironmentIdRef, + activeRuntimeWorktreeKey: activeRuntimeWorktreeKeyRef, + visibilityResumeOmissions: visibilityResumeOmissionsRef, + snapshotReceipt: visibilitySnapshotReceiptRef, + snapshotApply: visibilitySnapshotApplyRef, + snapshotRecord: visibilitySnapshotRecordRef, + ownerRevisions: ownerRevisionsRef + } + }) + }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) + + useEffect(() => { + return installActiveSessionTabsSubscription({ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimePairingRevision, + workspaceSessionReady, + visibilitySnapshotReceipt: visibilitySnapshotReceiptRef, + visibilitySnapshotApply: visibilitySnapshotApplyRef, + visibilitySnapshotRecord: visibilitySnapshotRecordRef + }) + }, [ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimePairingRevision, + activeWorktreeRuntimeId, + workspaceSessionReady + ]) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts new file mode 100644 index 00000000000..d5358d89484 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts @@ -0,0 +1,64 @@ +import { getTrackedWebSessionTabsWorktrees } from './tracking' +import { getWebSessionTabsTrackingGeneration } from './tracking-lifecycle' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import type { VisibilityResumeOmission } from './state' +import type { + MirroredRuntimeEnvironment, + VisibilityResumeBatch, + VisibilityResumeEnvironment +} from './visibility-resume-types' + +/** Build the inventory/replay state for the subscriptions restarted by a resume. */ +export function buildVisibilityResumeBatch(args: { + visibilityGeneration: number + restartingSpecIndexes: readonly number[] + environmentIdBySubscriptionSpec: readonly string[] + environments: readonly MirroredRuntimeEnvironment[] + omissions: Map + activeRuntimeWorktreeKey: () => string | null +}): VisibilityResumeBatch | null { + const activeKey = args.activeRuntimeWorktreeKey() + for (const [key, omission] of args.omissions) { + if (key !== activeKey || omission.visibilityGeneration < args.visibilityGeneration - 1) { + args.omissions.delete(key) + } + } + const resumed = new Map() + const trackedWorktreeIds = new Set() + for (const index of args.restartingSpecIndexes) { + const environmentId = args.environmentIdBySubscriptionSpec[index] + if (!environmentId) { + continue + } + const trackedWorktrees = getTrackedWebSessionTabsWorktrees(environmentId) + if (trackedWorktrees.length === 0) { + continue + } + for (const { worktree } of trackedWorktrees) { + trackedWorktreeIds.add(worktree) + } + const descriptor = args.environments.find((entry) => entry.environmentId === environmentId) + resumed.set(environmentId, { + trackedWorktrees, + inventoryReceived: false, + latestInventoryReceivedFrame: 0, + pendingMissingWorktrees: new Set(), + expectedEnvironmentConnectionGeneration: + descriptor?.expectedEnvironmentConnectionGeneration ?? + getRuntimeEnvironmentConnectionGeneration(environmentId), + expectedEnvironmentPairingRevision: descriptor?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) + }) + } + return resumed.size + ? { + visibilityGeneration: args.visibilityGeneration, + environments: resumed, + pendingInventoryCount: resumed.size, + pendingMissingByWorktree: new Map(), + deferredRepairWorktrees: new Set(), + trackedWorktreeIds, + reapplyableSnapshotsByKey: new Map() + } + : null +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts new file mode 100644 index 00000000000..7d02f2b38af --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts @@ -0,0 +1,309 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + advancesSessionTabsFreshness, + shouldApplyRecoveredWebSessionTabsSnapshot, + sessionTabsFreshnessKey +} from './tracking' +import { latestSessionTabsSnapshotByWorktree, sessionTabsEnvironmentsByWorktree } from './state' +import { + isCurrentSessionTabsRuntimeId, + isRetiredSessionTabsRuntimeId +} from './publisher-identity-fences' +import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle' +import type { WebSessionTabsSnapshotOperation } from './snapshot-api' +import { applyVisibilityResumeRepairs } from './visibility-resume-repair' +import { + recordVisibilityResumeInventory, + recordVisibilityResumeInventoryReceipt +} from './visibility-resume-inventory' +import type { VisibilityResumeOmission } from './state' +import type { + MirroredRuntimeEnvironment, + VisibilityResumeBatch, + VisibilityResumeMissing +} from './visibility-resume-types' +import { buildVisibilityResumeBatch } from './visibility-resume-batch' + +export type VisibilityResumeCoordinatorOptions = { + environments: readonly MirroredRuntimeEnvironment[] + environmentIdBySubscriptionSpec: readonly string[] + omissions: Map + activeRuntimeWorktreeKey: () => string | null +} + +/** Coordinates inventory omissions and cross-environment replay during visibility resumes. */ +export class VisibilityResumeCoordinator { + private batch: VisibilityResumeBatch | null = null + + constructor(private readonly options: VisibilityResumeCoordinatorOptions) {} + + recordSnapshotReceipt( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ): void { + if (runtimeId && this.isSupersededRuntimeId(environmentId, runtimeId)) { + return + } + const omission = this.options.omissions.get( + sessionTabsFreshnessKey(environmentId, snapshot.worktree) + ) + if ( + omission && + receivedFrame > omission.inventoryReceivedFrame && + ((snapshot as { removed?: unknown }).removed === true || + advancesSessionTabsFreshness(snapshot, omission.baseline)) + ) { + omission.superseded = true + if (this.batch?.pendingMissingByWorktree.has(snapshot.worktree)) { + this.reconcileWorktrees([snapshot.worktree]) + } + } + } + + shouldApplySnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ): boolean { + if (runtimeId && this.isSupersededRuntimeId(environmentId, runtimeId)) { + return false + } + const omission = this.options.omissions.get( + sessionTabsFreshnessKey(environmentId, snapshot.worktree) + ) + if (!omission) { + return true + } + if (receivedFrame < omission.inventoryReceivedFrame) { + return false + } + return ( + (snapshot as { removed?: unknown }).removed === true || + advancesSessionTabsFreshness(snapshot, omission.baseline) + ) + } + + /** A frame from a retired or non-current host process cannot steer this resume. */ + private isSupersededRuntimeId(environmentId: string, runtimeId: string): boolean { + return ( + isRetiredSessionTabsRuntimeId(environmentId, runtimeId) || + !isCurrentSessionTabsRuntimeId(environmentId, runtimeId) + ) + } + + private missingCurrent(missing: VisibilityResumeMissing): boolean { + const omission = this.options.omissions.get( + sessionTabsFreshnessKey(missing.environmentId, missing.snapshot.worktree) + ) + return ( + omission?.inventoryReceivedFrame === missing.inventoryReceivedFrame && !omission.superseded + ) + } + + private replayableEntry( + batch: VisibilityResumeBatch, + environmentId: string, + worktreeId: string + ): { + snapshot: RuntimeMobileSessionTabsResult + receivedFrame: number + runtimeId?: string + } | null { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const entry = batch.reapplyableSnapshotsByKey.get(key) + const freshness = entry ? latestSessionTabsSnapshotByWorktree.get(key) : undefined + if ( + !entry || + !freshness || + freshness.publicationEpoch !== entry.snapshot.publicationEpoch || + freshness.snapshotVersion !== entry.snapshot.snapshotVersion || + !shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + entry.snapshot, + entry.receivedFrame, + entry.runtimeId + ) + ) { + return null + } + return entry + } + + private replayableSnapshot( + batch: VisibilityResumeBatch, + environmentId: string, + worktreeId: string + ): RuntimeMobileSessionTabsResult | null { + return this.replayableEntry(batch, environmentId, worktreeId)?.snapshot ?? null + } + + private finishIfIdle(batch: VisibilityResumeBatch): void { + if ( + batch.pendingInventoryCount === 0 && + batch.pendingMissingByWorktree.size === 0 && + this.batch === batch + ) { + this.batch = null + } + } + + private reconcileWorktrees(worktreeIds: Iterable): void { + const batch = this.batch + if (!batch) { + return + } + const operations: WebSessionTabsSnapshotOperation[] = [] + for (const worktreeId of new Set(worktreeIds)) { + const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) + if (!pendingMissing) { + batch.deferredRepairWorktrees.delete(worktreeId) + continue + } + for (const [environmentId, missing] of pendingMissing) { + if (this.missingCurrent(missing)) { + continue + } + pendingMissing.delete(environmentId) + batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) + } + if (pendingMissing.size === 0) { + batch.pendingMissingByWorktree.delete(worktreeId) + batch.deferredRepairWorktrees.delete(worktreeId) + continue + } + const missingEnvironmentIds = new Set(pendingMissing.keys()) + const survivingSnapshots: { + environmentId: string + snapshot: RuntimeMobileSessionTabsResult + runtimeId?: string + }[] = [] + let canRepair = true + for (const environmentId of sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) { + if (missingEnvironmentIds.has(environmentId)) { + continue + } + const entry = this.replayableEntry(batch, environmentId, worktreeId) + if (!entry) { + canRepair = false + break + } + survivingSnapshots.push({ + environmentId, + snapshot: entry.snapshot, + ...(entry.runtimeId ? { runtimeId: entry.runtimeId } : {}) + }) + } + if (!canRepair) { + batch.deferredRepairWorktrees.add(worktreeId) + continue + } + for (const missing of pendingMissing.values()) { + operations.push({ + environmentId: missing.environmentId, + snapshot: missing.snapshot, + ...(missing.runtimeId ? { runtimeId: missing.runtimeId } : {}) + }) + } + for (const { environmentId, snapshot, runtimeId } of survivingSnapshots) { + acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) + operations.push({ environmentId, snapshot, ...(runtimeId ? { runtimeId } : {}) }) + } + for (const environmentId of pendingMissing.keys()) { + batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) + } + batch.pendingMissingByWorktree.delete(worktreeId) + batch.deferredRepairWorktrees.delete(worktreeId) + } + applyVisibilityResumeRepairs(batch, operations) + this.finishIfIdle(batch) + } + + recordSnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number, + runtimeId?: string + ): void { + if (runtimeId && this.isSupersededRuntimeId(environmentId, runtimeId)) { + return + } + const batch = this.batch + if (!batch || !batch.trackedWorktreeIds.has(snapshot.worktree)) { + return + } + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const existing = this.replayableSnapshot(batch, environmentId, snapshot.worktree) + const freshness = latestSessionTabsSnapshotByWorktree.get(key) + const crossHost = + (sessionTabsEnvironmentsByWorktree.get(snapshot.worktree)?.size ?? 0) > 1 || + batch.deferredRepairWorktrees.has(snapshot.worktree) + if ( + (snapshot as { removed?: unknown }).removed === true || + snapshot.tabs.length === 0 || + !crossHost || + freshness?.publicationEpoch !== snapshot.publicationEpoch || + freshness.snapshotVersion !== snapshot.snapshotVersion || + !shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, snapshot, receivedFrame, runtimeId) + ) { + if (!existing) { + batch.reapplyableSnapshotsByKey.delete(key) + } + } else { + batch.reapplyableSnapshotsByKey.set(key, { snapshot, receivedFrame, runtimeId }) + } + if (batch.pendingMissingByWorktree.has(snapshot.worktree)) { + this.reconcileWorktrees([snapshot.worktree]) + } + } + + recordInventory( + environmentId: string, + visibilityGeneration: number, + inventoryReceivedFrame: number, + missingWorktrees: readonly VisibilityResumeMissing[] + ): void { + recordVisibilityResumeInventory({ + batch: this.batch, + environmentId, + visibilityGeneration, + inventoryReceivedFrame, + missingWorktrees, + reconcileWorktrees: (worktreeIds) => this.reconcileWorktrees(worktreeIds) + }) + } + + recordInventoryReceipt( + environmentId: string, + visibilityGeneration: number, + inventoryReceivedFrame: number, + snapshots: readonly RuntimeMobileSessionTabsResult[], + runtimeId?: string + ): VisibilityResumeMissing[] { + return recordVisibilityResumeInventoryReceipt({ + batch: this.batch, + omissions: this.options.omissions, + environmentId, + visibilityGeneration, + inventoryReceivedFrame, + snapshots, + runtimeId + }) + } + + beginVisibilityResume( + visibilityGeneration: number, + restartingSpecIndexes: readonly number[] + ): void { + this.batch = buildVisibilityResumeBatch({ + visibilityGeneration, + restartingSpecIndexes, + environmentIdBySubscriptionSpec: this.options.environmentIdBySubscriptionSpec, + environments: this.options.environments, + omissions: this.options.omissions, + activeRuntimeWorktreeKey: this.options.activeRuntimeWorktreeKey + }) + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts new file mode 100644 index 00000000000..3398595b01b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts @@ -0,0 +1,116 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + buildMissingWebSessionTabsRemovals, + recordReceivedWebSessionTabsRemoval, + sessionTabsFreshnessKey +} from './tracking' +import { isCurrentSessionTabsRuntimeFrame } from './publisher-identity-fences' +import type { VisibilityResumeOmission } from './state' +import type { VisibilityResumeBatch, VisibilityResumeMissing } from './visibility-resume-types' + +export function recordVisibilityResumeInventoryReceipt(args: { + batch: VisibilityResumeBatch | null + omissions: Map + environmentId: string + visibilityGeneration: number + inventoryReceivedFrame: number + snapshots: readonly RuntimeMobileSessionTabsResult[] + runtimeId?: string +}): VisibilityResumeMissing[] { + const { + batch, + omissions, + environmentId, + visibilityGeneration, + inventoryReceivedFrame, + snapshots, + runtimeId + } = args + if (!isCurrentSessionTabsRuntimeFrame(environmentId, runtimeId)) { + return [] + } + for (const snapshot of snapshots) { + omissions.delete(sessionTabsFreshnessKey(environmentId, snapshot.worktree)) + } + if (!batch || batch.visibilityGeneration !== visibilityGeneration) { + return [] + } + const environment = batch.environments.get(environmentId) + if (!environment) { + return [] + } + environment.latestInventoryReceivedFrame = Math.max( + environment.latestInventoryReceivedFrame, + inventoryReceivedFrame + ) + if (environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { + return [] + } + const publishedWorktrees = new Set(snapshots.map((snapshot) => snapshot.worktree)) + return buildMissingWebSessionTabsRemovals( + environmentId, + environment.trackedWorktrees, + publishedWorktrees + ).map((missing) => { + const key = sessionTabsFreshnessKey(environmentId, missing.snapshot.worktree) + omissions.set(key, { + baseline: missing.trackedWorktree.freshness, + environmentId, + inventoryReceivedFrame, + superseded: false, + visibilityGeneration + }) + recordReceivedWebSessionTabsRemoval( + environmentId, + missing.snapshot.worktree, + inventoryReceivedFrame + ) + return { + environmentId, + inventoryReceivedFrame, + ...(runtimeId ? { runtimeId } : {}), + ...missing + } + }) +} + +export function recordVisibilityResumeInventory(args: { + batch: VisibilityResumeBatch | null + environmentId: string + visibilityGeneration: number + inventoryReceivedFrame: number + missingWorktrees: readonly VisibilityResumeMissing[] + reconcileWorktrees: (worktreeIds: Iterable) => void +}): void { + const { batch, environmentId, visibilityGeneration, inventoryReceivedFrame, missingWorktrees } = + args + if (!batch || visibilityGeneration === 0 || batch.visibilityGeneration !== visibilityGeneration) { + return + } + const environment = batch.environments.get(environmentId) + if (!environment || environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { + return + } + const affectedWorktrees = new Set(environment.pendingMissingWorktrees) + for (const worktreeId of environment.pendingMissingWorktrees) { + const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) + pendingMissing?.delete(environmentId) + if (pendingMissing?.size === 0) { + batch.pendingMissingByWorktree.delete(worktreeId) + } + } + environment.pendingMissingWorktrees.clear() + for (const missing of missingWorktrees) { + const worktreeId = missing.snapshot.worktree + const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) ?? new Map() + pendingMissing.set(environmentId, missing) + batch.pendingMissingByWorktree.set(worktreeId, pendingMissing) + environment.pendingMissingWorktrees.add(worktreeId) + affectedWorktrees.add(worktreeId) + } + if (!environment.inventoryReceived) { + environment.inventoryReceived = true + batch.pendingInventoryCount -= 1 + } + args.reconcileWorktrees(affectedWorktrees) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts new file mode 100644 index 00000000000..367342d6ef8 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts @@ -0,0 +1,36 @@ +import { + applyWebSessionTabsSnapshotOperations, + decideWebSessionTabsSnapshotOperations, + type WebSessionTabsSnapshotOperation +} from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import type { VisibilityResumeBatch } from './visibility-resume-types' + +/** Write the tombstone + surviving-host replay a resume repair decided on, in one store patch. */ +export function applyVisibilityResumeRepairs( + batch: VisibilityResumeBatch, + operations: readonly WebSessionTabsSnapshotOperation[] +): void { + if (operations.length === 0) { + return + } + const decided = decideWebSessionTabsSnapshotOperations(operations) + const settle = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshotOperations(state, decided), + { + frames: decided.map(({ environmentId, snapshot, decision }) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision, + expectedEnvironmentConnectionGeneration: + batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision: + batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: + batch.environments.get(environmentId)?.expectedTrackingGeneration + })) + }, + operations.map(({ snapshot }) => snapshot) + ) + settle() +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts new file mode 100644 index 00000000000..11f1aa23eb5 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts @@ -0,0 +1,42 @@ +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsRemovedResult +} from '../../../../shared/runtime-types' +import type { TrackedWebSessionTabsWorktree } from './state' + +export type VisibilityResumeEnvironment = { + trackedWorktrees: readonly TrackedWebSessionTabsWorktree[] + inventoryReceived: boolean + latestInventoryReceivedFrame: number + pendingMissingWorktrees: Set + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number +} + +export type VisibilityResumeMissing = { + environmentId: string + inventoryReceivedFrame: number + trackedWorktree: TrackedWebSessionTabsWorktree + snapshot: RuntimeMobileSessionTabsRemovedResult + runtimeId?: string +} + +export type VisibilityResumeBatch = { + visibilityGeneration: number + environments: Map + pendingInventoryCount: number + pendingMissingByWorktree: Map> + deferredRepairWorktrees: Set + trackedWorktreeIds: ReadonlySet + reapplyableSnapshotsByKey: Map< + string, + { snapshot: RuntimeMobileSessionTabsResult; receivedFrame: number; runtimeId?: string } + > +} + +export type MirroredRuntimeEnvironment = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts new file mode 100644 index 00000000000..468725f7df4 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts @@ -0,0 +1,6 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +/** The floating terminal is local-only and never belongs to a host mirror. */ +export function isHostMirroredWorktree(worktreeId: string): boolean { + return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID +} diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts new file mode 100644 index 00000000000..cbd3b991a67 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -0,0 +1,218 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' +import { + resolveAgentPaneAuthorityKey, + retireAgentPaneAuthorityAliases, + transferAgentPaneAuthorityAlias +} from './agent-pane-authority' +import { + boundRecentlyRetiredAgentStatusPaneKeys, + movePaneKeyedRecord, + removePaneKeys +} from './agent-status-map-helpers' +import { + getLeafIdFromPaneKey, + getTabIdFromPaneKey, + isRecentlyClosedAgentStatusTab +} from './agent-status-pane-helpers' + +export function createAgentStatusAuthorityActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'scheduleAgentStatusFreshness' + | 'retireAgentPaneAuthority' + | 'restoreAgentPaneAuthority' + | 'transferAgentPaneAuthority' +> { + const { get, set, freshness } = runtime + return { + scheduleAgentStatusFreshness: () => freshness.schedule(), + + retireAgentPaneAuthority: (paneKey, options) => { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey) + const retiredPaneKeySet = new Set(retiredPaneKeys) + for (const key of retiredPaneKeys) { + rendererAgentStatusObservations.forget(key) + } + let hadLive = false + set((s) => { + const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) + hadLive = retiredLivePaneKeys.length > 0 + let nextRetentionSuppressedPaneKeys = removePaneKeys( + s.retentionSuppressedPaneKeys, + retiredPaneKeySet + ) + if ( + retiredLivePaneKeys.length > 0 && + nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys + ) { + nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys } + } + for (const key of retiredLivePaneKeys) { + nextRetentionSuppressedPaneKeys[key] = true + } + return { + agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet), + runtimeAgentOrchestrationByPaneKey: removePaneKeys( + s.runtimeAgentOrchestrationByPaneKey, + retiredPaneKeySet + ), + retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet), + sleepingAgentSessionsByPaneKey: options?.preserveSleepingAgentSession + ? s.sleepingAgentSessionsByPaneKey + : removePaneKeys(s.sleepingAgentSessionsByPaneKey, retiredPaneKeySet), + agentLaunchConfigByPaneKey: removePaneKeys( + s.agentLaunchConfigByPaneKey, + retiredPaneKeySet + ), + acknowledgedAgentsByPaneKey: removePaneKeys( + s.acknowledgedAgentsByPaneKey, + retiredPaneKeySet + ), + paneForegroundAgentByPaneKey: removePaneKeys( + s.paneForegroundAgentByPaneKey, + retiredPaneKeySet + ), + unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet), + unreadAgentCompletionPanes: removePaneKeys( + s.unreadAgentCompletionPanes, + retiredPaneKeySet + ), + lastTerminalInputAtByPaneKey: removePaneKeys( + s.lastTerminalInputAtByPaneKey, + retiredPaneKeySet + ), + cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet), + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys( + s.recentlyRetiredAgentStatusPaneKeys, + retiredPaneKeys + ), + agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + freshness.scheduleDeferred() + } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey) + } + }, + + // Why: the tombstone claims this pane is gone; a live PTY binding to it proves + // otherwise. Lift the fence on that proof rather than on the next hook event — + // a pane re-attached mid-turn or while idle emits no new-turn event, so a + // turn-triggered revival leaves exactly the reported permanent suppression + // (STA-4114). This deliberately does NOT restore the rows retirement dropped; + // those are genuinely stale. It only re-opens the pane to future status. + restoreAgentPaneAuthority: (paneKey) => { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + // Why: a closed tab is a stronger, separate claim — re-attach must not undo it. + if ( + isRecentlyClosedAgentStatusTab( + get().recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(ownerPaneKey) + ) + ) { + return + } + set((s) => { + const restorable = [paneKey, ownerPaneKey].filter( + (key) => key in s.recentlyRetiredAgentStatusPaneKeys + ) + if (restorable.length === 0) { + return s + } + const next = { ...s.recentlyRetiredAgentStatusPaneKeys } + for (const key of restorable) { + delete next[key] + } + return { recentlyRetiredAgentStatusPaneKeys: next } + }) + // Why: deliberately OUTSIDE the guard above, and not gated on having cleared + // anything here. This map is not a mirror of main's — main fences panes the + // renderer never hears about (retirePtyAgentLaunchAuthority on command-finished + // and PTY exit calls the hook server directly, and nothing pushes that back), and + // this map is per-window and non-persisted, so a renderer reload empties it while + // main's survives. Gating the send on a local tombstone reintroduces STA-4114 for + // exactly those panes. The send is idempotent and main refuses closed tabs itself. + if (typeof window !== 'undefined') { + window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey) + } + }, + transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => { + const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId }) + if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) { + return + } + const from = transfer.previousOwnerPaneKey + const to = transfer.ownerPaneKey + // Why: the moved row carries the observation stamped for its OLD key; renderer-authored + // observations for the new key must sort after it, not race it. + rendererAgentStatusObservations.forget(from) + rendererAgentStatusObservations.rebind(to) + const targetTabId = getTabIdFromPaneKey(to) ?? undefined + const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined + set((s) => ({ + agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({ + ...entry, + paneKey: to, + tabId: targetTabId + })), + // Why: retention/sidebar consumers gate on the epoch; a moved live row is a + // pane-key change they must observe, not a silent remap. + ...(from in s.agentStatusByPaneKey + ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } + : {}), + runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord( + s.runtimeAgentOrchestrationByPaneKey, + from, + to + ), + retainedAgentsByPaneKey: movePaneKeyedRecord( + s.retainedAgentsByPaneKey, + from, + to, + (retained) => ({ + ...retained, + entry: { ...retained.entry, paneKey: to, tabId: targetTabId }, + tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab + }) + ), + sleepingAgentSessionsByPaneKey: movePaneKeyedRecord( + s.sleepingAgentSessionsByPaneKey, + from, + to, + (record) => ({ ...record, paneKey: to, tabId: targetTabId }) + ), + agentLaunchConfigByPaneKey: movePaneKeyedRecord( + s.agentLaunchConfigByPaneKey, + from, + to, + (entry) => ({ + ...entry, + identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId } + }) + ), + acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to), + paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to), + unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to), + unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to), + lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to), + cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to), + retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to) + })) + if (typeof window !== 'undefined') { + window.api?.agentStatus?.transferPaneAuthority?.({ + fromPaneKey: from, + toPaneKey: to, + ...(transfer.ptyId ? { ptyId: transfer.ptyId } : {}) + }) + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts new file mode 100644 index 00000000000..78047c13560 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts @@ -0,0 +1,197 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { + collectWorktreeIdsForConnection, + pruneMigrationUnsupportedEntries +} from './agent-status-map-helpers' + +/** Actions for removing transient rows and migration-era cache entries. */ +export function createAgentStatusCleanupActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'setMigrationUnsupportedPty' + | 'clearMigrationUnsupportedPty' + | 'removeAgentStatus' + | 'removeAgentStatusByTabPrefix' + | 'clearTransientAgentStatuses' +> { + const { get, set, freshness } = runtime + return { + setMigrationUnsupportedPty: (entry) => { + set((s) => { + const existing = s.migrationUnsupportedByPtyId[entry.ptyId] + if (existing && entry.updatedAt < existing.updatedAt) { + return s + } + return { + migrationUnsupportedByPtyId: { + ...s.migrationUnsupportedByPtyId, + [entry.ptyId]: entry + }, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + }, + + clearMigrationUnsupportedPty: (ptyId) => { + if (!(ptyId in get().migrationUnsupportedByPtyId)) { + return + } + set((s) => { + const next = { ...s.migrationUnsupportedByPtyId } + delete next[ptyId] + return { + migrationUnsupportedByPtyId: next, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + }, + + removeAgentStatus: (paneKey) => { + const current = get() + if ( + !(paneKey in current.agentStatusByPaneKey) && + !(paneKey in current.agentLaunchConfigByPaneKey) && + !Object.values(current.migrationUnsupportedByPtyId).some( + (entry) => entry.paneKey === paneKey + ) + ) { + return + } + set((s) => { + const hasLive = paneKey in s.agentStatusByPaneKey + const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete next[paneKey] + } + const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey + const nextLaunchConfigs = hasLaunchConfig + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + if (hasLaunchConfig) { + delete nextLaunchConfigs[paneKey] + } + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + // Ack entries belong to the pane lifecycle; never let a reused key inherit one. + let nextAck = s.acknowledgedAgentsByPaneKey + if (paneKey in nextAck) { + nextAck = { ...nextAck } + delete nextAck[paneKey] + } + return { + agentStatusByPaneKey: next, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + freshness.scheduleDeferred() + }, + + removeAgentStatusByTabPrefix: (tabIdPrefix) => { + const prefix = `${tabIdPrefix}:` + const current = get() + const toRemove = Object.keys(current.agentStatusByPaneKey).filter((key) => + key.startsWith(prefix) + ) + const launchConfigKeys = Object.keys(current.agentLaunchConfigByPaneKey).filter((key) => + key.startsWith(prefix) + ) + const hasMigrationUnsupported = Object.values(current.migrationUnsupportedByPtyId).some( + (entry) => entry.paneKey?.startsWith(prefix) + ) + if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) { + return + } + set((s) => { + const next = { ...s.agentStatusByPaneKey } + for (const key of toRemove) { + delete next[key] + } + const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey?.startsWith(prefix) ?? false + ) + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter((key) => key.startsWith(prefix)) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const key of ackKeys) { + delete nextAck[key] + } + } + return { + agentStatusByPaneKey: next, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + freshness.scheduleDeferred() + }, + + clearTransientAgentStatuses: (connectionId, clearedAt) => { + if (connectionId.length === 0 || !Number.isFinite(clearedAt)) { + return + } + let removed = false + set((s) => { + const worktreeIdsOnConnection = collectWorktreeIdsForConnection(s, connectionId) + let next: Record | null = null + for (const [paneKey, existing] of Object.entries(s.agentStatusByPaneKey)) { + if (existing.updatedAt > clearedAt) { + continue + } + const belongsToConnection = + existing.connectionId === connectionId || + (existing.connectionId === undefined && + existing.worktreeId !== undefined && + worktreeIdsOnConnection.has(existing.worktreeId)) + if (!belongsToConnection) { + continue + } + next ??= { ...s.agentStatusByPaneKey } + delete next[paneKey] + } + const alreadyBlocked = connectionId in s.transientClearedAgentStatusConnectionIds + if (!next && alreadyBlocked) { + return s + } + removed = next !== null + return { + ...(next + ? { + agentStatusByPaneKey: next, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + : {}), + transientClearedAgentStatusConnectionIds: alreadyBlocked + ? s.transientClearedAgentStatusConnectionIds + : { ...s.transientClearedAgentStatusConnectionIds, [connectionId]: true } + } + }) + if (removed) { + freshness.scheduleDeferred() + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts new file mode 100644 index 00000000000..457da539318 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -0,0 +1,149 @@ +import type { AppState } from '../types' +import type { + AgentStateHistoryEntry, + AgentStatusEntry, + AgentStatusOrchestrationContext, + AgentType, + MigrationUnsupportedPtyEntry, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' +import type { + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +export type RetainedAgentEntry = { + entry: AgentStatusEntry + worktreeId: string + /** Snapshot of the tab at retention time; kept full (not just an id) because the tab may be gone from `tabsByWorktree` by render time. */ + tab: TerminalTab + agentType: AgentType + startedAt: number +} + +export type AgentStatusWorktreeShutdownReason = + | 'manual-sleep' + | 'remove-worktree' + | 'auto-hibernate-completed-agent' + +export type AllAgentSessionCaptureMode = 'periodic' | 'quit' + +export type DropAgentStatusByWorktreeOptions = { + shutdownReason?: AgentStatusWorktreeShutdownReason + sleepingPaneKeys?: readonly string[] | ReadonlySet + retainedCompletionEvidence?: readonly RetainedAgentEntry[] +} + +export type DropHibernatedAgentPaneOptions = { + retainedCompletionEvidence?: readonly RetainedAgentEntry[] +} + +export type DropAgentStatusByTabPrefixOptions = { + worktreeId?: string +} + +export type AgentLaunchConfigRegistrationMetadata = { + agentType?: AgentType + launchToken?: string + tabId?: string + leafId?: string + terminalHandle?: string + providerSession?: AgentProviderSessionMetadata +} + +export type AgentLaunchConfigStatusMetadata = { + paneKey: string + agentType?: AgentType + tabId?: string + terminalHandle?: string + launchToken?: string + providerSession?: AgentProviderSessionMetadata + existingProviderSession?: AgentProviderSessionMetadata + providerSessionChanged?: boolean +} + +export type AgentLaunchConfigRegistryEntry = { + launchConfig: SleepingAgentLaunchConfig + registeredAt: number + identity: AgentLaunchConfigRegistrationMetadata +} + +export type AgentStatusPayload = ParsedAgentStatusPayload & { + orchestration?: AgentStatusOrchestrationContext + promptInteractionKey?: string + restoredUnconfirmed?: boolean + /** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits + * it produces exactly the entry it produces today. See agent-status-observation.ts. */ + observation?: AgentStatusObservation +} + +export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } + +export type AgentStatusRouting = { + tabId?: string + worktreeId?: string + terminalHandle?: string + connectionId?: string | null +} + +export type AgentStatusMetadata = { + providerSession?: AgentProviderSessionMetadata + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + terminalResumeEligible?: false +} + +export type AgentStatusUpdate = { + kind?: 'status' + paneKey: string + payload: AgentStatusPayload + terminalTitle?: string + timing?: AgentStatusTiming + routing?: AgentStatusRouting + metadata?: AgentStatusMetadata +} + +export type AgentProviderSessionTiming = { updatedAt?: number } + +export type AgentProviderSessionRouting = { + tabId?: string + worktreeId?: string + connectionId?: string | null +} + +export type AgentProviderSessionRecordMetadata = { launchToken?: string } + +export type AgentProviderSessionUpdate = { + kind: 'providerSession' + paneKey: string + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + timing?: AgentProviderSessionTiming + routing?: AgentProviderSessionRouting + metadata?: AgentProviderSessionRecordMetadata +} + +export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate + +export type AgentStatusBatchTransaction = { + getState: () => AppState + apply: (update: AgentStatusBatchUpdate) => boolean + afterCommit: (effect: () => void) => void +} + +export type { + AgentStateHistoryEntry, + AgentStatusEntry, + AgentStatusOrchestrationContext, + AgentType, + MigrationUnsupportedPtyEntry, + ParsedAgentStatusPayload, + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts new file mode 100644 index 00000000000..fdaaa450b52 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -0,0 +1,225 @@ +import type { + RetainedAgentEntry, + DropAgentStatusByTabPrefixOptions, + DropHibernatedAgentPaneOptions +} from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' +import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { + retainedAgentEntryFromLive, + shouldReplaceRetainedWithLive +} from './agent-status-pane-helpers' +import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority' + +function removeAcknowledgement( + acknowledgements: Record, + paneKey: string +): Record { + if (!(paneKey in acknowledgements)) { + return acknowledgements + } + const next = { ...acknowledgements } + delete next[paneKey] + return next +} + +export function createAgentStatusDropActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + 'dropAgentStatus' | 'dropAgentStatusByTabPrefix' | 'dropHibernatedAgentStatusPane' +> { + const { set, freshness } = runtime + return { + dropAgentStatus: (paneKey) => { + let liveExisted = false + set((s) => { + const hasLive = paneKey in s.agentStatusByPaneKey + liveExisted = hasLive + const hasRetained = paneKey in s.retainedAgentsByPaneKey + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + const nextAck = removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) + const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey + const nextLaunchConfigs = hasLaunchConfig + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + if (hasLaunchConfig) { + delete nextLaunchConfigs[paneKey] + } + if (!hasLive && !hasRetained && !migrationUnsupported.changed) { + if (hasLaunchConfig) { + return { + agentLaunchConfigByPaneKey: nextLaunchConfigs, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}) + } + } + return nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : s + } + const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete nextLive[paneKey] + } + const nextRetained = hasRetained + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained) { + delete nextRetained[paneKey] + } + const needsSuppressor = hasLive && !(paneKey in s.retentionSuppressedPaneKeys) + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(needsSuppressor + ? { + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + : {}), + agentStatusEpoch: + hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (liveExisted) { + freshness.scheduleDeferred() + } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.drop?.(paneKey) + } + }, + + dropAgentStatusByTabPrefix: (tabIdPrefix, opts?: DropAgentStatusByTabPrefixOptions) => { + const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix) + let hadLive = false + set((s) => { + const dropped = buildAgentStatusTabPrefixDropPatch( + s, + tabIdPrefix, + retiredAliasPaneKeys, + opts + ) + hadLive = dropped.hadLive + return dropped.patch + }) + if (hadLive) { + freshness.scheduleDeferred() + } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix) + } + }, + + dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts?: DropHibernatedAgentPaneOptions) => { + let hadLive = false + set((s) => { + const liveEntry = s.agentStatusByPaneKey[paneKey] + const hasLive = liveEntry !== undefined + const hasRetained = paneKey in s.retainedAgentsByPaneKey + const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + const retainedEvidence = new Map() + for (const retained of opts?.retainedCompletionEvidence ?? []) { + if ( + retained.entry.paneKey === paneKey && + !liveEntry && + shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained) + ) { + retainedEvidence.set(paneKey, retained) + } + } + if ( + liveEntry?.state === 'done' && + liveEntry.agentType !== undefined && + liveEntry.interrupted !== true + ) { + retainedEvidence.set( + paneKey, + retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType) + ) + } + const keepsCompletionEvidence = retainedEvidence.has(paneKey) + const nextAck = !keepsCompletionEvidence + ? removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) + : s.acknowledgedAgentsByPaneKey + if ( + !hasLive && + !hasRetained && + !hasLaunchConfig && + !migrationUnsupported.changed && + !keepsCompletionEvidence + ) { + return nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : s + } + hadLive = hasLive + const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete nextLive[paneKey] + } + const nextLaunchConfigs = hasLaunchConfig + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + if (hasLaunchConfig) { + delete nextLaunchConfigs[paneKey] + } + const nextRetained = + hasRetained || keepsCompletionEvidence + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained && !keepsCompletionEvidence) { + delete nextRetained[paneKey] + } + for (const [key, retained] of retainedEvidence) { + if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) { + nextRetained[key] = retained + } + } + const needsSuppressor = + hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys) + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(needsSuppressor + ? { + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + : {}), + agentStatusEpoch: + hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + freshness.scheduleDeferred() + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts new file mode 100644 index 00000000000..fc24f06add8 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -0,0 +1,159 @@ +import type { AppState } from '../types' +import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract' +import { + boundRecentlyClosedAgentStatusTabIds, + boundRecentlyRetiredAgentStatusPaneKeys, + pruneMigrationUnsupportedEntries +} from './agent-status-map-helpers' +import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-helpers' + +/** Slices that only the fold touched, so the batch commits as a MERGE. */ +export function buildAgentStatusBatchPatch( + initialState: AppState, + nextState: AppState +): Partial { + const patch: Record = {} + for (const key of Object.keys(nextState) as (keyof AppState)[]) { + if (!Object.is(nextState[key], initialState[key])) { + patch[key as string] = nextState[key] + } + } + return patch as Partial +} + +/** The slice of app state the tab-prefix drop reduces over — narrow so callers + * outside the store (the paired snapshot apply) can build the same patch. */ +export type AgentStatusTabPrefixDropState = Pick< + AppState, + | 'acknowledgedAgentsByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'agentStatusByPaneKey' + | 'agentStatusEpoch' + | 'migrationUnsupportedByPtyId' + | 'recentlyClosedAgentStatusTabIds' + | 'recentlyRetiredAgentStatusPaneKeys' + | 'retainedAgentsByPaneKey' + | 'retentionSuppressedPaneKeys' + | 'sortEpoch' + | 'tabsByWorktree' +> + +/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot + * apply folds the same sweep into a patch it assembles itself, so the two + * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because + * retiring pane-authority aliases is a registry side effect, not a reduction. */ +export function buildAgentStatusTabPrefixDropPatch( + s: AgentStatusTabPrefixDropState, + tabIdPrefix: string, + retiredAliasPaneKeys: readonly string[], + opts?: DropAgentStatusByTabPrefixOptions +): { patch: Partial; hadLive: boolean } { + const prefix = `${tabIdPrefix}:` + let hadLive = false + const buildPatch = (): Partial => { + const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(s, opts?.worktreeId, prefix) + const completedOrphanKeySet = new Set(completedOrphanKeys) + const liveKeys = [ + ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), + ...completedOrphanKeys + ] + const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey?.startsWith(prefix) ?? false + ) + // See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state. + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const k of ackKeys) { + delete nextAck[k] + } + } + const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( + s.recentlyClosedAgentStatusTabIds, + tabIdPrefix + ) + const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( + s.recentlyRetiredAgentStatusPaneKeys, + retiredAliasPaneKeys + ) + + if ( + liveKeys.length === 0 && + launchConfigKeys.length === 0 && + retainedKeys.length === 0 && + !migrationUnsupported.changed + ) { + if (nextAck !== s.acknowledgedAgentsByPaneKey) { + return { + acknowledgedAgentsByPaneKey: nextAck, + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + } + } + return { + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + } + } + hadLive = liveKeys.length > 0 + + const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + for (const key of liveKeys) { + delete nextLive[key] + } + const nextLaunchConfigs = + launchConfigKeys.length > 0 + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + + const nextRetained = + retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey + for (const key of retainedKeys) { + delete nextRetained[key] + } + + // Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus). + const suppressorAdds = liveKeys.filter( + (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) + ) + let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys + if (suppressorAdds.length > 0) { + nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } + for (const key of suppressorAdds) { + nextRetentionSuppressedPaneKeys[key] = true + } + } + + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. + agentStatusEpoch: + hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + } + const patch = buildPatch() + return { patch, hadLive } +} diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts new file mode 100644 index 00000000000..67832edfab6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -0,0 +1,110 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import type { AgentLaunchConfigRegistryEntry } from './agent-status-contract' +import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-recovery-helpers' +import { + getLaunchConfigForEntry, + launchConfigRegistryEntriesEqual, + normalizeLaunchConfigRegistrationMetadata, + registryEntryMatchesStatus +} from './agent-status-launch-config' +import { getLaunchConfigForStatusMetadata } from './agent-status-map-helpers' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' + +export function createAgentStatusLaunchActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'registerAgentLaunchConfig' + | 'getAgentLaunchConfigForStatusEntry' + | 'getAgentLaunchConfigForStatusMetadata' + | 'clearAgentLaunchConfig' +> { + const { get, set } = runtime + return { + registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => { + set((s) => { + const copiedLaunchConfig = copyLaunchConfig(launchConfig) + const nextRegistryEntry: AgentLaunchConfigRegistryEntry = { + launchConfig: copiedLaunchConfig, + registeredAt: Date.now(), + identity: normalizeLaunchConfigRegistrationMetadata(paneKey, metadata) + } + const existingRegistryEntry = s.agentLaunchConfigByPaneKey[paneKey] + const registryChanged = !launchConfigRegistryEntriesEqual( + existingRegistryEntry, + nextRegistryEntry + ) + const existingEntry = s.agentStatusByPaneKey[paneKey] + const entryMatchesRegistry = registryEntryMatchesStatus({ + entry: nextRegistryEntry, + paneKey, + agentType: existingEntry?.agentType, + tabId: existingEntry?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, + terminalHandle: existingEntry?.terminalHandle, + launchToken: metadata?.launchToken, + providerSession: existingEntry?.providerSession, + existingProviderSession: existingEntry?.providerSession, + providerSessionChanged: false + }) + const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] + let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey + if (existingSleepingRecord && entryMatchesRegistry && existingEntry) { + const worktreeId = + existingEntry.worktreeId ?? + existingSleepingRecord.worktreeId ?? + findAgentPaneWorktreeId(s, paneKey) + const refreshedRecord = worktreeId + ? sleepingRecordFromEntry({ + state: s, + entry: existingEntry, + worktreeId, + capturedAt: existingSleepingRecord.capturedAt, + launchConfig: copiedLaunchConfig, + origin: existingSleepingRecord.origin + }) + : null + if (refreshedRecord) { + nextSleepingAgentSessions = { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: { + ...refreshedRecord, + capturedAt: existingSleepingRecord.capturedAt + } + } + } + } + if (!registryChanged && nextSleepingAgentSessions === s.sleepingAgentSessionsByPaneKey) { + return s + } + return { + ...(registryChanged + ? { + agentLaunchConfigByPaneKey: { + ...s.agentLaunchConfigByPaneKey, + [paneKey]: nextRegistryEntry + } + } + : {}), + ...(nextSleepingAgentSessions !== s.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions } + : {}) + } + }) + }, + getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry), + getAgentLaunchConfigForStatusMetadata: (metadata) => + getLaunchConfigForStatusMetadata(get(), metadata), + + clearAgentLaunchConfig: (paneKey) => { + set((s) => { + if (!(paneKey in s.agentLaunchConfigByPaneKey)) { + return s + } + const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } + delete nextLaunchConfigs[paneKey] + return { agentLaunchConfigByPaneKey: nextLaunchConfigs } + }) + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-launch-config.ts b/src/renderer/src/store/slices/agent-status-launch-config.ts new file mode 100644 index 00000000000..ff265bc3ff6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-launch-config.ts @@ -0,0 +1,142 @@ +import type { AppState } from '../types' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata +} from './agent-status-contract' +import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { launchConfigsEqual } from './agent-status-recovery-equivalence' + +export function normalizeLaunchConfigRegistrationMetadata( + paneKey: string, + metadata: AgentLaunchConfigRegistrationMetadata | undefined +): AgentLaunchConfigRegistrationMetadata { + return { + ...(metadata?.agentType ? { agentType: metadata.agentType } : {}), + ...(metadata?.launchToken ? { launchToken: metadata.launchToken } : {}), + tabId: metadata?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, + leafId: metadata?.leafId ?? getLeafIdFromPaneKey(paneKey) ?? undefined, + ...(metadata?.terminalHandle ? { terminalHandle: metadata.terminalHandle } : {}), + ...(metadata?.providerSession ? { providerSession: metadata.providerSession } : {}) + } +} + +export function launchConfigRegistryEntriesEqual( + a: AgentLaunchConfigRegistryEntry | undefined, + b: AgentLaunchConfigRegistryEntry +): boolean { + return ( + a !== undefined && + launchConfigsEqual(a.launchConfig, b.launchConfig) && + a.identity.agentType === b.identity.agentType && + a.identity.launchToken === b.identity.launchToken && + a.identity.tabId === b.identity.tabId && + a.identity.leafId === b.identity.leafId && + a.identity.terminalHandle === b.identity.terminalHandle && + agentProviderSessionsEqual( + a.identity.agentType ?? b.identity.agentType, + a.identity.providerSession, + b.identity.providerSession + ) + ) +} + +export function registryEntryMatchesStatus(args: { + entry: AgentLaunchConfigRegistryEntry | undefined + paneKey: string + agentType: AgentType | undefined + tabId: string | undefined + terminalHandle: string | undefined + launchToken: string | undefined + providerSession: AgentProviderSessionMetadata | undefined + existingProviderSession: AgentProviderSessionMetadata | undefined + providerSessionChanged: boolean +}): boolean { + const entry = args.entry + if (!entry || args.providerSessionChanged) { + return false + } + const identity = entry.identity + if (identity.agentType !== undefined && identity.agentType !== args.agentType) { + return false + } + if (identity.tabId !== undefined && identity.tabId !== args.tabId) { + return false + } + if (identity.leafId !== undefined && identity.leafId !== getLeafIdFromPaneKey(args.paneKey)) { + return false + } + if ( + identity.terminalHandle !== undefined && + (args.terminalHandle === undefined || identity.terminalHandle !== args.terminalHandle) + ) { + return false + } + if ( + identity.launchToken !== undefined && + (args.launchToken === undefined || identity.launchToken !== args.launchToken) + ) { + // Why: a missing/mismatched launch token is stale proof even if a later manual/mixed Codex run reused the provider session id. + return false + } + if (identity.providerSession !== undefined) { + return agentProviderSessionsEqual( + args.agentType, + identity.providerSession, + args.providerSession + ) + } + if (identity.launchToken !== undefined) { + return true + } + if (identity.terminalHandle !== undefined) { + return true + } + if (args.existingProviderSession && args.providerSession) { + return agentProviderSessionsEqual( + args.agentType, + args.existingProviderSession, + args.providerSession + ) + } + return false +} + +export function getLaunchConfigForEntry( + state: AppState, + entry: AgentStatusEntry +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey] + const registryLaunchConfig = registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: entry.paneKey, + agentType: entry.agentType, + tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined, + terminalHandle: entry.terminalHandle, + launchToken: undefined, + providerSession: entry.providerSession, + existingProviderSession: entry.providerSession, + providerSessionChanged: false + }) + ? registryEntry?.launchConfig + : undefined + if (registryLaunchConfig) { + return registryLaunchConfig + } + const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey] + return sleepingRecord?.launchConfig && + sleepingRecord.agent === entry.agentType && + entry.providerSession && + agentProviderSessionsEqual( + entry.agentType, + sleepingRecord.providerSession, + entry.providerSession + ) + ? sleepingRecord.launchConfig + : undefined +} diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts new file mode 100644 index 00000000000..f534c5efb54 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -0,0 +1,120 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import type { + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming +} from './agent-status-contract' +import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' +import { + buildAgentStatusLiveEntry, + type AgentStatusLiveEntryBuild +} from './agent-status-live-entry-builder' +import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer' +import { + agentStatusTabAlreadyHasProtectedOrGeneratedTitle, + getTabIdFromPaneKey, + isRecentlyClosedAgentStatusTab +} from './agent-status-pane-helpers' +import { + getAgentRowGeneratedTitleText, + getOrcaDispatchTaskId, + isOrcaDispatchPrompt, + orchestrationLabelsMatchLiveDispatch +} from '@/lib/agent-row-primary-text' + +export function createAgentStatusLiveActions( + runtime: AgentStatusRuntime +): Pick { + const { get, set, applyGeneratedTabTitleUpdate, requestFreshness, transactAgentStatuses } = + runtime + const setAgentStatus = ( + rawPaneKey: string, + payload: AgentStatusPayload, + terminalTitle?: string, + timing?: AgentStatusTiming, + routing?: AgentStatusRouting, + metadata?: AgentStatusMetadata + ): void => { + const paneKey = resolveAgentPaneAuthorityKey(rawPaneKey) + const updatedAt = timing?.updatedAt ?? Date.now() + const current = get() + if ( + paneKey in current.recentlyRetiredAgentStatusPaneKeys || + isRecentlyClosedAgentStatusTab( + current.recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(paneKey) + ) + ) { + return + } + let built: AgentStatusLiveEntryBuild | null = null + set((state) => { + built = buildAgentStatusLiveEntry({ + state, + paneKey, + payload, + terminalTitle, + timing, + routing, + metadata, + updatedAt + }) + return built ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state + }) + // Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment. + const builtResult = built as AgentStatusLiveEntryBuild | null + if (!builtResult) { + // Keep standalone calls' deferred freshness contract even when a stale + // event is rejected by the reducer. + requestFreshness(false) + return + } + const { entry } = builtResult + // Sticky orchestration titles are replaced only when they still describe this dispatch. + const hasMatchingOrchestrationLabels = Boolean( + (entry.orchestration?.displayName?.trim() || entry.orchestration?.taskTitle?.trim()) && + orchestrationLabelsMatchLiveDispatch(entry) + ) + const liveIsDispatchPrompt = isOrcaDispatchPrompt(entry.prompt) + const liveDispatchTaskId = liveIsDispatchPrompt ? getOrcaDispatchTaskId(entry.prompt) : null + const stickyOrchestrationTaskId = entry.orchestration?.taskId?.trim() || null + const isNewDispatchAgainstStickyOrchestration = Boolean( + liveDispatchTaskId && + stickyOrchestrationTaskId && + liveDispatchTaskId !== stickyOrchestrationTaskId + ) + const shouldReplaceGeneratedTitle = + hasMatchingOrchestrationLabels || isNewDispatchAgainstStickyOrchestration + const mayWriteGeneratedTitle = + get().settings?.tabAutoGenerateTitle === true && + (shouldReplaceGeneratedTitle || + !agentStatusTabAlreadyHasProtectedOrGeneratedTitle( + get(), + entry.tabId ?? getTabIdFromPaneKey(paneKey), + entry.worktreeId + )) + const generatedTitlePrompt = + liveIsDispatchPrompt && mayWriteGeneratedTitle + ? getAgentRowGeneratedTitleText(entry) + : entry.prompt + applyGeneratedTabTitleUpdate({ + paneKey, + prompt: generatedTitlePrompt, + ...(shouldReplaceGeneratedTitle ? { options: { replaceExistingGeneratedTitle: true } } : {}) + }) + requestFreshness(true) + if (builtResult.completionRefreshWorktreeId) { + const worktreeId = builtResult.completionRefreshWorktreeId + queueMicrotask(() => get().refreshGitHubForWorktreeIfStale(worktreeId)) + } + } + + const setAgentStatuses = (updates: Parameters[0]) => + updates.length === 0 + ? [] + : transactAgentStatuses((transaction) => updates.map(transaction.apply)) + + return { setAgentStatus, setAgentStatuses, transactAgentStatuses } +} diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts new file mode 100644 index 00000000000..871728f0d81 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -0,0 +1,291 @@ +import type { AppState } from '../types' +import { + AGENT_STATE_HISTORY_MAX, + agentSubagentsEqual, + type MigrationUnsupportedPtyEntry, + type AgentStateHistoryEntry, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + isResumableTuiAgent, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../../shared/agent-status-identity' +import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' +import type { + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming +} from './agent-status-contract' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { registryEntryMatchesStatus } from './agent-status-launch-config' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' +import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' + +export type AgentStatusLiveEntryBuild = { + entry: AgentStatusEntry + existing: AgentStatusEntry | undefined + existingSleepingRecord: SleepingAgentSessionRecord | undefined + liveRecoveryRecord: SleepingAgentSessionRecord | null + launchConfigSource: SleepingAgentLaunchConfig | undefined + registryEntry: AppState['agentLaunchConfigByPaneKey'][string] | undefined + registryMatched: boolean + providerSession: AgentProviderSessionMetadata | undefined + providerSessionChanged: boolean + retainsResumableRecoveryIdentity: boolean + migrationUnsupported: { + next: Record + changed: boolean + } + commandCodeNewTurn: boolean + sortRelevantChange: boolean + retentionRelevantChange: boolean + completionRefreshWorktreeId: string | null + boundaryResolved: boolean +} + +export type AgentStatusLiveEntryArgs = { + state: AppState + paneKey: string + payload: AgentStatusPayload + terminalTitle?: string + timing?: AgentStatusTiming + routing?: AgentStatusRouting + metadata?: AgentStatusMetadata + updatedAt: number +} + +/** Build one accepted live row and the derived map-update facts. */ +export function buildAgentStatusLiveEntry( + args: AgentStatusLiveEntryArgs +): AgentStatusLiveEntryBuild | null { + const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args + const existing = state.agentStatusByPaneKey[paneKey] + if (existing && updatedAt < existing.updatedAt) { + return null + } + const effectiveTitle = terminalTitle ?? existing?.terminalTitle + let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] + let lastCompletedAssistantMessage = existing?.lastCompletedAssistantMessage + const boundaryLandsOnRealDone = + existing?.state === 'done' && + existing.sessionBoundary !== true && + payload.state === 'done' && + payload.sessionBoundary === true + if ( + existing && + (existing.state !== payload.state || boundaryLandsOnRealDone) && + !(existing.state === 'done' && existing.sessionBoundary === true) + ) { + history = [ + ...history, + { + state: existing.state, + prompt: existing.prompt, + startedAt: existing.stateStartedAt, + interrupted: existing.interrupted + } + ] + if (history.length > AGENT_STATE_HISTORY_MAX) { + history = history.slice(history.length - AGENT_STATE_HISTORY_MAX) + } + if (existing.state === 'done') { + lastCompletedAssistantMessage = existing.lastAssistantMessage + } + } + const identity = resolveAgentStatusIdentity({ + existing: existing + ? { + agentType: existing.agentType, + state: existing.state, + updatedAt: existing.updatedAt, + restoredUnconfirmed: existing.restoredUnconfirmed + } + : undefined, + incoming: payload.agentType, + now: updatedAt + }) + const commandCodeNewTurn = + existing !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: identity.agentType, + previousState: existing.state, + incomingState: payload.state, + previousPrompt: existing.prompt, + incomingPrompt: payload.prompt, + previousPromptInteractionKey: existing.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) + const promptInteractionKey = + payload.promptInteractionKey ?? + (payload.prompt === existing?.prompt ? existing?.promptInteractionKey : undefined) + const stateStartedAt = + timing?.stateStartedAt ?? + (commandCodeNewTurn + ? updatedAt + : existing && existing.state === payload.state + ? existing.stateStartedAt + : updatedAt) + if ( + existing && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: payload.state + }) + ) { + return null + } + const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey] + const runtimeMergedOrchestration = runtimeOrchestration + ? mergeCurrentOrchestrationContext(existing?.orchestration, runtimeOrchestration) + : undefined + const payloadMergedOrchestration = payload.orchestration + ? mergeCurrentOrchestrationContext( + runtimeMergedOrchestration ?? existing?.orchestration, + payload.orchestration + ) + : undefined + const orchestration = + payloadMergedOrchestration ?? + runtimeMergedOrchestration ?? + (payload.state === 'done' ? existing?.orchestration : undefined) + const canReuseExistingProviderSession = + existing?.agentType === identity.agentType && + (existing.state !== 'done' || payload.state === 'done') + const providerSession = + metadata?.providerSession ?? + (canReuseExistingProviderSession ? existing.providerSession : undefined) + const existingProviderSession = canReuseExistingProviderSession + ? existing.providerSession + : undefined + const providerSessionChanged = + Boolean(metadata?.providerSession && existingProviderSession) && + !agentProviderSessionsEqual( + identity.agentType, + metadata?.providerSession, + existingProviderSession + ) + const statusTabId = routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined + const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle + const registryEntry = state.agentLaunchConfigByPaneKey[paneKey] + const registryMatched = registryEntryMatchesStatus({ + entry: registryEntry, + paneKey, + agentType: identity.agentType, + tabId: statusTabId, + terminalHandle: statusTerminalHandle, + launchToken: metadata?.launchToken, + providerSession, + existingProviderSession, + providerSessionChanged + }) + const matchedRegistryLaunchConfig = registryMatched ? registryEntry?.launchConfig : undefined + const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] + const retainsResumableRecoveryIdentity = + payload.state === 'done' && + isResumableTuiAgent(identity.agentType) && + providerSession !== undefined && + getAgentResumeArgv(identity.agentType, providerSession) !== null + const matchedSleepingLaunchConfig = + (payload.state !== 'done' || retainsResumableRecoveryIdentity) && + existingSleepingRecord?.launchConfig && + existingSleepingRecord.agent === identity.agentType && + providerSession && + agentProviderSessionsEqual( + identity.agentType, + existingSleepingRecord.providerSession, + providerSession + ) + ? existingSleepingRecord.launchConfig + : undefined + const launchConfigSource = + (payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken + ? metadata?.launchConfig + : undefined) ?? + matchedRegistryLaunchConfig ?? + matchedSleepingLaunchConfig + const entry: AgentStatusEntry = { + state: payload.state, + workingMode: payload.workingMode, + prompt: payload.prompt, + updatedAt, + stateStartedAt, + agentType: identity.agentType, + model: + payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined), + paneKey, + terminalHandle: statusTerminalHandle, + worktreeId: + routing?.worktreeId ?? + existing?.worktreeId ?? + findAgentPaneWorktreeId(state, paneKey) ?? + undefined, + ...(routing?.connectionId !== undefined + ? { connectionId: routing.connectionId } + : existing?.connectionId !== undefined + ? { connectionId: existing.connectionId } + : state.sleepingAgentSessionsByPaneKey[paneKey]?.connectionId !== undefined + ? { connectionId: state.sleepingAgentSessionsByPaneKey[paneKey].connectionId } + : {}), + tabId: statusTabId, + terminalTitle: effectiveTitle, + stateHistory: history, + toolName: payload.toolName, + toolInput: payload.toolInput, + interactivePrompt: payload.interactivePrompt, + lastAssistantMessage: payload.lastAssistantMessage, + lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput, + ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), + orchestration, + subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) + ? existing?.subagents + : payload.subagents, + ...(providerSession ? { providerSession } : {}), + ...(metadata?.terminalResumeEligible === false + ? { terminalResumeEligible: false as const } + : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1, + ...(payload.observation ? { observation: payload.observation } : {}), + interrupted: payload.interrupted, + sessionBoundary: + payload.sessionBoundary ?? + (existing?.state === 'done' && + payload.state === 'done' && + payload.lastAssistantMessage === undefined && + payload.prompt === existing.prompt + ? existing.sessionBoundary + : undefined) + } + const facts = deriveAgentStatusLiveFacts({ + state, + paneKey, + entry, + existing, + launchConfigSource, + retainsResumableRecoveryIdentity, + commandCodeNewTurn, + updatedAt + }) + return { + entry, + existing, + ...facts, + launchConfigSource, + providerSession, + providerSessionChanged, + registryEntry, + registryMatched, + retainsResumableRecoveryIdentity, + commandCodeNewTurn + } +} diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts new file mode 100644 index 00000000000..4dd2691699e --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -0,0 +1,131 @@ +import type { AppState } from '../types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' +import { findAgentPaneWorktreeId, isAgentCompletionState } from './agent-status-pane-helpers' +import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { sleepingRecordFromEntry } from './agent-status-recovery-helpers' + +export type AgentStatusLiveFacts = { + existingSleepingRecord: SleepingAgentSessionRecord | undefined + liveRecoveryRecord: SleepingAgentSessionRecord | null + migrationUnsupported: ReturnType + completionRefreshWorktreeId: string | null + sortRelevantChange: boolean + retentionRelevantChange: boolean + boundaryResolved: boolean +} + +export type AgentStatusLiveFactsArgs = { + state: AppState + paneKey: string + entry: AgentStatusEntry + existing: AgentStatusEntry | undefined + launchConfigSource: SleepingAgentLaunchConfig | undefined + retainsResumableRecoveryIdentity: boolean + commandCodeNewTurn: boolean + updatedAt: number +} + +export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): AgentStatusLiveFacts { + const { + state, + paneKey, + entry, + existing, + launchConfigSource, + retainsResumableRecoveryIdentity, + commandCodeNewTurn, + updatedAt + } = args + const boundaryResolved = + entry.state === 'done' && entry.sessionBoundary !== true && existing?.sessionBoundary === true + if (boundaryResolved) { + recordHibernationBoundaryResolved(paneKey, updatedAt) + } + const completionRefreshWorktreeId = + isAgentCompletionState(entry.state) && + entry.sessionBoundary !== true && + existing !== undefined && + !isAgentCompletionState(existing.state) + ? (entry.worktreeId ?? findAgentPaneWorktreeId(state, paneKey)) + : null + const wasFresh = + !!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS) + const attributionChanged = + existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId + const sameStateStateStartedAtChanged = + !!existing && existing.state === entry.state && entry.stateStartedAt !== existing.stateStartedAt + const sameStateDoneAttentionChanged = + existing?.state === 'done' && + entry.state === 'done' && + agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) + const sortRelevantChange = + !existing || + existing.state !== entry.state || + !wasFresh || + attributionChanged || + commandCodeNewTurn || + sameStateStateStartedAtChanged || + sameStateDoneAttentionChanged + const doneRetentionFieldsChanged = + existing?.state === 'done' && + entry.state === 'done' && + (entry.prompt !== existing.prompt || + entry.updatedAt !== existing.updatedAt || + entry.stateStartedAt !== existing.stateStartedAt || + entry.agentType !== existing.agentType || + entry.model !== existing.model || + entry.terminalTitle !== existing.terminalTitle || + entry.toolName !== existing.toolName || + entry.toolInput !== existing.toolInput || + entry.lastAssistantMessage !== existing.lastAssistantMessage || + entry.lastAssistantMessageIsToolOutput !== existing.lastAssistantMessageIsToolOutput || + entry.orchestration !== existing.orchestration || + entry.subagents !== existing.subagents || + entry.providerSession !== existing.providerSession || + entry.interrupted !== existing.interrupted) + const retentionRelevantChange = + sortRelevantChange || + attributionChanged || + existing?.workingMode !== entry.workingMode || + doneRetentionFieldsChanged + const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] + const liveRecoveryWorktreeId = + entry.state === 'done' && !retainsResumableRecoveryIdentity + ? null + : (entry.worktreeId ?? findAgentPaneWorktreeId(state, paneKey)) + const liveRecoveryRecord = liveRecoveryWorktreeId + ? sleepingRecordFromEntry({ + state, + entry: retainsResumableRecoveryIdentity + ? { ...entry, prompt: '', lastAssistantMessage: undefined } + : entry, + worktreeId: liveRecoveryWorktreeId, + capturedAt: updatedAt, + launchConfig: launchConfigSource, + origin: 'live' + }) + : null + const migrationUnsupported = pruneMigrationUnsupportedEntries( + state.migrationUnsupportedByPtyId, + (migrationEntry) => migrationEntry.paneKey === paneKey + ) + return { + existingSleepingRecord, + liveRecoveryRecord, + migrationUnsupported, + completionRefreshWorktreeId, + sortRelevantChange, + retentionRelevantChange, + boundaryResolved + } +} diff --git a/src/renderer/src/store/slices/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status-live-reducer.ts new file mode 100644 index 00000000000..2bae1cb0db0 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-reducer.ts @@ -0,0 +1,104 @@ +import type { AppState } from '../types' +import { capLiveAgentStatusesInPlace, classifyPaneKeyLiveness } from './agent-status-pane-helpers' +import { removePaneKeys } from './agent-status-map-helpers' +import { recoveryRecordMatches } from './agent-status-recovery-equivalence' +import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' + +/** Apply the map changes associated with an accepted live status row. */ +export function reduceAgentStatusLiveUpdate( + state: AppState, + build: AgentStatusLiveEntryBuild, + updatedAt: number +): Partial { + const { + entry, + existingSleepingRecord, + liveRecoveryRecord, + registryEntry, + registryMatched, + providerSession, + providerSessionChanged, + migrationUnsupported, + retentionRelevantChange, + sortRelevantChange + } = build + const paneKey = entry.paneKey + let nextRetentionSuppressedPaneKeys = state.retentionSuppressedPaneKeys + if (paneKey in nextRetentionSuppressedPaneKeys) { + nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys } + delete nextRetentionSuppressedPaneKeys[paneKey] + } + const nextRetainedAgents = + paneKey in state.retainedAgentsByPaneKey + ? { ...state.retainedAgentsByPaneKey } + : state.retainedAgentsByPaneKey + if (nextRetainedAgents !== state.retainedAgentsByPaneKey) { + delete nextRetainedAgents[paneKey] + } + let nextSleepingAgentSessions = state.sleepingAgentSessionsByPaneKey + let nextLaunchConfigs = state.agentLaunchConfigByPaneKey + if ( + registryMatched && + registryEntry && + providerSession && + !agentProviderSessionsEqual( + entry.agentType, + registryEntry.identity.providerSession, + providerSession + ) + ) { + nextLaunchConfigs = { + ...nextLaunchConfigs, + [paneKey]: { ...registryEntry, identity: { ...registryEntry.identity, providerSession } } + } + } + // Launch tokens authorize only the session they started; a completed turn or changed provider id consumes them. + if ( + (providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) && + paneKey in state.agentLaunchConfigByPaneKey + ) { + nextLaunchConfigs = { ...state.agentLaunchConfigByPaneKey } + delete nextLaunchConfigs[paneKey] + } + if (liveRecoveryRecord) { + if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) { + nextSleepingAgentSessions = { + ...state.sleepingAgentSessionsByPaneKey, + [paneKey]: liveRecoveryRecord + } + } + } else if (existingSleepingRecord) { + nextSleepingAgentSessions = { ...state.sleepingAgentSessionsByPaneKey } + delete nextSleepingAgentSessions[paneKey] + } + const nextLive = { ...state.agentStatusByPaneKey, [paneKey]: entry } + const evictedPaneKeys = capLiveAgentStatusesInPlace( + nextLive, + paneKey, + () => classifyPaneKeyLiveness(state), + updatedAt + ) + const evictedOrphans = evictedPaneKeys.length > 0 + if (evictedOrphans) { + const evicted = new Set(evictedPaneKeys) + nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evicted) + nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evicted) + } + return { + agentStatusByPaneKey: nextLive, + retainedAgentsByPaneKey: nextRetainedAgents, + sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + agentStatusEpoch: + retentionRelevantChange || migrationUnsupported.changed || evictedOrphans + ? state.agentStatusEpoch + 1 + : state.agentStatusEpoch, + sortEpoch: + sortRelevantChange || migrationUnsupported.changed || evictedOrphans + ? state.sortEpoch + 1 + : state.sortEpoch + } +} diff --git a/src/renderer/src/store/slices/agent-status-map-helpers.ts b/src/renderer/src/store/slices/agent-status-map-helpers.ts new file mode 100644 index 00000000000..7ebf55bbad0 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-map-helpers.ts @@ -0,0 +1,210 @@ +import type { AppState } from '../types' +import type { + AgentStatusOrchestrationContext, + MigrationUnsupportedPtyEntry +} from '../../../../shared/agent-status-types' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId +} from '../../../../shared/execution-host' +import { getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { registryEntryMatchesStatus } from './agent-status-launch-config' +import type { AgentLaunchConfigStatusMetadata } from './agent-status-contract' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' + +export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 + +// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates +// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. +export function boundRecentlyClosedAgentStatusTabIds( + existing: Record, + tabId: string +): Record { + const next: Record = {} + for (const key of Object.keys(existing)) { + if (key !== tabId) { + next[key] = true + } + } + next[tabId] = true + const keys = Object.keys(next) + if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { + delete next[stale] + } + } + return next +} + +export function boundRecentlyRetiredAgentStatusPaneKeys( + existing: Record, + paneKeys: readonly string[] +): Record { + const additions = new Set(paneKeys) + const next: Record = {} + for (const key of Object.keys(existing)) { + if (!additions.has(key)) { + next[key] = true + } + } + for (const paneKey of additions) { + next[paneKey] = true + } + const keys = Object.keys(next) + for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { + delete next[stale] + } + return next +} + +export function movePaneKeyedRecord( + record: Record, + fromPaneKey: string, + toPaneKey: string, + transform: (value: T) => T = (value) => value +): Record { + const value = record[fromPaneKey] + if (value === undefined || fromPaneKey === toPaneKey) { + return record + } + const next = { ...record } + delete next[fromPaneKey] + next[toPaneKey] = transform(value) + return next +} + +export function removePaneKeys( + record: Record, + paneKeys: ReadonlySet +): Record { + const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) + if (matchingKeys.length === 0) { + return record + } + const next = { ...record } + for (const key of matchingKeys) { + delete next[key] + } + return next +} + +export function getLaunchConfigForStatusMetadata( + state: AppState, + metadata: AgentLaunchConfigStatusMetadata +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] + return registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: metadata.paneKey, + agentType: metadata.agentType, + tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, + terminalHandle: metadata.terminalHandle, + launchToken: metadata.launchToken, + providerSession: metadata.providerSession, + existingProviderSession: metadata.existingProviderSession, + providerSessionChanged: metadata.providerSessionChanged ?? false + }) + ? registryEntry?.launchConfig + : undefined +} + +export function pruneMigrationUnsupportedEntries( + entries: Record, + predicate: (entry: MigrationUnsupportedPtyEntry) => boolean +): { next: Record; changed: boolean } { + let changed = false + const next: Record = {} + for (const [ptyId, entry] of Object.entries(entries)) { + if (predicate(entry)) { + changed = true + continue + } + next[ptyId] = entry + } + return { next: changed ? next : entries, changed } +} + +export function orchestrationContextsEqual( + a: AgentStatusOrchestrationContext, + b: AgentStatusOrchestrationContext +): boolean { + return ( + a.taskId === b.taskId && + a.dispatchId === b.dispatchId && + a.dispatchStatus === b.dispatchStatus && + a.taskTitle === b.taskTitle && + a.displayName === b.displayName && + a.parentTerminalHandle === b.parentTerminalHandle && + a.parentPaneKey === b.parentPaneKey && + a.coordinatorHandle === b.coordinatorHandle && + a.orchestrationRunId === b.orchestrationRunId + ) +} + +export function orchestrationMapsEqual( + a: Record, + b: Record +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) +} + +export function mergeCurrentOrchestrationContext( + existing: AgentStatusOrchestrationContext | undefined, + current: AgentStatusOrchestrationContext +): AgentStatusOrchestrationContext { + if (!existing) { + return current + } + const sameDispatch = + existing.taskId === current.taskId && existing.dispatchId === current.dispatchId + if (!sameDispatch) { + return current + } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } + const merged = { ...existing, ...current } + return orchestrationContextsEqual(existing, merged) ? existing : merged +} + +// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never +// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host +// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a +// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same +// path on two hosts yields one shared id that must not clear another host's live rows. +export function collectWorktreeIdsForConnection( + state: AppState, + connectionId: string +): Set { + const hostIdsOnConnection = new Set( + state.repos + .filter((repo) => repo.connectionId === connectionId) + .map((repo) => getRepoExecutionHostId(repo)) + ) + if (hostIdsOnConnection.size === 0) { + return new Set() + } + const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) + const onConnection = new Set() + const onOtherHost = new Set() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + const repo = repoById.get(repoId) + for (const worktree of worktrees) { + const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) + ? onConnection + : onOtherHost + bucket.add(worktree.id) + } + } + // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. + for (const id of onOtherHost) { + onConnection.delete(id) + } + return onConnection +} diff --git a/src/renderer/src/store/slices/agent-status-orchestration-actions.ts b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts new file mode 100644 index 00000000000..2d038ebf29e --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts @@ -0,0 +1,103 @@ +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext +} from '../../../../shared/agent-status-types' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { + getAgentRowGeneratedTitleText, + orchestrationLabelsMatchLiveDispatch +} from '@/lib/agent-row-primary-text' +import { + mergeCurrentOrchestrationContext, + orchestrationMapsEqual +} from './agent-status-map-helpers' + +export function createAgentStatusOrchestrationActions( + runtime: AgentStatusRuntime +): Pick { + const { get, set } = runtime + return { + setRuntimeAgentOrchestrationByPaneKey: ( + entries: Record + ) => { + const generatedTitleUpdates: AgentStatusEntry[] = [] + set((s) => { + const runtimeMapChanged = !orchestrationMapsEqual( + s.runtimeAgentOrchestrationByPaneKey, + entries + ) + let nextLive = s.agentStatusByPaneKey + let liveChanged = false + let nextRetained = s.retainedAgentsByPaneKey + let retainedChanged = false + + for (const [paneKey, runtimeOrchestration] of Object.entries(entries)) { + const liveEntry = nextLive[paneKey] + if (liveEntry) { + const merged = mergeCurrentOrchestrationContext( + liveEntry.orchestration, + runtimeOrchestration + ) + if (merged !== liveEntry.orchestration) { + if (!liveChanged) { + nextLive = { ...nextLive } + liveChanged = true + } + const nextEntry = { ...liveEntry, orchestration: merged } + nextLive[paneKey] = nextEntry + // Why: only replace titles when labels match the live dispatch taskId; sticky completed context must not rename a later turn. + if ( + (merged.displayName?.trim() || merged.taskTitle?.trim()) && + orchestrationLabelsMatchLiveDispatch({ + prompt: nextEntry.prompt, + orchestration: merged + }) + ) { + generatedTitleUpdates.push(nextEntry) + } + } + } + + const retainedEntry = nextRetained[paneKey] + if (retainedEntry) { + const merged = mergeCurrentOrchestrationContext( + retainedEntry.entry.orchestration, + runtimeOrchestration + ) + if (merged !== retainedEntry.entry.orchestration) { + if (!retainedChanged) { + nextRetained = { ...nextRetained } + retainedChanged = true + } + nextRetained[paneKey] = { + ...retainedEntry, + entry: { ...retainedEntry.entry, orchestration: merged } + } + } + } + } + + if (!runtimeMapChanged && !liveChanged && !retainedChanged) { + return s + } + + return { + ...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: entries } : {}), + ...(liveChanged ? { agentStatusByPaneKey: nextLive } : {}), + ...(retainedChanged ? { retainedAgentsByPaneKey: nextRetained } : {}), + ...(liveChanged ? { agentStatusEpoch: s.agentStatusEpoch + 1 } : {}) + } + }) + for (const entry of generatedTitleUpdates) { + get().setGeneratedTabTitleFromAgentPrompt( + entry.paneKey, + getAgentRowGeneratedTitleText(entry), + { + replaceExistingGeneratedTitle: true + } + ) + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-pane-helpers.ts b/src/renderer/src/store/slices/agent-status-pane-helpers.ts new file mode 100644 index 00000000000..49e0fc53af6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-pane-helpers.ts @@ -0,0 +1,277 @@ +import type { AppState } from '../types' +import type { + AgentStatusEntry, + AgentType, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' +import type { AgentStatusTabPrefixDropState } from './agent-status-drop-reducer' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' + +const MAX_RETAINED_AGENTS = 500 + +export function capRetainedAgents( + retained: Record, + maxEntries = MAX_RETAINED_AGENTS +): Record { + const keys = Object.keys(retained) + if (keys.length <= maxEntries) { + return retained + } + const capped: Record = {} + for (const key of keys.slice(keys.length - maxEntries)) { + capped[key] = retained[key] + } + return capped +} + +// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). +export const MAX_LIVE_AGENT_STATUSES = 500 + +type PaneLiveness = 'live' | 'dead' | 'unprovable' + +// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). +export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { + const rootedLeafKeys = new Set() + const rootedTabIds = new Set() + for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (!layout?.root) { + continue + } + rootedTabIds.add(tabId) + const stack: TerminalPaneLayoutNode[] = [layout.root] + while (stack.length > 0) { + const node = stack.pop()! + if (node.type === 'leaf') { + rootedLeafKeys.add(`${tabId}:${node.leafId}`) + } else { + stack.push(node.first, node.second) + } + } + } + return (paneKey) => { + if (rootedLeafKeys.has(paneKey)) { + return 'live' + } + const tabId = getTabIdFromPaneKey(paneKey) + return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' + } +} + +// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. +export function capLiveAgentStatusesInPlace( + freshLive: Record, + protectedPaneKey: string, + buildClassifier: () => (paneKey: string) => PaneLiveness, + now: number, + maxEntries = MAX_LIVE_AGENT_STATUSES +): string[] { + const keys = Object.keys(freshLive) + let overflow = keys.length - maxEntries + if (overflow <= 0) { + return [] + } + const classify = buildClassifier() + const evictedPaneKeys: string[] = [] + const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { + for (const key of keys) { + if (overflow <= 0) { + break + } + if (key === protectedPaneKey || !(key in freshLive)) { + continue + } + const liveness = classify(key) + if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { + continue + } + delete freshLive[key] + overflow -= 1 + evictedPaneKeys.push(key) + } + } + // Prefer rows that are provably dead or too stale to represent a live agent. + sweep( + (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) + // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. + if (overflow > 0) { + sweep(() => true) + } + return evictedPaneKeys +} + +export function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { + for (const prefix of tabPrefixes) { + if (paneKey.startsWith(prefix)) { + return true + } + } + return false +} + +export function isAgentCompletionState(state: ParsedAgentStatusPayload['state']): boolean { + return state === 'done' || state === 'waiting' || state === 'blocked' +} + +export function getTabIdFromPaneKey(paneKey: string): string | null { + const separator = paneKey.indexOf(':') + if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { + return null + } + return paneKey.slice(0, separator) +} + +/** True when auto-title generation would no-op without replace (custom/quick/generated). */ +export function agentStatusTabAlreadyHasProtectedOrGeneratedTitle( + state: AppState, + tabId: string | null, + worktreeId?: string | null +): boolean { + if (!tabId) { + return false + } + const ownerTabs = worktreeId ? state.tabsByWorktree[worktreeId] : undefined + if (ownerTabs) { + const tab = ownerTabs.find((candidate) => candidate.id === tabId) + return Boolean( + tab?.customTitle?.trim() || tab?.quickCommandLabel?.trim() || tab?.generatedTitle?.trim() + ) + } + for (const tabs of Object.values(state.tabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (!tab) { + continue + } + return Boolean( + tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.generatedTitle?.trim() + ) + } + return false +} + +export function getLeafIdFromPaneKey(paneKey: string): string | null { + const separator = paneKey.indexOf(':') + if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { + return null + } + const leafId = paneKey.slice(separator + 1) + return leafId.length > 0 ? leafId : null +} + +export function findCompletedOrphanPaneKeysForTabClose( + state: AgentStatusTabPrefixDropState, + worktreeId: string | undefined, + prefix: string +): string[] { + if (!worktreeId) { + return [] + } + const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const paneKeys: string[] = [] + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) { + continue + } + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId || openTabIds.has(tabId)) { + continue + } + paneKeys.push(paneKey) + } + return paneKeys +} + +export function isRecentlyClosedAgentStatusTab( + closedTabs: Record, + tabId: string | null +): boolean { + if (!tabId) { + return false + } + return closedTabs[tabId] === true +} + +export function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null { + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null +} + +export function findTabForAgentEntry( + state: AppState, + worktreeId: string, + entry: AgentStatusEntry +): TerminalTab | undefined { + const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) + if (!tabId) { + return undefined + } + return (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === tabId) +} + +export function getRetainedFallbackTab(entry: AgentStatusEntry, worktreeId: string): TerminalTab { + const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? entry.paneKey + return { + id: tabId, + ptyId: null, + worktreeId, + title: entry.terminalTitle ?? 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: entry.stateStartedAt + } +} + +export function retainedAgentEntryFromLive( + state: AppState, + worktreeId: string, + entry: AgentStatusEntry, + agentType: AgentType +): RetainedAgentEntry { + const tab = + findTabForAgentEntry(state, worktreeId, entry) ?? getRetainedFallbackTab(entry, worktreeId) + return { + entry, + worktreeId, + tab, + agentType, + startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + } +} + +export function shouldReplaceRetainedWithLive( + retained: RetainedAgentEntry | undefined, + live: RetainedAgentEntry +): boolean { + if (!retained) { + return true + } + if (live.startedAt !== retained.startedAt) { + return live.startedAt > retained.startedAt + } + const retainedSessionId = retained.entry.providerSession?.id + const liveSessionId = live.entry.providerSession?.id + if (retainedSessionId && liveSessionId && retainedSessionId !== liveSessionId) { + return live.entry.updatedAt >= retained.entry.updatedAt + } + return live.entry.updatedAt > retained.entry.updatedAt +} + +export function normalizePaneKeySet( + paneKeys: DropAgentStatusByWorktreeOptions['sleepingPaneKeys'] +): ReadonlySet | null { + if (!paneKeys) { + return null + } + return paneKeys instanceof Set ? paneKeys : new Set(paneKeys) +} diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts new file mode 100644 index 00000000000..36e1f71ab09 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -0,0 +1,177 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import type { + AgentProviderSessionRecordMetadata, + AgentProviderSessionRouting, + AgentProviderSessionTiming +} from './agent-status-contract' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + type ResumableTuiAgent, + type AgentProviderSessionMetadata, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' +import { + findAgentPaneWorktreeId, + getTabIdFromPaneKey, + isRecentlyClosedAgentStatusTab +} from './agent-status-pane-helpers' +import { removePaneKeys } from './agent-status-map-helpers' +import { registryEntryMatchesStatus } from './agent-status-launch-config' +import { copyLaunchConfig } from './agent-status-recovery-helpers' + +export function createAgentStatusProviderSessionActions( + runtime: AgentStatusRuntime +): Pick { + const { get, set, requestFreshness } = runtime + return { + recordAgentProviderSession: ( + paneKey: string, + agent: ResumableTuiAgent, + providerSession: AgentProviderSessionMetadata, + timing?: AgentProviderSessionTiming, + routing?: AgentProviderSessionRouting, + metadata?: AgentProviderSessionRecordMetadata + ) => { + paneKey = resolveAgentPaneAuthorityKey(paneKey) + const updatedAt = timing?.updatedAt ?? Date.now() + if ( + paneKey in get().recentlyRetiredAgentStatusPaneKeys || + isRecentlyClosedAgentStatusTab( + get().recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(paneKey) + ) || + !getAgentResumeArgv(agent, providerSession) + ) { + return + } + let removedLiveStatus = false + set((s) => { + const existingStatus = s.agentStatusByPaneKey[paneKey] + const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] + if ( + (existingStatus && updatedAt < existingStatus.updatedAt) || + (existingRecord && updatedAt < existingRecord.updatedAt) + ) { + return s + } + const tabId = routing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? existingRecord?.tabId + const worktreeId = + routing?.worktreeId ?? + existingStatus?.worktreeId ?? + existingRecord?.worktreeId ?? + findAgentPaneWorktreeId(s, paneKey) + if (!worktreeId) { + return s + } + const registryEntry = s.agentLaunchConfigByPaneKey[paneKey] + const registryMatches = registryEntryMatchesStatus({ + entry: registryEntry, + paneKey, + agentType: agent, + tabId, + terminalHandle: undefined, + launchToken: metadata?.launchToken, + providerSession, + existingProviderSession: existingRecord?.providerSession, + providerSessionChanged: false + }) + const existingRecordMatchesProviderSession = + existingRecord?.agent === agent && + agentProviderSessionsEqual(agent, existingRecord.providerSession, providerSession) + // Why: provider-session heartbeats can arrive after the turn is complete; preserve the + // completed checkpoint so a late heartbeat cannot make it eligible for ghost resume. + const preservesCompletedRecoveryRecord = + existingRecordMatchesProviderSession && existingRecord?.state === 'done' + // Why: an explicit quit capture must remain the resume handle until a new provider session replaces it. + const preservesQuitOrigin = + existingRecordMatchesProviderSession && existingRecord?.origin === 'quit' + const launchConfig = + (registryMatches ? registryEntry?.launchConfig : undefined) ?? + (existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined) + const record: SleepingAgentSessionRecord = { + paneKey, + ...(tabId ? { tabId } : {}), + worktreeId, + agent, + providerSession, + prompt: '', + // Why: durable process/session identity, not visible turn state; a non-done value keeps cold restore eligible. + state: preservesCompletedRecoveryRecord ? 'done' : 'working', + capturedAt: updatedAt, + updatedAt, + ...(existingStatus?.terminalTitle + ? { terminalTitle: existingStatus.terminalTitle } + : existingRecord?.terminalTitle + ? { terminalTitle: existingRecord.terminalTitle } + : {}), + ...(routing?.connectionId !== undefined + ? { connectionId: routing.connectionId } + : existingRecord?.connectionId !== undefined + ? { connectionId: existingRecord.connectionId } + : {}), + ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), + ...(existingRecordMatchesProviderSession && + existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' + ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } + : {}), + ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined + ? { interrupted: existingRecord.interrupted } + : {}), + origin: preservesQuitOrigin ? 'quit' : 'live' + } + removedLiveStatus = existingStatus !== undefined + const nextLive = removedLiveStatus ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (removedLiveStatus) { + delete nextLive[paneKey] + } + const nextRetained = + paneKey in s.retainedAgentsByPaneKey + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (nextRetained !== s.retainedAgentsByPaneKey) { + delete nextRetained[paneKey] + } + // Why: on identity mismatch the sleeping record drops its launch config, so clear the stale + // registry entry too, else a later return to the old identity reuses stale args/env. + let nextLaunchConfigs = s.agentLaunchConfigByPaneKey + if (registryMatches && registryEntry) { + nextLaunchConfigs = { + ...nextLaunchConfigs, + [paneKey]: { + ...registryEntry, + identity: { ...registryEntry.identity, providerSession } + } + } + } else if (registryEntry) { + nextLaunchConfigs = { ...nextLaunchConfigs } + delete nextLaunchConfigs[paneKey] + } + return { + agentStatusByPaneKey: nextLive, + retainedAgentsByPaneKey: nextRetained, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: record + }, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + acknowledgedAgentsByPaneKey: removePaneKeys( + s.acknowledgedAgentsByPaneKey, + new Set([paneKey]) + ), + unreadAgentCompletionPanes: removePaneKeys( + s.unreadAgentCompletionPanes, + new Set([paneKey]) + ), + agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (removedLiveStatus) { + requestFreshness(true) + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts new file mode 100644 index 00000000000..d3109b3cb88 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -0,0 +1,198 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection' +import { + removeSleepingRecordsReplacedByManualWorktreeSleep, + sleepingRecordFromEntry +} from './agent-status-recovery-helpers' +import { + recoveryRecordTargetsSameSession, + sleepingRecordsEquivalentIgnoringCaptureTime +} from './agent-status-recovery-equivalence' +import { getLaunchConfigForEntry } from './agent-status-launch-config' +import { findAgentPaneWorktreeId } from './agent-status-pane-helpers' +import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' + +export function createAgentStatusRecoveryActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'captureSleepingAgentSessionsByWorktree' + | 'captureAllSleepingAgentSessions' + | 'clearSleepingAgentSession' + | 'clearSleepingAgentSessionsByPaneKey' + | 'setSleepingAgentAutomaticResumeBlocked' + | 'clearSleepingAgentSessionsByWorktree' + | 'pruneSleepingAgentSessions' +> { + const { set, clearSleepingAgentSessionsByPaneKey } = runtime + return { + captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => { + set((s) => { + const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { + paneKeys, + captureMode: 'manual-worktree-sleep' + }) + const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep( + s.sleepingAgentSessionsByPaneKey, + worktreeId, + paneKeys, + records + ) + let next = { ...replaced.records } + let changed = replaced.changed + for (const record of Object.values(records)) { + if (next[record.paneKey] !== record) { + next[record.paneKey] = record + changed = true + } + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + + captureAllSleepingAgentSessions: (mode) => { + set((s) => { + const capturedAt = Date.now() + const origin = mode === 'quit' ? ('quit' as const) : ('live' as const) + const next: Record = { + ...s.sleepingAgentSessionsByPaneKey + } + let changed = false + for (const entry of Object.values(s.agentStatusByPaneKey)) { + if (entry.state === 'done') { + const existing = next[entry.paneKey] + if ( + !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(entry, existing) || + mode === 'periodic' + ) { + continue + } + const record = { ...existing, capturedAt, origin } + if (!sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { + next[entry.paneKey] = record + changed = true + } + continue + } + const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey) + if (!worktreeId) { + continue + } + const record = sleepingRecordFromEntry({ + state: s, + entry, + worktreeId, + capturedAt, + launchConfig: getLaunchConfigForEntry(s, entry), + origin + }) + const existing = next[entry.paneKey] + if ( + mode === 'periodic' && + existing?.origin === 'quit' && + record && + recoveryRecordTargetsSameSession(existing, record) + ) { + continue + } + if (record && !sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { + next[record.paneKey] = record + changed = true + } + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + + clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), + clearSleepingAgentSessionsByPaneKey, + + setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { + set((s) => { + const current = s.sleepingAgentSessionsByPaneKey[paneKey] + if ( + !current || + (blocked + ? current.automaticResumeBlockedBy === 'legacy-orchestration-worker' + : current.automaticResumeBlockedBy === undefined) + ) { + return s + } + const next = { ...current } + if (blocked) { + next.automaticResumeBlockedBy = 'legacy-orchestration-worker' + } else { + delete next.automaticResumeBlockedBy + } + return { + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: next + } + } + }) + }, + + clearSleepingAgentSessionsByWorktree: (worktreeId) => { + set((s) => { + let changed = false + const next: Record = {} + const removed: string[] = [] + for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { + if (record.worktreeId === worktreeId) { + changed = true + removed.push(paneKey) + } else { + next[paneKey] = record + } + } + if (!changed) { + return s + } + const nextLaunch = + removed.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : s.agentLaunchConfigByPaneKey + for (const paneKey of removed) { + delete nextLaunch[paneKey] + } + return { + sleepingAgentSessionsByPaneKey: next, + ...(nextLaunch !== s.agentLaunchConfigByPaneKey + ? { agentLaunchConfigByPaneKey: nextLaunch } + : {}) + } + }) + }, + + pruneSleepingAgentSessions: (validWorktreeIds) => { + set((s) => { + let changed = false + const next: Record = {} + const removed: string[] = [] + for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { + if (!validWorktreeIds.has(record.worktreeId)) { + changed = true + removed.push(paneKey) + } else { + next[paneKey] = record + } + } + if (!changed) { + return s + } + const nextLaunch = + removed.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : s.agentLaunchConfigByPaneKey + for (const paneKey of removed) { + delete nextLaunch[paneKey] + } + return { + sleepingAgentSessionsByPaneKey: next, + ...(nextLaunch !== s.agentLaunchConfigByPaneKey + ? { agentLaunchConfigByPaneKey: nextLaunch } + : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-collection.ts b/src/renderer/src/store/slices/agent-status-recovery-collection.ts new file mode 100644 index 00000000000..d5c0b1cd37b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-collection.ts @@ -0,0 +1,174 @@ +import type { AppState } from '../types' +import { + getAgentResumeArgv, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { RetainedAgentEntry } from './agent-status-contract' +import { + normalizePaneKeySet, + paneKeyMatchesAnyTabPrefix, + retainedAgentEntryFromLive +} from './agent-status-pane-helpers' +import { + carryOverAutomaticResumeBlock, + isValidCompletedAgentHibernationEntry, + manualSleepCaptureEntry, + markManualSleepLazyRestore, + normalizeSleepingAgentSessionCollectOptions, + sleepingRecordFromEntry, + type CollectSleepingAgentSessionRecordsOptions +} from './agent-status-recovery-helpers' +import { getLaunchConfigForEntry } from './agent-status-launch-config' +import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' + +export function collectSleepingAgentSessionRecordsForWorktree( + state: AppState, + worktreeId: string, + options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions +): Record { + const capturedAt = Date.now() + const collectOptions = normalizeSleepingAgentSessionCollectOptions(options) + const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null + const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep' + const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation' + const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation + // Why: hibernated completions are intentional worktree-owned records; wake treats + // originless completed records as ambiguous legacy captures. + const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture + ? 'worktree-sleep' + : undefined + const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const records: Record = {} + const promotedLiveRecoveryPaneKeys = new Set() + + if (isManualWorktreeSleep) { + for (const existing of Object.values(state.sleepingAgentSessionsByPaneKey)) { + const liveEntry = state.agentStatusByPaneKey[existing.paneKey] + if ( + existing.worktreeId !== worktreeId || + existing.origin !== 'live' || + (liveEntry !== undefined && + !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(liveEntry, existing)) || + (allowedPaneKeys && !allowedPaneKeys.has(existing.paneKey)) || + !getAgentResumeArgv(existing.agent, existing.providerSession) + ) { + continue + } + // Why: Pi identity is resumable with no turn row and while idle after done, so manual + // sleep must promote both instead of deleting the checkpoint. + records[existing.paneKey] = { + ...existing, + state: 'working', + capturedAt, + updatedAt: capturedAt, + origin: 'worktree-sleep' + } + promotedLiveRecoveryPaneKeys.add(existing.paneKey) + } + } + + for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + if (isCompletedAgentHibernation) { + continue + } + if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { + continue + } + if (retained.worktreeId !== worktreeId) { + continue + } + // Why: the promoted checkpoint carries recovery identity (transcript, connection) a retained + // turn row lacks, so it must not be overwritten by a re-derived record. + if (promotedLiveRecoveryPaneKeys.has(retained.entry.paneKey)) { + continue + } + const record = sleepingRecordFromEntry({ + state, + entry: isManualWorktreeSleep + ? manualSleepCaptureEntry(retained.entry, capturedAt) + : retained.entry, + worktreeId, + tab: retained.tab, + capturedAt, + launchConfig: getLaunchConfigForEntry(state, retained.entry), + origin + }) + if (record) { + if (isManualWorktreeSleep) { + markManualSleepLazyRestore(record) + carryOverAutomaticResumeBlock( + record, + state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] + ) + } + records[record.paneKey] = record + } + } + + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { + continue + } + // Why: the promoted checkpoint carries recovery identity (transcript, connection) the live + // turn row lacks, so it must not be overwritten by a re-derived record. + if (promotedLiveRecoveryPaneKeys.has(paneKey)) { + continue + } + const belongsToWorktree = + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + if (!belongsToWorktree) { + continue + } + if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { + continue + } + const record = sleepingRecordFromEntry({ + state, + entry: isManualWorktreeSleep ? manualSleepCaptureEntry(entry, capturedAt) : entry, + worktreeId, + capturedAt, + launchConfig: getLaunchConfigForEntry(state, entry), + origin + }) + if (record) { + if (isManualWorktreeSleep) { + markManualSleepLazyRestore(record) + carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) + } + records[record.paneKey] = record + } + } + + return records +} + +export function collectHibernatedCompletionEvidenceForWorktree( + state: AppState, + worktreeId: string, + paneKeys?: readonly string[] +): RetainedAgentEntry[] { + const allowedPaneKeys = normalizePaneKeySet(paneKeys) + if (!allowedPaneKeys || allowedPaneKeys.size === 0) { + return [] + } + const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const retained: RetainedAgentEntry[] = [] + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + const agentType = entry.agentType + if ( + !allowedPaneKeys.has(paneKey) || + entry.state !== 'done' || + agentType === undefined || + entry.interrupted === true + ) { + continue + } + const belongsToWorktree = + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + if (!belongsToWorktree) { + continue + } + retained.push(retainedAgentEntryFromLive(state, worktreeId, entry, agentType)) + } + return retained +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts new file mode 100644 index 00000000000..660d104abf6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts @@ -0,0 +1,83 @@ +import type { + SleepingAgentSessionRecord, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' + +export function launchConfigsEqual( + a: SleepingAgentLaunchConfig | undefined, + b: SleepingAgentLaunchConfig | undefined +): boolean { + if (a === undefined || b === undefined) { + return a === b + } + if ( + a.agentCommand !== b.agentCommand || + a.agentArgs !== b.agentArgs || + a.ompResumeFilePath !== b.ompResumeFilePath + ) { + return false + } + const aKeys = Object.keys(a.agentEnv) + const bKeys = Object.keys(b.agentEnv) + return aKeys.length === bKeys.length && aKeys.every((key) => a.agentEnv[key] === b.agentEnv[key]) +} + +export function sleepingRecordsEquivalentIgnoringCaptureTime( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.paneKey === next.paneKey && + existing.tabId === next.tabId && + existing.worktreeId === next.worktreeId && + existing.agent === next.agent && + agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && + existing.prompt === next.prompt && + existing.state === next.state && + existing.updatedAt === next.updatedAt && + existing.terminalTitle === next.terminalTitle && + existing.lastAssistantMessage === next.lastAssistantMessage && + existing.interrupted === next.interrupted && + existing.origin === next.origin && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + +export function recoveryRecordMatches( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + // Why: completion or interruption must replace a pre-status working checkpoint. + return ( + existing.origin === next.origin && + existing.agent === next.agent && + existing.worktreeId === next.worktreeId && + existing.tabId === next.tabId && + existing.state === next.state && + existing.interrupted === next.interrupted && + agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + +export function recoveryRecordTargetsSameSession( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.agent === next.agent && + existing.worktreeId === next.worktreeId && + existing.tabId === next.tabId && + agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) + ) +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-helpers.ts b/src/renderer/src/store/slices/agent-status-recovery-helpers.ts new file mode 100644 index 00000000000..9b7a07c4471 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-helpers.ts @@ -0,0 +1,152 @@ +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + isResumableTuiAgent, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { findTabForAgentEntry } from './agent-status-pane-helpers' + +export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { + return { + ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), + agentArgs: config.agentArgs, + agentEnv: { ...config.agentEnv }, + ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) + } +} + +export function sleepingRecordFromEntry(args: { + state: AppState + entry: AgentStatusEntry + worktreeId: string + tab?: TerminalTab + capturedAt: number + launchConfig?: SleepingAgentLaunchConfig + origin?: SleepingAgentSessionRecord['origin'] +}): SleepingAgentSessionRecord | null { + const agent = args.entry.agentType + if ( + args.entry.terminalResumeEligible === false || + !isResumableTuiAgent(agent) || + !args.entry.providerSession + ) { + return null + } + if (!getAgentResumeArgv(agent, args.entry.providerSession)) { + return null + } + const tab = args.tab ?? findTabForAgentEntry(args.state, args.worktreeId, args.entry) + return { + paneKey: args.entry.paneKey, + ...(tab ? { tabId: tab.id } : {}), + worktreeId: args.worktreeId, + agent, + providerSession: args.entry.providerSession, + ...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}), + prompt: args.entry.prompt, + state: args.entry.state, + capturedAt: args.capturedAt, + updatedAt: args.entry.updatedAt, + ...((args.entry.terminalTitle ?? tab?.title) + ? { terminalTitle: (args.entry.terminalTitle ?? tab?.title)! } + : {}), + ...(args.entry.lastAssistantMessage + ? { lastAssistantMessage: args.entry.lastAssistantMessage } + : {}), + ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), + ...(args.entry.interrupted ? { interrupted: true } : {}), + ...(args.origin ? { origin: args.origin } : {}) + } +} + +export type CollectSleepingAgentSessionRecordsOptions = { + paneKeys?: readonly string[] + captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation' +} + +export function normalizeSleepingAgentSessionCollectOptions( + options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined +): CollectSleepingAgentSessionRecordsOptions { + if (!options) { + return {} + } + return Array.isArray(options) + ? { paneKeys: options } + : (options as CollectSleepingAgentSessionRecordsOptions) +} + +export function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { + return entry.state === 'done' && entry.interrupted !== true +} + +// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive +// record's tab. Sleeping a workspace must not become "one phone tap respawns all of it" — the pane +// issues its own `--resume` cold restore when its tab is opened instead (#11598). +export function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): void { + if (record.state === 'done') { + record.restoreOnTabOpenOnly = true + } +} + +// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit +// sleep or quit capture is the pane's only resume handle once its live row is gone. +export function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { + return record.origin === 'worktree-sleep' || record.origin === 'quit' +} + +// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead +// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the +// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved +// so a done pane wakes lazily in place instead of spawning a new tab. +export function manualSleepCaptureEntry( + entry: AgentStatusEntry, + capturedAt: number +): AgentStatusEntry { + return { ...entry, updatedAt: capturedAt, interrupted: false } +} + +// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately +// blocked worker must not become auto-resumable at wake. +export function carryOverAutomaticResumeBlock( + record: SleepingAgentSessionRecord, + previous: SleepingAgentSessionRecord | undefined +): void { + if ( + previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && + previous.agent === record.agent && + agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) + ) { + record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy + } +} + +export function removeSleepingRecordsReplacedByManualWorktreeSleep( + records: Record, + worktreeId: string, + paneKeys?: readonly string[], + replacements?: Readonly> +): { records: Record; changed: boolean } { + const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + let next = records + let changed = false + for (const [paneKey, record] of Object.entries(records)) { + if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { + continue + } + // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the + // pane was never woken, so it has no live status row to rebuild it from (#11598). + if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { + continue + } + if (next === records) { + next = { ...records } + } + delete next[paneKey] + changed = true + } + return { records: next, changed } +} diff --git a/src/renderer/src/store/slices/agent-status-retention-actions.ts b/src/renderer/src/store/slices/agent-status-retention-actions.ts new file mode 100644 index 00000000000..884a4ca6fa4 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-retention-actions.ts @@ -0,0 +1,141 @@ +import type { RetainedAgentEntry } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' +import { capRetainedAgents } from './agent-status-pane-helpers' + +export function createAgentStatusRetentionActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'retainAgents' + | 'dismissRetainedAgent' + | 'dismissRetainedAgentsByWorktree' + | 'pruneRetainedAgents' + | 'clearRetentionSuppressedPaneKeys' +> { + const { set } = runtime + return { + retainAgents: (entries: RetainedAgentEntry[]) => { + if (entries.length === 0) { + return + } + set((s) => { + let changed = false + for (const retained of entries) { + if (s.retainedAgentsByPaneKey[retained.entry.paneKey] !== retained) { + changed = true + break + } + } + if (!changed) { + return s + } + const next = { ...s.retainedAgentsByPaneKey } + for (const retained of entries) { + const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[retained.entry.paneKey] + const mergedOrchestration = runtimeOrchestration + ? mergeCurrentOrchestrationContext(retained.entry.orchestration, runtimeOrchestration) + : retained.entry.orchestration + const entry = + mergedOrchestration !== retained.entry.orchestration + ? { ...retained.entry, orchestration: mergedOrchestration } + : retained.entry + next[retained.entry.paneKey] = + entry === retained.entry ? retained : { ...retained, entry } + } + return { retainedAgentsByPaneKey: capRetainedAgents(next) } + }) + }, + + dismissRetainedAgent: (paneKey) => { + set((s) => { + if (!(paneKey in s.retainedAgentsByPaneKey)) { + return s + } + const next = { ...s.retainedAgentsByPaneKey } + delete next[paneKey] + const hasLive = paneKey in s.agentStatusByPaneKey + if (!hasLive || paneKey in s.retentionSuppressedPaneKeys) { + return { retainedAgentsByPaneKey: next } + } + return { + retainedAgentsByPaneKey: next, + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + }) + }, + + dismissRetainedAgentsByWorktree: (worktreeId) => { + const dismissedPaneKeys: string[] = [] + set((s) => { + let changed = false + const next: Record = {} + const toSuppress: string[] = [] + for (const [key, retained] of Object.entries(s.retainedAgentsByPaneKey)) { + if (retained.worktreeId === worktreeId) { + changed = true + dismissedPaneKeys.push(key) + if (key in s.agentStatusByPaneKey && !(key in s.retentionSuppressedPaneKeys)) { + toSuppress.push(key) + } + } else { + next[key] = retained + } + } + if (!changed) { + return s + } + if (toSuppress.length === 0) { + return { retainedAgentsByPaneKey: next } + } + const nextSuppressed = { ...s.retentionSuppressedPaneKeys } + for (const key of toSuppress) { + nextSuppressed[key] = true + } + return { + retainedAgentsByPaneKey: next, + retentionSuppressedPaneKeys: nextSuppressed + } + }) + if (typeof window !== 'undefined') { + for (const paneKey of dismissedPaneKeys) { + window.api?.agentStatus?.drop?.(paneKey) + } + } + }, + + pruneRetainedAgents: (validWorktreeIds) => { + set((s) => { + let changed = false + const next: Record = {} + for (const [key, retained] of Object.entries(s.retainedAgentsByPaneKey)) { + if (!validWorktreeIds.has(retained.worktreeId)) { + changed = true + } else { + next[key] = retained + } + } + return changed ? { retainedAgentsByPaneKey: next } : s + }) + }, + + clearRetentionSuppressedPaneKeys: (paneKeys) => { + set((s) => { + let changed = false + const next = { ...s.retentionSuppressedPaneKeys } + for (const paneKey of paneKeys) { + if (!(paneKey in next)) { + continue + } + delete next[paneKey] + changed = true + } + return changed ? { retentionSuppressedPaneKeys: next } : s + }) + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts new file mode 100644 index 00000000000..64dc343f122 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -0,0 +1,224 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' +import { createFreshnessScheduler } from './agent-status-freshness-scheduler' + +export type AgentStatusStateUpdate = + | AppState + | Partial + | ((state: AppState) => AppState | Partial) + +export type AgentStatusRuntime = { + get: () => AppState + set: (update: AgentStatusStateUpdate) => void + runAfterCommit: (effect: () => void) => void + applyGeneratedTabTitleUpdate: (update: GeneratedTabTitleUpdate) => void + requestFreshness: (acceptedInBatch: boolean) => void + freshness: ReturnType + transactAgentStatuses: ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ) => Result + clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void +} + +export function createAgentStatusRuntime( + storeSet: Parameters>[0], + storeGet: Parameters>[1], + getActions: () => Pick +): AgentStatusRuntime { + let batchedAgentStatusState: AppState | null = null + let batchedAgentStatusEffects: (() => void)[] | null = null + let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null + let batchedAgentStatusFreshnessRequested = false + const get = (): AppState => batchedAgentStatusState ?? storeGet() + + // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in + // this slice can compile into a REPLACE the batch commit is unable to express. + const set = (update: AgentStatusStateUpdate): void => { + if (batchedAgentStatusState === null) { + storeSet(update, false) + return + } + const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update + if (Object.is(nextState, batchedAgentStatusState)) { + return + } + batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState) + } + + const runAfterCommit = (effect: () => void): void => { + if (batchedAgentStatusEffects) { + batchedAgentStatusEffects.push(effect) + return + } + effect() + } + + const applyGeneratedTabTitleUpdate = (update: GeneratedTabTitleUpdate): void => { + if (batchedGeneratedTabTitleUpdates) { + batchedGeneratedTabTitleUpdates.push(update) + return + } + if (update.options) { + get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options) + } else { + get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt) + } + } + + const requestFreshness = (acceptedInBatch: boolean): void => { + if (batchedAgentStatusState !== null) { + batchedAgentStatusFreshnessRequested ||= acceptedInBatch + return + } + freshness.scheduleDeferred() + } + + const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => { + const stateBeforeUpdate = batchedAgentStatusState + if (!stateBeforeUpdate) { + return false + } + const actions = getActions() + if (update.kind === 'providerSession') { + actions.recordAgentProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + } else { + actions.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + } + return batchedAgentStatusState !== stateBeforeUpdate + } + + const batchTransaction: AgentStatusBatchTransaction = { + getState: get, + apply: applyBatchedAgentStatusUpdate, + afterCommit: runAfterCommit + } + + const transactAgentStatuses = ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ): Result => { + if (batchedAgentStatusState) { + return operation(batchTransaction) + } + const initialState = storeGet() + batchedAgentStatusState = initialState + batchedAgentStatusEffects = [] + batchedGeneratedTabTitleUpdates = [] + try { + const result = operation(batchTransaction) + const nextState = batchedAgentStatusState + const effects = batchedAgentStatusEffects + const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates + const freshnessRequested = batchedAgentStatusFreshnessRequested + batchedAgentStatusState = null + batchedAgentStatusEffects = null + batchedGeneratedTabTitleUpdates = null + batchedAgentStatusFreshnessRequested = false + if (nextState !== initialState) { + storeSet(buildAgentStatusBatchPatch(initialState, nextState), false) + } + if (generatedTabTitleUpdates.length > 0) { + storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) + } + if (freshnessRequested) { + freshness.scheduleDeferred() + } + for (const effect of effects) { + effect() + } + return result + } finally { + batchedAgentStatusState = null + batchedAgentStatusEffects = null + batchedGeneratedTabTitleUpdates = null + batchedAgentStatusFreshnessRequested = false + } + } + + const freshness = createFreshnessScheduler({ + getEntries: () => Object.values(get().agentStatusByPaneKey), + bumpEpochs: () => { + // Why: freshness is time-based — bump both epochs at the stale boundary to force selector + // recompute and re-sort even with no new output, since staleness can change worktree ordering. + set((s) => ({ + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + })) + } + }) + + const clearSleepingAgentSessionsByPaneKey = (paneKeys: readonly string[]): void => { + if (paneKeys.length === 0) { + return + } + const uniquePaneKeys = new Set(paneKeys) + set((s) => { + let nextSleeping = s.sleepingAgentSessionsByPaneKey + let nextLaunchConfigs = s.agentLaunchConfigByPaneKey + for (const paneKey of uniquePaneKeys) { + if (paneKey in nextSleeping) { + if (nextSleeping === s.sleepingAgentSessionsByPaneKey) { + nextSleeping = { ...nextSleeping } + } + delete nextSleeping[paneKey] + } + if (paneKey in nextLaunchConfigs) { + if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) { + nextLaunchConfigs = { ...nextLaunchConfigs } + } + delete nextLaunchConfigs[paneKey] + } + } + if ( + nextSleeping === s.sleepingAgentSessionsByPaneKey && + nextLaunchConfigs === s.agentLaunchConfigByPaneKey + ) { + return s + } + return { + sleepingAgentSessionsByPaneKey: nextSleeping, + agentLaunchConfigByPaneKey: nextLaunchConfigs + } + }) + } + + return { + get, + set, + runAfterCommit, + applyGeneratedTabTitleUpdate, + requestFreshness, + freshness, + transactAgentStatuses, + clearSleepingAgentSessionsByPaneKey + } +} + +function buildAgentStatusBatchPatch( + initialState: AppState, + nextState: AppState +): Partial { + const patch: Record = {} + for (const key of Object.keys(nextState) as (keyof AppState)[]) { + if (!Object.is(nextState[key], initialState[key])) { + patch[key as string] = nextState[key] + } + } + return patch as Partial +} diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts new file mode 100644 index 00000000000..c1598f77a5a --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -0,0 +1,178 @@ +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata, + AgentLaunchConfigStatusMetadata, + AgentProviderSessionRecordMetadata, + AgentProviderSessionRouting, + AgentProviderSessionTiming, + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming, + AgentProviderSessionMetadata, + DropAgentStatusByTabPrefixOptions, + DropAgentStatusByWorktreeOptions, + DropHibernatedAgentPaneOptions, + RetainedAgentEntry, + AllAgentSessionCaptureMode +} from './agent-status-contract' +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext, + MigrationUnsupportedPtyEntry +} from '../../../../shared/agent-status-types' +import type { + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' + +export type AgentStatusSlice = { + /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ + agentStatusByPaneKey: Record + /** Main-synced dispatch metadata for live panes that may only have title-derived status in the renderer. */ + runtimeAgentOrchestrationByPaneKey: Record + /** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */ + migrationUnsupportedByPtyId: Record + /** Monotonic tick that advances when agent-status freshness boundaries pass. */ + agentStatusEpoch: number + /** SSH connections whose transient rows were cleared and must reject renderer callbacks + * until a later reconnect establishes a new connection lifecycle. */ + transientClearedAgentStatusConnectionIds: Record + /** Arm the shared freshness timer after an external mirror writes live rows. */ + scheduleAgentStatusFreshness: () => void + + /** Retained "done" snapshots of agents gone from `agentStatusByPaneKey`, keyed by paneKey so pane re-appearance overwrites; shared by dashboard and sidebar hover. */ + retainedAgentsByPaneKey: Record + + /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ + sleepingAgentSessionsByPaneKey: Record + + /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ + agentLaunchConfigByPaneKey: Record + + /** Pane keys explicitly torn down, forbidden from re-retention on next disappearance; a one-shot suppressor consumed by the retention sync. */ + retentionSuppressedPaneKeys: Record + + /** Terminal tabs explicitly closed this session; used to drop late in-flight IPC statuses and stale main-cache replays. */ + recentlyClosedAgentStatusTabIds: Record + + /** Exact pane authorities retired while sibling panes in the tab stay live. */ + recentlyRetiredAgentStatusPaneKeys: Record + + retireAgentPaneAuthority: ( + paneKey: string, + options?: { preserveSleepingAgentSession?: boolean } + ) => void + /** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */ + restoreAgentPaneAuthority: (paneKey: string) => void + transferAgentPaneAuthority: (args: { + fromPaneKey: string + toPaneKey: string + ptyId?: string | null + }) => void + + /** Update or insert an agent status entry from a status payload. */ + setAgentStatus: ( + paneKey: string, + payload: AgentStatusPayload, + terminalTitle?: string, + timing?: AgentStatusTiming, + routing?: AgentStatusRouting, + metadata?: AgentStatusMetadata + ) => void + + /** Apply ordered status updates as one status publication (generated titles and tab + * titles still publish after it — three total, not 2N). */ + setAgentStatuses: (updates: readonly AgentStatusBatchUpdate[]) => boolean[] + + /** Fold caller-derived updates against exact staged state, committing one status publication. */ + transactAgentStatuses: ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ) => Result + + /** Record resume identity without creating a visible turn-status row. */ + recordAgentProviderSession: ( + paneKey: string, + agent: ResumableTuiAgent, + providerSession: AgentProviderSessionMetadata, + timing?: AgentProviderSessionTiming, + routing?: AgentProviderSessionRouting, + metadata?: AgentProviderSessionRecordMetadata + ) => void + + registerAgentLaunchConfig: ( + paneKey: string, + launchConfig: SleepingAgentLaunchConfig, + metadata?: AgentLaunchConfigRegistrationMetadata + ) => void + getAgentLaunchConfigForStatusEntry: ( + entry: AgentStatusEntry + ) => SleepingAgentLaunchConfig | undefined + getAgentLaunchConfigForStatusMetadata: ( + metadata: AgentLaunchConfigStatusMetadata + ) => SleepingAgentLaunchConfig | undefined + clearAgentLaunchConfig: (paneKey: string) => void + + setRuntimeAgentOrchestrationByPaneKey: ( + entries: Record + ) => void + + setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void + clearMigrationUnsupportedPty: (ptyId: string) => void + + /** Remove a single entry (e.g., when a pane's terminal exits). */ + removeAgentStatus: (paneKey: string) => void + + /** Remove all entries whose paneKey starts with the given prefix (tab close prefix-sweep). */ + removeAgentStatusByTabPrefix: (tabIdPrefix: string) => void + + /** Remove stale live rows while preserving pane launch and resume identity. */ + clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void + + /** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */ + dropAgentStatus: (paneKey: string) => void + + /** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */ + dropAgentStatusByTabPrefix: ( + tabIdPrefix: string, + opts?: DropAgentStatusByTabPrefixOptions + ) => void + + /** Remove one auto-hibernated completed-agent pane while preserving sibling live/retained rows in the same worktree. */ + dropHibernatedAgentStatusPane: ( + worktreeId: string, + paneKey: string, + opts?: DropHibernatedAgentPaneOptions + ) => void + + /** Remove all entries for a worktree AND suppress re-retention for live rows (worktree sleep/remove). + * Sweeps live rows by tab prefix and by main-stamped worktree attribution so worker rows that arrive before their tab don't survive. */ + dropAgentStatusByWorktree: (worktreeId: string, opts?: DropAgentStatusByWorktreeOptions) => void + + captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void + /** Capture resumable agent sessions across every worktree for crash recovery or quit; mode sets live/quit precedence. */ + captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void + clearSleepingAgentSession: (paneKey: string) => void + clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void + setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void + clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void + pruneSleepingAgentSessions: (validWorktreeIds: Set) => void + + /** Retain agent snapshots. Accepts an array so simultaneous disappearances produce a single set() with no mid-loop intermediate states. */ + retainAgents: (entries: RetainedAgentEntry[]) => void + + /** Dismiss a retained entry by its paneKey. */ + dismissRetainedAgent: (paneKey: string) => void + + /** Dismiss all retained entries belonging to a worktree. */ + dismissRetainedAgentsByWorktree: (worktreeId: string) => void + + /** Prune retained entries whose worktreeId is not in the given set. */ + pruneRetainedAgents: (validWorktreeIds: Set) => void + + /** Clear one-shot teardown suppressors after the retention sync declines to retain the row. */ + clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void +} diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts new file mode 100644 index 00000000000..f6820296735 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -0,0 +1,157 @@ +import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { + normalizePaneKeySet, + paneKeyMatchesAnyTabPrefix, + retainedAgentEntryFromLive, + shouldReplaceRetainedWithLive +} from './agent-status-pane-helpers' + +export function createAgentStatusWorktreeDropActions( + runtime: AgentStatusRuntime +): Pick { + const { set, freshness } = runtime + return { + dropAgentStatusByWorktree: (worktreeId, opts?: DropAgentStatusByWorktreeOptions) => { + let hadLive = false + set((s) => { + const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( + ([paneKey, entry]) => + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + ) + const liveKeys = liveEntries.map(([paneKey]) => paneKey) + const liveKeySet = new Set(liveKeys) + const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( + (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) + ) + const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) + .filter( + ([paneKey, retained]) => + retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + ) + .map(([paneKey]) => paneKey) + const retainedKeySet = new Set(retainedKeys) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => + entry.worktreeId === worktreeId || + (entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false) + ) + const allowedPaneKeys = normalizePaneKeySet(opts?.sleepingPaneKeys) + const preserveHibernatedEvidence = + opts?.shutdownReason === 'auto-hibernate-completed-agent' && + allowedPaneKeys !== null && + allowedPaneKeys.size > 0 + const liveEntryByPaneKey = new Map(liveEntries) + const retainedEvidence = new Map() + if (preserveHibernatedEvidence) { + for (const retained of opts?.retainedCompletionEvidence ?? []) { + if ( + allowedPaneKeys.has(retained.entry.paneKey) && + !liveEntryByPaneKey.has(retained.entry.paneKey) && + shouldReplaceRetainedWithLive(retainedEvidence.get(retained.entry.paneKey), retained) + ) { + retainedEvidence.set(retained.entry.paneKey, retained) + } + } + for (const [paneKey, entry] of liveEntries) { + if ( + allowedPaneKeys.has(paneKey) && + entry.state === 'done' && + entry.agentType !== undefined && + entry.interrupted !== true + ) { + retainedEvidence.set( + paneKey, + retainedAgentEntryFromLive(s, worktreeId, entry, entry.agentType) + ) + } + } + } + const retainedEvidenceKeys = new Set(retainedEvidence.keys()) + // Keep acknowledgement for completion evidence so a slept card does not turn bold again. + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter( + (key) => + !retainedEvidenceKeys.has(key) && + (paneKeyMatchesAnyTabPrefix(key, tabPrefixes) || + liveKeySet.has(key) || + retainedKeySet.has(key)) + ) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const key of ackKeys) { + delete nextAck[key] + } + } + if ( + liveKeys.length === 0 && + launchConfigKeys.length === 0 && + retainedKeys.length === 0 && + retainedEvidence.size === 0 && + !migrationUnsupported.changed + ) { + return nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : s + } + hadLive = liveKeys.length > 0 + const nextLive = + liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + for (const key of liveKeys) { + delete nextLive[key] + } + const nextLaunchConfigs = + launchConfigKeys.length > 0 + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + const nextRetained = + retainedKeys.length > 0 || retainedEvidence.size > 0 + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + for (const key of retainedKeys) { + if (!retainedEvidenceKeys.has(key)) { + delete nextRetained[key] + } + } + for (const [paneKey, retained] of retainedEvidence) { + if (shouldReplaceRetainedWithLive(nextRetained[paneKey], retained)) { + nextRetained[paneKey] = retained + } + } + const suppressorAdds = liveKeys.filter( + (key) => !retainedEvidenceKeys.has(key) && !(key in s.retentionSuppressedPaneKeys) + ) + let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys + if (suppressorAdds.length > 0) { + nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } + for (const key of suppressorAdds) { + nextRetentionSuppressedPaneKeys[key] = true + } + } + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + agentStatusEpoch: + hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + freshness.scheduleDeferred() + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 6cd68bdfca9..8093c2be805 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1,1621 +1,98 @@ -/* eslint-disable max-lines -- Why: the agent-status slice co-locates live map, retained snapshots, retention-suppression, and tab-prefix sweep so the teardown contract stays readable end-to-end. Splitting across files would scatter the drop/remove/retain interactions that must stay in lockstep. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { - agentSubagentsEqual, - AGENT_STATUS_STALE_AFTER_MS, - AGENT_STATE_HISTORY_MAX, - type AgentStateHistoryEntry, - type AgentStatusEntry, - type AgentStatusOrchestrationContext, - type AgentType, - type MigrationUnsupportedPtyEntry, - type ParsedAgentStatusPayload -} from '../../../../shared/agent-status-types' -import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' -import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' -import { - agentProviderSessionsEqual, - getAgentResumeArgv, - isResumableTuiAgent, - type AgentProviderSessionMetadata, - type ResumableTuiAgent, - type SleepingAgentLaunchConfig, - type SleepingAgentSessionRecord -} from '../../../../shared/agent-session-resume' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../../shared/agent-status-identity' -import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' -import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' -import { - getRepoExecutionHostId, - getWorktreeExecutionHostId -} from '../../../../shared/execution-host' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { - getAgentRowGeneratedTitleText, - getOrcaDispatchTaskId, - isOrcaDispatchPrompt, - orchestrationLabelsMatchLiveDispatch -} from '@/lib/agent-row-primary-text' -import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' -import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' -import { - resolveAgentPaneAuthorityKey, - retireAgentPaneAuthorityAliases, - retireAgentPaneAuthorityAliasesByOwnerTab, - transferAgentPaneAuthorityAlias -} from './agent-pane-authority' -import { createFreshnessScheduler } from './agent-status-freshness-scheduler' -import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' - -/** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover - * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at - * retention time so the row's home is known even after its tab/pty is gone. */ -export type RetainedAgentEntry = { - entry: AgentStatusEntry - worktreeId: string - /** Snapshot of the tab at retention time; kept full (not just an id) because the tab may be gone from `tabsByWorktree` by render time. */ - tab: TerminalTab - agentType: AgentType - startedAt: number -} - -export type AgentStatusWorktreeShutdownReason = - | 'manual-sleep' - | 'remove-worktree' - | 'auto-hibernate-completed-agent' - -type AllAgentSessionCaptureMode = 'periodic' | 'quit' - -type DropAgentStatusByWorktreeOptions = { - shutdownReason?: AgentStatusWorktreeShutdownReason - sleepingPaneKeys?: readonly string[] | ReadonlySet - retainedCompletionEvidence?: readonly RetainedAgentEntry[] -} - -type DropHibernatedAgentPaneOptions = { - retainedCompletionEvidence?: readonly RetainedAgentEntry[] -} - -type DropAgentStatusByTabPrefixOptions = { - worktreeId?: string -} - -type AgentLaunchConfigRegistrationMetadata = { - agentType?: AgentType - launchToken?: string - tabId?: string - leafId?: string - terminalHandle?: string - providerSession?: AgentProviderSessionMetadata -} - -type AgentLaunchConfigStatusMetadata = { - paneKey: string - agentType?: AgentType - tabId?: string - terminalHandle?: string - launchToken?: string - providerSession?: AgentProviderSessionMetadata - existingProviderSession?: AgentProviderSessionMetadata - providerSessionChanged?: boolean -} - -type AgentLaunchConfigRegistryEntry = { - launchConfig: SleepingAgentLaunchConfig - registeredAt: number - identity: AgentLaunchConfigRegistrationMetadata -} - -export type AgentStatusPayload = ParsedAgentStatusPayload & { - orchestration?: AgentStatusOrchestrationContext - promptInteractionKey?: string - restoredUnconfirmed?: boolean - /** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits - * it produces exactly the entry it produces today. See agent-status-observation.ts. */ - observation?: AgentStatusObservation -} - -export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } - -export type AgentStatusRouting = { - tabId?: string - worktreeId?: string - terminalHandle?: string - connectionId?: string | null -} - -export type AgentStatusMetadata = { - providerSession?: AgentProviderSessionMetadata - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - terminalResumeEligible?: false -} - -export type AgentStatusUpdate = { - kind?: 'status' - paneKey: string - payload: AgentStatusPayload - terminalTitle?: string - timing?: AgentStatusTiming - routing?: AgentStatusRouting - metadata?: AgentStatusMetadata -} - -export type AgentProviderSessionTiming = { updatedAt?: number } - -export type AgentProviderSessionRouting = { - tabId?: string - worktreeId?: string - connectionId?: string | null -} - -export type AgentProviderSessionRecordMetadata = { launchToken?: string } - -export type AgentProviderSessionUpdate = { - kind: 'providerSession' - paneKey: string - agent: ResumableTuiAgent - providerSession: AgentProviderSessionMetadata - timing?: AgentProviderSessionTiming - routing?: AgentProviderSessionRouting - metadata?: AgentProviderSessionRecordMetadata -} - -export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate - -export type AgentStatusBatchTransaction = { - getState: () => AppState - apply: (update: AgentStatusBatchUpdate) => boolean - afterCommit: (effect: () => void) => void -} - -export type AgentStatusSlice = { - /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ - agentStatusByPaneKey: Record - /** Main-synced dispatch metadata for live panes that may only have title-derived status in the renderer. */ - runtimeAgentOrchestrationByPaneKey: Record - /** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */ - migrationUnsupportedByPtyId: Record - /** Monotonic tick that advances when agent-status freshness boundaries pass. */ - agentStatusEpoch: number - /** SSH connections whose transient rows were cleared and must reject renderer callbacks - * until a later reconnect establishes a new connection lifecycle. */ - transientClearedAgentStatusConnectionIds: Record - /** Arm the shared freshness timer after an external mirror writes live rows. */ - scheduleAgentStatusFreshness: () => void - - /** Retained "done" snapshots of agents gone from `agentStatusByPaneKey`, keyed by paneKey so pane re-appearance overwrites; shared by dashboard and sidebar hover. */ - retainedAgentsByPaneKey: Record - - /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ - sleepingAgentSessionsByPaneKey: Record - - /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ - agentLaunchConfigByPaneKey: Record - - /** Pane keys explicitly torn down, forbidden from re-retention on next disappearance; a one-shot suppressor consumed by the retention sync. */ - retentionSuppressedPaneKeys: Record - - /** Terminal tabs explicitly closed this session; used to drop late in-flight IPC statuses and stale main-cache replays. */ - recentlyClosedAgentStatusTabIds: Record - - /** Exact pane authorities retired while sibling panes in the tab stay live. */ - recentlyRetiredAgentStatusPaneKeys: Record - - retireAgentPaneAuthority: ( - paneKey: string, - options?: { preserveSleepingAgentSession?: boolean } - ) => void - /** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */ - restoreAgentPaneAuthority: (paneKey: string) => void - transferAgentPaneAuthority: (args: { - fromPaneKey: string - toPaneKey: string - ptyId?: string | null - }) => void - - /** Update or insert an agent status entry from a status payload. */ - setAgentStatus: ( - paneKey: string, - payload: AgentStatusPayload, - terminalTitle?: string, - timing?: AgentStatusTiming, - routing?: AgentStatusRouting, - metadata?: AgentStatusMetadata - ) => void - - /** Apply ordered status updates as one status publication (generated titles and tab - * titles still publish after it — three total, not 2N). */ - setAgentStatuses: (updates: readonly AgentStatusBatchUpdate[]) => boolean[] - - /** Fold caller-derived updates against exact staged state, committing one status publication. */ - transactAgentStatuses: ( - operation: (transaction: AgentStatusBatchTransaction) => Result - ) => Result - - /** Record resume identity without creating a visible turn-status row. */ - recordAgentProviderSession: ( - paneKey: string, - agent: ResumableTuiAgent, - providerSession: AgentProviderSessionMetadata, - timing?: AgentProviderSessionTiming, - routing?: AgentProviderSessionRouting, - metadata?: AgentProviderSessionRecordMetadata - ) => void - - registerAgentLaunchConfig: ( - paneKey: string, - launchConfig: SleepingAgentLaunchConfig, - metadata?: AgentLaunchConfigRegistrationMetadata - ) => void - getAgentLaunchConfigForStatusEntry: ( - entry: AgentStatusEntry - ) => SleepingAgentLaunchConfig | undefined - getAgentLaunchConfigForStatusMetadata: ( - metadata: AgentLaunchConfigStatusMetadata - ) => SleepingAgentLaunchConfig | undefined - clearAgentLaunchConfig: (paneKey: string) => void - - setRuntimeAgentOrchestrationByPaneKey: ( - entries: Record - ) => void - - setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void - clearMigrationUnsupportedPty: (ptyId: string) => void - - /** Remove a single entry (e.g., when a pane's terminal exits). */ - removeAgentStatus: (paneKey: string) => void - - /** Remove all entries whose paneKey starts with the given prefix (tab close prefix-sweep). */ - removeAgentStatusByTabPrefix: (tabIdPrefix: string) => void - - /** Remove stale live rows while preserving pane launch and resume identity. */ - clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void - - /** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */ - dropAgentStatus: (paneKey: string) => void - - /** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */ - dropAgentStatusByTabPrefix: ( - tabIdPrefix: string, - opts?: DropAgentStatusByTabPrefixOptions - ) => void - - /** Remove one auto-hibernated completed-agent pane while preserving sibling live/retained rows in the same worktree. */ - dropHibernatedAgentStatusPane: ( - worktreeId: string, - paneKey: string, - opts?: DropHibernatedAgentPaneOptions - ) => void - - /** Remove all entries for a worktree AND suppress re-retention for live rows (worktree sleep/remove). - * Sweeps live rows by tab prefix and by main-stamped worktree attribution so worker rows that arrive before their tab don't survive. */ - dropAgentStatusByWorktree: (worktreeId: string, opts?: DropAgentStatusByWorktreeOptions) => void - - captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void - /** Capture resumable agent sessions across every worktree for crash recovery or quit; mode sets live/quit precedence. */ - captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void - clearSleepingAgentSession: (paneKey: string) => void - clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void - clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void - pruneSleepingAgentSessions: (validWorktreeIds: Set) => void - - /** Retain agent snapshots. Accepts an array so simultaneous disappearances produce a single set() with no mid-loop intermediate states. */ - retainAgents: (entries: RetainedAgentEntry[]) => void - - /** Dismiss a retained entry by its paneKey. */ - dismissRetainedAgent: (paneKey: string) => void - - /** Dismiss all retained entries belonging to a worktree. */ - dismissRetainedAgentsByWorktree: (worktreeId: string) => void - - /** Prune retained entries whose worktreeId is not in the given set. */ - pruneRetainedAgents: (validWorktreeIds: Set) => void - - /** Clear one-shot teardown suppressors after the retention sync declines to retain the row. */ - clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void -} - -// Why: retained entries are heavy (~24KB) and grow unbounded on busy worktrees (dominant renderer OOM); cap, evicting oldest completions first. -const MAX_RETAINED_AGENTS = 500 - -function capRetainedAgents( - retained: Record, - maxEntries = MAX_RETAINED_AGENTS -): Record { - const keys = Object.keys(retained) - if (keys.length <= maxEntries) { - return retained - } - const capped: Record = {} - for (const key of keys.slice(keys.length - maxEntries)) { - capped[key] = retained[key] - } - return capped -} - -// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). -export const MAX_LIVE_AGENT_STATUSES = 500 - -type PaneLiveness = 'live' | 'dead' | 'unprovable' - -// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). -function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { - const rootedLeafKeys = new Set() - const rootedTabIds = new Set() - for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { - if (!layout?.root) { - continue - } - rootedTabIds.add(tabId) - const stack: TerminalPaneLayoutNode[] = [layout.root] - while (stack.length > 0) { - const node = stack.pop()! - if (node.type === 'leaf') { - rootedLeafKeys.add(`${tabId}:${node.leafId}`) - } else { - stack.push(node.first, node.second) - } - } - } - return (paneKey) => { - if (rootedLeafKeys.has(paneKey)) { - return 'live' - } - const tabId = getTabIdFromPaneKey(paneKey) - return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' - } -} - -// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. -function capLiveAgentStatusesInPlace( - freshLive: Record, - protectedPaneKey: string, - buildClassifier: () => (paneKey: string) => PaneLiveness, - now: number, - maxEntries = MAX_LIVE_AGENT_STATUSES -): string[] { - const keys = Object.keys(freshLive) - let overflow = keys.length - maxEntries - if (overflow <= 0) { - return [] - } - const classify = buildClassifier() - const evictedPaneKeys: string[] = [] - const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { - for (const key of keys) { - if (overflow <= 0) { - break - } - if (key === protectedPaneKey || !(key in freshLive)) { - continue - } - const liveness = classify(key) - if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { - continue - } - delete freshLive[key] - overflow -= 1 - evictedPaneKeys.push(key) - } - } - // Prefer rows that are provably dead or too stale to represent a live agent. - sweep( - (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS - ) - // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. - if (overflow > 0) { - sweep(() => true) - } - return evictedPaneKeys -} - -function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { - for (const prefix of tabPrefixes) { - if (paneKey.startsWith(prefix)) { - return true - } - } - return false -} - -function isAgentCompletionState(state: ParsedAgentStatusPayload['state']): boolean { - return state === 'done' || state === 'waiting' || state === 'blocked' -} - -function getTabIdFromPaneKey(paneKey: string): string | null { - const separator = paneKey.indexOf(':') - if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { - return null - } - return paneKey.slice(0, separator) -} - -/** True when auto-title generation would no-op without replace (custom/quick/generated). */ -function agentStatusTabAlreadyHasProtectedOrGeneratedTitle( - state: AppState, - tabId: string | null, - worktreeId?: string | null -): boolean { - if (!tabId) { - return false - } - const ownerTabs = worktreeId ? state.tabsByWorktree[worktreeId] : undefined - if (ownerTabs) { - const tab = ownerTabs.find((candidate) => candidate.id === tabId) - return Boolean( - tab?.customTitle?.trim() || tab?.quickCommandLabel?.trim() || tab?.generatedTitle?.trim() - ) - } - for (const tabs of Object.values(state.tabsByWorktree)) { - const tab = tabs.find((candidate) => candidate.id === tabId) - if (!tab) { - continue - } - return Boolean( - tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.generatedTitle?.trim() - ) - } - return false -} - -function getLeafIdFromPaneKey(paneKey: string): string | null { - const separator = paneKey.indexOf(':') - if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { - return null - } - const leafId = paneKey.slice(separator + 1) - return leafId.length > 0 ? leafId : null -} - -function findCompletedOrphanPaneKeysForTabClose( - state: AgentStatusTabPrefixDropState, - worktreeId: string | undefined, - prefix: string -): string[] { - if (!worktreeId) { - return [] - } - const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) - const paneKeys: string[] = [] - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) { - continue - } - const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId || openTabIds.has(tabId)) { - continue - } - paneKeys.push(paneKey) - } - return paneKeys -} - -function isRecentlyClosedAgentStatusTab( - closedTabs: Record, - tabId: string | null -): boolean { - if (!tabId) { - return false - } - return closedTabs[tabId] === true -} - -function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null { - const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId) { - return null - } - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - if (tabs.some((tab) => tab.id === tabId)) { - return worktreeId - } - } - return null -} - -function findTabForAgentEntry( - state: AppState, - worktreeId: string, - entry: AgentStatusEntry -): TerminalTab | undefined { - const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) - if (!tabId) { - return undefined - } - return (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === tabId) -} - -function getRetainedFallbackTab(entry: AgentStatusEntry, worktreeId: string): TerminalTab { - const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? entry.paneKey - return { - id: tabId, - ptyId: null, - worktreeId, - title: entry.terminalTitle ?? 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: entry.stateStartedAt - } -} - -function retainedAgentEntryFromLive( - state: AppState, - worktreeId: string, - entry: AgentStatusEntry, - agentType: AgentType -): RetainedAgentEntry { - const tab = - findTabForAgentEntry(state, worktreeId, entry) ?? getRetainedFallbackTab(entry, worktreeId) - return { - entry, - worktreeId, - tab, - agentType, - startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt - } -} - -function shouldReplaceRetainedWithLive( - retained: RetainedAgentEntry | undefined, - live: RetainedAgentEntry -): boolean { - if (!retained) { - return true - } - if (live.startedAt !== retained.startedAt) { - return live.startedAt > retained.startedAt - } - const retainedSessionId = retained.entry.providerSession?.id - const liveSessionId = live.entry.providerSession?.id - if (retainedSessionId && liveSessionId && retainedSessionId !== liveSessionId) { - return live.entry.updatedAt >= retained.entry.updatedAt - } - return live.entry.updatedAt > retained.entry.updatedAt -} - -function normalizePaneKeySet( - paneKeys: DropAgentStatusByWorktreeOptions['sleepingPaneKeys'] -): ReadonlySet | null { - if (!paneKeys) { - return null - } - return paneKeys instanceof Set ? paneKeys : new Set(paneKeys) -} - -function sleepingRecordFromEntry(args: { - state: AppState - entry: AgentStatusEntry - worktreeId: string - tab?: TerminalTab - capturedAt: number - launchConfig?: SleepingAgentLaunchConfig - origin?: SleepingAgentSessionRecord['origin'] -}): SleepingAgentSessionRecord | null { - const agent = args.entry.agentType - if ( - args.entry.terminalResumeEligible === false || - !isResumableTuiAgent(agent) || - !args.entry.providerSession - ) { - return null - } - if (!getAgentResumeArgv(agent, args.entry.providerSession)) { - return null - } - const tab = args.tab ?? findTabForAgentEntry(args.state, args.worktreeId, args.entry) - return { - paneKey: args.entry.paneKey, - ...(tab ? { tabId: tab.id } : {}), - worktreeId: args.worktreeId, - agent, - providerSession: args.entry.providerSession, - ...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}), - prompt: args.entry.prompt, - state: args.entry.state, - capturedAt: args.capturedAt, - updatedAt: args.entry.updatedAt, - ...((args.entry.terminalTitle ?? tab?.title) - ? { terminalTitle: (args.entry.terminalTitle ?? tab?.title)! } - : {}), - ...(args.entry.lastAssistantMessage - ? { lastAssistantMessage: args.entry.lastAssistantMessage } - : {}), - ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), - ...(args.entry.interrupted ? { interrupted: true } : {}), - ...(args.origin ? { origin: args.origin } : {}) - } -} - -type CollectSleepingAgentSessionRecordsOptions = { - paneKeys?: readonly string[] - captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation' -} - -function normalizeSleepingAgentSessionCollectOptions( - options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined -): CollectSleepingAgentSessionRecordsOptions { - if (!options) { - return {} - } - return Array.isArray(options) - ? { paneKeys: options } - : (options as CollectSleepingAgentSessionRecordsOptions) -} - -function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { - return entry.state === 'done' && entry.interrupted !== true -} - -// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive -// record's tab. Sleeping a workspace must not become "one phone tap respawns all of it" — the pane -// issues its own `--resume` cold restore when its tab is opened instead (#11598). -function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): void { - if (record.state === 'done') { - record.restoreOnTabOpenOnly = true - } -} - -// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit -// sleep or quit capture is the pane's only resume handle once its live row is gone. -function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { - return record.origin === 'worktree-sleep' || record.origin === 'quit' -} - -// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead -// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the -// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved -// so a done pane wakes lazily in place instead of spawning a new tab. -function manualSleepCaptureEntry(entry: AgentStatusEntry, capturedAt: number): AgentStatusEntry { - return { ...entry, updatedAt: capturedAt, interrupted: false } -} - -// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately -// blocked worker must not become auto-resumable at wake. -function carryOverAutomaticResumeBlock( - record: SleepingAgentSessionRecord, - previous: SleepingAgentSessionRecord | undefined -): void { - if ( - previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - previous.agent === record.agent && - agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) - ) { - record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy - } -} - -export function removeSleepingRecordsReplacedByManualWorktreeSleep( - records: Record, - worktreeId: string, - paneKeys?: readonly string[], - replacements?: Readonly> -): { records: Record; changed: boolean } { - const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null - let next = records - let changed = false - for (const [paneKey, record] of Object.entries(records)) { - if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { - continue - } - // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the - // pane was never woken, so it has no live status row to rebuild it from (#11598). - if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { - continue - } - if (next === records) { - next = { ...records } - } - delete next[paneKey] - changed = true - } - return { records: next, changed } -} - -export function collectSleepingAgentSessionRecordsForWorktree( - state: AppState, - worktreeId: string, - options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions -): Record { - const capturedAt = Date.now() - const collectOptions = normalizeSleepingAgentSessionCollectOptions(options) - const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null - const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep' - const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation' - const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation - // Why: hibernated completions are intentional worktree-owned records; wake treats - // originless completed records as ambiguous legacy captures. - const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture - ? 'worktree-sleep' - : undefined - const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const records: Record = {} - const promotedLiveRecoveryPaneKeys = new Set() - - if (isManualWorktreeSleep) { - for (const existing of Object.values(state.sleepingAgentSessionsByPaneKey)) { - const liveEntry = state.agentStatusByPaneKey[existing.paneKey] - if ( - existing.worktreeId !== worktreeId || - existing.origin !== 'live' || - (liveEntry !== undefined && - !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(liveEntry, existing)) || - (allowedPaneKeys && !allowedPaneKeys.has(existing.paneKey)) || - !getAgentResumeArgv(existing.agent, existing.providerSession) - ) { - continue - } - // Why: Pi identity is resumable with no turn row and while idle after done, so manual - // sleep must promote both instead of deleting the checkpoint. - records[existing.paneKey] = { - ...existing, - state: 'working', - capturedAt, - updatedAt: capturedAt, - origin: 'worktree-sleep' - } - promotedLiveRecoveryPaneKeys.add(existing.paneKey) - } - } - - for (const retained of Object.values(state.retainedAgentsByPaneKey)) { - if (isCompletedAgentHibernation) { - continue - } - if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { - continue - } - if (retained.worktreeId !== worktreeId) { - continue - } - // Why: the promoted checkpoint carries recovery identity (transcript, connection) a retained - // turn row lacks, so it must not be overwritten by a re-derived record. - if (promotedLiveRecoveryPaneKeys.has(retained.entry.paneKey)) { - continue - } - const record = sleepingRecordFromEntry({ - state, - entry: isManualWorktreeSleep - ? manualSleepCaptureEntry(retained.entry, capturedAt) - : retained.entry, - worktreeId, - tab: retained.tab, - capturedAt, - launchConfig: getLaunchConfigForEntry(state, retained.entry), - origin - }) - if (record) { - if (isManualWorktreeSleep) { - markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock( - record, - state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] - ) - } - records[record.paneKey] = record - } - } - - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { - continue - } - // Why: the promoted checkpoint carries recovery identity (transcript, connection) the live - // turn row lacks, so it must not be overwritten by a re-derived record. - if (promotedLiveRecoveryPaneKeys.has(paneKey)) { - continue - } - const belongsToWorktree = - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - if (!belongsToWorktree) { - continue - } - if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { - continue - } - const record = sleepingRecordFromEntry({ - state, - entry: isManualWorktreeSleep ? manualSleepCaptureEntry(entry, capturedAt) : entry, - worktreeId, - capturedAt, - launchConfig: getLaunchConfigForEntry(state, entry), - origin - }) - if (record) { - if (isManualWorktreeSleep) { - markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) - } - records[record.paneKey] = record - } - } - - return records -} - -export function collectHibernatedCompletionEvidenceForWorktree( - state: AppState, - worktreeId: string, - paneKeys?: readonly string[] -): RetainedAgentEntry[] { - const allowedPaneKeys = normalizePaneKeySet(paneKeys) - if (!allowedPaneKeys || allowedPaneKeys.size === 0) { - return [] - } - const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const retained: RetainedAgentEntry[] = [] - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - const agentType = entry.agentType - if ( - !allowedPaneKeys.has(paneKey) || - entry.state !== 'done' || - agentType === undefined || - entry.interrupted === true - ) { - continue - } - const belongsToWorktree = - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - if (!belongsToWorktree) { - continue - } - retained.push(retainedAgentEntryFromLive(state, worktreeId, entry, agentType)) - } - return retained -} - -// Why: comparing all fields except capturedAt lets an unchanged agent skip the store write, -// so periodic idle re-captures never dirty session persistence. -function sleepingRecordsEquivalentIgnoringCaptureTime( - existing: SleepingAgentSessionRecord | undefined, - next: SleepingAgentSessionRecord -): boolean { - if (!existing) { - return false - } - return ( - existing.paneKey === next.paneKey && - existing.tabId === next.tabId && - existing.worktreeId === next.worktreeId && - existing.agent === next.agent && - agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && - existing.prompt === next.prompt && - existing.state === next.state && - existing.updatedAt === next.updatedAt && - existing.terminalTitle === next.terminalTitle && - existing.lastAssistantMessage === next.lastAssistantMessage && - existing.interrupted === next.interrupted && - existing.origin === next.origin && - launchConfigsEqual(existing.launchConfig, next.launchConfig) - ) -} - -function recoveryRecordMatches( - existing: SleepingAgentSessionRecord | undefined, - next: SleepingAgentSessionRecord -): boolean { - if (!existing) { - return false - } - // Why: completion or interruption must replace a pre-status working checkpoint. - return ( - existing.origin === next.origin && - existing.agent === next.agent && - existing.worktreeId === next.worktreeId && - existing.tabId === next.tabId && - existing.state === next.state && - existing.interrupted === next.interrupted && - agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && - launchConfigsEqual(existing.launchConfig, next.launchConfig) - ) -} - -function recoveryRecordTargetsSameSession( - existing: SleepingAgentSessionRecord | undefined, - next: SleepingAgentSessionRecord -): boolean { - if (!existing) { - return false - } - return ( - existing.agent === next.agent && - existing.worktreeId === next.worktreeId && - existing.tabId === next.tabId && - agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) - ) -} - -function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { - return { - ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), - agentArgs: config.agentArgs, - agentEnv: { ...config.agentEnv }, - ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) - } -} - -function launchConfigsEqual( - a: SleepingAgentLaunchConfig | undefined, - b: SleepingAgentLaunchConfig | undefined -): boolean { - if (a === undefined || b === undefined) { - return a === b - } - if ( - a.agentCommand !== b.agentCommand || - a.agentArgs !== b.agentArgs || - a.ompResumeFilePath !== b.ompResumeFilePath - ) { - return false - } - const aKeys = Object.keys(a.agentEnv) - const bKeys = Object.keys(b.agentEnv) - return aKeys.length === bKeys.length && aKeys.every((key) => a.agentEnv[key] === b.agentEnv[key]) -} - -function normalizeLaunchConfigRegistrationMetadata( - paneKey: string, - metadata: AgentLaunchConfigRegistrationMetadata | undefined -): AgentLaunchConfigRegistrationMetadata { - return { - ...(metadata?.agentType ? { agentType: metadata.agentType } : {}), - ...(metadata?.launchToken ? { launchToken: metadata.launchToken } : {}), - tabId: metadata?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, - leafId: metadata?.leafId ?? getLeafIdFromPaneKey(paneKey) ?? undefined, - ...(metadata?.terminalHandle ? { terminalHandle: metadata.terminalHandle } : {}), - ...(metadata?.providerSession ? { providerSession: metadata.providerSession } : {}) - } -} - -function launchConfigRegistryEntriesEqual( - a: AgentLaunchConfigRegistryEntry | undefined, - b: AgentLaunchConfigRegistryEntry -): boolean { - return ( - a !== undefined && - launchConfigsEqual(a.launchConfig, b.launchConfig) && - a.identity.agentType === b.identity.agentType && - a.identity.launchToken === b.identity.launchToken && - a.identity.tabId === b.identity.tabId && - a.identity.leafId === b.identity.leafId && - a.identity.terminalHandle === b.identity.terminalHandle && - agentProviderSessionsEqual( - a.identity.agentType ?? b.identity.agentType, - a.identity.providerSession, - b.identity.providerSession - ) - ) -} - -function registryEntryMatchesStatus(args: { - entry: AgentLaunchConfigRegistryEntry | undefined - paneKey: string - agentType: AgentType | undefined - tabId: string | undefined - terminalHandle: string | undefined - launchToken: string | undefined - providerSession: AgentProviderSessionMetadata | undefined - existingProviderSession: AgentProviderSessionMetadata | undefined - providerSessionChanged: boolean -}): boolean { - const entry = args.entry - if (!entry || args.providerSessionChanged) { - return false - } - const identity = entry.identity - if (identity.agentType !== undefined && identity.agentType !== args.agentType) { - return false - } - if (identity.tabId !== undefined && identity.tabId !== args.tabId) { - return false - } - if (identity.leafId !== undefined && identity.leafId !== getLeafIdFromPaneKey(args.paneKey)) { - return false - } - if ( - identity.terminalHandle !== undefined && - (args.terminalHandle === undefined || identity.terminalHandle !== args.terminalHandle) - ) { - return false - } - if ( - identity.launchToken !== undefined && - (args.launchToken === undefined || identity.launchToken !== args.launchToken) - ) { - // Why: a missing/mismatched launch token is stale proof even if a later manual/mixed Codex run reused the provider session id. - return false - } - if (identity.providerSession !== undefined) { - return agentProviderSessionsEqual( - args.agentType, - identity.providerSession, - args.providerSession - ) - } - if (identity.launchToken !== undefined) { - return true - } - if (identity.terminalHandle !== undefined) { - return true - } - if (args.existingProviderSession && args.providerSession) { - return agentProviderSessionsEqual( - args.agentType, - args.existingProviderSession, - args.providerSession - ) - } - return false -} - -function getLaunchConfigForEntry( - state: AppState, - entry: AgentStatusEntry -): SleepingAgentLaunchConfig | undefined { - const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey] - const registryLaunchConfig = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: entry.paneKey, - agentType: entry.agentType, - tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined, - terminalHandle: entry.terminalHandle, - launchToken: undefined, - providerSession: entry.providerSession, - existingProviderSession: entry.providerSession, - providerSessionChanged: false - }) - ? registryEntry?.launchConfig - : undefined - if (registryLaunchConfig) { - return registryLaunchConfig - } - const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey] - return sleepingRecord?.launchConfig && - sleepingRecord.agent === entry.agentType && - entry.providerSession && - agentProviderSessionsEqual( - entry.agentType, - sleepingRecord.providerSession, - entry.providerSession - ) - ? sleepingRecord.launchConfig - : undefined -} - -// Why: renderer twin of main's #7561 FIFO-capped closedAgentStatusTabIds — suppresses late -// events for a just-closed tab, but was add-only and grew unbounded, hence this cap. -export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 - -// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates -// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. -function boundRecentlyClosedAgentStatusTabIds( - existing: Record, - tabId: string -): Record { - const next: Record = {} - for (const key of Object.keys(existing)) { - if (key !== tabId) { - next[key] = true - } - } - next[tabId] = true - const keys = Object.keys(next) - if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { - delete next[stale] - } - } - return next -} - -function boundRecentlyRetiredAgentStatusPaneKeys( - existing: Record, - paneKeys: readonly string[] -): Record { - const additions = new Set(paneKeys) - const next: Record = {} - for (const key of Object.keys(existing)) { - if (!additions.has(key)) { - next[key] = true - } - } - for (const paneKey of additions) { - next[paneKey] = true - } - const keys = Object.keys(next) - for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { - delete next[stale] - } - return next -} - -function movePaneKeyedRecord( - record: Record, - fromPaneKey: string, - toPaneKey: string, - transform: (value: T) => T = (value) => value -): Record { - const value = record[fromPaneKey] - if (value === undefined || fromPaneKey === toPaneKey) { - return record - } - const next = { ...record } - delete next[fromPaneKey] - next[toPaneKey] = transform(value) - return next -} - -function removePaneKeys( - record: Record, - paneKeys: ReadonlySet -): Record { - const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) - if (matchingKeys.length === 0) { - return record - } - const next = { ...record } - for (const key of matchingKeys) { - delete next[key] - } - return next -} - -function getLaunchConfigForStatusMetadata( - state: AppState, - metadata: AgentLaunchConfigStatusMetadata -): SleepingAgentLaunchConfig | undefined { - const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] - return registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: metadata.paneKey, - agentType: metadata.agentType, - tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, - terminalHandle: metadata.terminalHandle, - launchToken: metadata.launchToken, - providerSession: metadata.providerSession, - existingProviderSession: metadata.existingProviderSession, - providerSessionChanged: metadata.providerSessionChanged ?? false - }) - ? registryEntry?.launchConfig - : undefined -} - -function pruneMigrationUnsupportedEntries( - entries: Record, - predicate: (entry: MigrationUnsupportedPtyEntry) => boolean -): { next: Record; changed: boolean } { - let changed = false - const next: Record = {} - for (const [ptyId, entry] of Object.entries(entries)) { - if (predicate(entry)) { - changed = true - continue - } - next[ptyId] = entry - } - return { next: changed ? next : entries, changed } -} - -function orchestrationContextsEqual( - a: AgentStatusOrchestrationContext, - b: AgentStatusOrchestrationContext -): boolean { - return ( - a.taskId === b.taskId && - a.dispatchId === b.dispatchId && - a.dispatchStatus === b.dispatchStatus && - a.taskTitle === b.taskTitle && - a.displayName === b.displayName && - a.parentTerminalHandle === b.parentTerminalHandle && - a.parentPaneKey === b.parentPaneKey && - a.coordinatorHandle === b.coordinatorHandle && - a.orchestrationRunId === b.orchestrationRunId - ) -} - -function orchestrationMapsEqual( - a: Record, - b: Record -): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) { - return false - } - return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) -} - -function mergeCurrentOrchestrationContext( - existing: AgentStatusOrchestrationContext | undefined, - current: AgentStatusOrchestrationContext -): AgentStatusOrchestrationContext { - if (!existing) { - return current - } - const sameDispatch = - existing.taskId === current.taskId && existing.dispatchId === current.dispatchId - if (!sameDispatch) { - return current - } - if (current.dispatchStatus !== undefined) { - return orchestrationContextsEqual(existing, current) ? existing : current - } - const merged = { ...existing, ...current } - return orchestrationContextsEqual(existing, merged) ? existing : merged -} - -// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never -// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host -// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a -// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same -// path on two hosts yields one shared id that must not clear another host's live rows. -function collectWorktreeIdsForConnection(state: AppState, connectionId: string): Set { - const hostIdsOnConnection = new Set( - state.repos - .filter((repo) => repo.connectionId === connectionId) - .map((repo) => getRepoExecutionHostId(repo)) - ) - if (hostIdsOnConnection.size === 0) { - return new Set() - } - const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) - const onConnection = new Set() - const onOtherHost = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { - const repo = repoById.get(repoId) - for (const worktree of worktrees) { - const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) - ? onConnection - : onOtherHost - bucket.add(worktree.id) - } - } - // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. - for (const id of onOtherHost) { - onConnection.delete(id) - } - return onConnection -} - -/** Slices that only the fold touched, so the batch commits as a MERGE. - * Why: the fold runs against a snapshot taken at transaction start. Committing that - * snapshot wholesale (zustand REPLACE) would silently revert any write that landed on - * the real store meanwhile — and it can, because a batched action reaching another - * slice's setter through `get()` bypasses the shadowed `set` entirely - * (setRuntimeAgentOrchestrationByPaneKey → setGeneratedTabTitleFromAgentPrompt does this). - * A merge keeps the single publication while confining conflicts to keys this fold wrote. */ -function buildAgentStatusBatchPatch( - initialState: AppState, - nextState: AppState -): Partial { - const patch: Record = {} - for (const key of Object.keys(nextState) as (keyof AppState)[]) { - if (!Object.is(nextState[key], initialState[key])) { - patch[key as string] = nextState[key] - } - } - return patch as Partial -} - -/** The slice of app state the tab-prefix drop reduces over — narrow so callers - * outside the store (the paired snapshot apply) can build the same patch. */ -export type AgentStatusTabPrefixDropState = Pick< - AppState, - | 'acknowledgedAgentsByPaneKey' - | 'agentLaunchConfigByPaneKey' - | 'agentStatusByPaneKey' - | 'agentStatusEpoch' - | 'migrationUnsupportedByPtyId' - | 'recentlyClosedAgentStatusTabIds' - | 'recentlyRetiredAgentStatusPaneKeys' - | 'retainedAgentsByPaneKey' - | 'retentionSuppressedPaneKeys' - | 'sortEpoch' - | 'tabsByWorktree' -> - -/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot - * apply folds the same sweep into a patch it assembles itself, so the two - * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because - * retiring pane-authority aliases is a registry side effect, not a reduction. */ -export function buildAgentStatusTabPrefixDropPatch( - s: AgentStatusTabPrefixDropState, - tabIdPrefix: string, - retiredAliasPaneKeys: readonly string[], - opts?: DropAgentStatusByTabPrefixOptions -): { patch: Partial; hadLive: boolean } { - const prefix = `${tabIdPrefix}:` - let hadLive = false - const buildPatch = (): Partial => { - const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(s, opts?.worktreeId, prefix) - const completedOrphanKeySet = new Set(completedOrphanKeys) - const liveKeys = [ - ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), - ...completedOrphanKeys - ] - const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey?.startsWith(prefix) ?? false - ) - // See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const k of ackKeys) { - delete nextAck[k] - } - } - const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( - s.recentlyClosedAgentStatusTabIds, - tabIdPrefix - ) - const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( - s.recentlyRetiredAgentStatusPaneKeys, - retiredAliasPaneKeys - ) - - if ( - liveKeys.length === 0 && - launchConfigKeys.length === 0 && - retainedKeys.length === 0 && - !migrationUnsupported.changed - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { - acknowledgedAgentsByPaneKey: nextAck, - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys - } - } - return { - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys - } - } - hadLive = liveKeys.length > 0 - - const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - for (const key of liveKeys) { - delete nextLive[key] - } - const nextLaunchConfigs = - launchConfigKeys.length > 0 - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - - const nextRetained = - retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey - for (const key of retainedKeys) { - delete nextRetained[key] - } - - // Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus). - const suppressorAdds = liveKeys.filter( - (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) - ) - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (suppressorAdds.length > 0) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - for (const key of suppressorAdds) { - nextRetentionSuppressedPaneKeys[key] = true - } - } - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. - agentStatusEpoch: - hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - } - const patch = buildPatch() - return { patch, hadLive } -} +import type { AgentStatusSlice } from './agent-status-slice-contract' +import { createAgentStatusRuntime } from './agent-status-runtime' +import { createAgentStatusAuthorityActions } from './agent-status-authority-actions' +import { createAgentStatusCleanupActions } from './agent-status-cleanup-actions' +import { createAgentStatusDropActions } from './agent-status-drop-actions' +import { createAgentStatusWorktreeDropActions } from './agent-status-worktree-drop-actions' +import { createAgentStatusLaunchActions } from './agent-status-launch-actions' +import { createAgentStatusLiveActions } from './agent-status-live-actions' +import { createAgentStatusOrchestrationActions } from './agent-status-orchestration-actions' +import { createAgentStatusProviderSessionActions } from './agent-status-provider-session-actions' +import { createAgentStatusRecoveryActions } from './agent-status-recovery-actions' +import { createAgentStatusRetentionActions } from './agent-status-retention-actions' + +// Keep this path as the public contract for callers while implementation details live beside it. +export type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata, + AgentLaunchConfigStatusMetadata, + AgentProviderSessionRecordMetadata, + AgentProviderSessionRouting, + AgentProviderSessionTiming, + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming, + AgentStatusUpdate, + AgentProviderSessionUpdate, + AgentStatusWorktreeShutdownReason, + AllAgentSessionCaptureMode, + DropAgentStatusByTabPrefixOptions, + DropAgentStatusByWorktreeOptions, + DropHibernatedAgentPaneOptions, + RetainedAgentEntry, + AgentStateHistoryEntry, + AgentStatusEntry, + AgentStatusOrchestrationContext, + AgentType, + MigrationUnsupportedPtyEntry, + ParsedAgentStatusPayload, + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from './agent-status-contract' +export type { AgentStatusSlice } from './agent-status-slice-contract' +export { + collectSleepingAgentSessionRecordsForWorktree, + collectHibernatedCompletionEvidenceForWorktree +} from './agent-status-recovery-collection' +export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-recovery-helpers' +export { + buildAgentStatusTabPrefixDropPatch, + type AgentStatusTabPrefixDropState +} from './agent-status-drop-reducer' +export { MAX_LIVE_AGENT_STATUSES } from './agent-status-pane-helpers' +export { + RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, + RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX +} from './agent-status-map-helpers' export const createAgentStatusSlice: StateCreator = ( storeSet, storeGet ) => { - type AgentStatusStateUpdate = - | AppState - | Partial - | ((state: AppState) => AppState | Partial) - - let batchedAgentStatusState: AppState | null = null - let batchedAgentStatusEffects: (() => void)[] | null = null - let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null - let batchedAgentStatusFreshnessRequested = false - const get = (): AppState => batchedAgentStatusState ?? storeGet() - // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in - // this slice can compile into a REPLACE the batch commit is unable to express. - const set = (update: AgentStatusStateUpdate): void => { - if (batchedAgentStatusState === null) { - storeSet(update, false) - return - } - const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update - if (Object.is(nextState, batchedAgentStatusState)) { - return - } - batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState) - } - const runAfterAgentStatusCommit = (effect: () => void): void => { - if (batchedAgentStatusEffects) { - batchedAgentStatusEffects.push(effect) - return - } - effect() - } - const applyGeneratedTabTitleUpdate = (update: GeneratedTabTitleUpdate): void => { - if (batchedGeneratedTabTitleUpdates) { - batchedGeneratedTabTitleUpdates.push(update) - return - } - if (update.options) { - get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options) - } else { - get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt) - } - } - const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => { - const stateBeforeUpdate = batchedAgentStatusState - if (!stateBeforeUpdate) { - return false - } - if (update.kind === 'providerSession') { - get().recordAgentProviderSession( - update.paneKey, - update.agent, - update.providerSession, - update.timing, - update.routing, - update.metadata - ) - } else { - get().setAgentStatus( - update.paneKey, - update.payload, - update.terminalTitle, - update.timing, - update.routing, - update.metadata - ) - } - return batchedAgentStatusState !== stateBeforeUpdate - } - const batchTransaction: AgentStatusBatchTransaction = { - getState: get, - apply: applyBatchedAgentStatusUpdate, - afterCommit: runAfterAgentStatusCommit - } - const transactAgentStatuses = ( - operation: (transaction: AgentStatusBatchTransaction) => Result - ): Result => { - if (batchedAgentStatusState) { - return operation(batchTransaction) - } - const initialState = storeGet() - batchedAgentStatusState = initialState - batchedAgentStatusEffects = [] - batchedGeneratedTabTitleUpdates = [] - try { - const result = operation(batchTransaction) - const nextState = batchedAgentStatusState - const effects = batchedAgentStatusEffects - const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates - const freshnessRequested = batchedAgentStatusFreshnessRequested - batchedAgentStatusState = null - batchedAgentStatusEffects = null - batchedGeneratedTabTitleUpdates = null - batchedAgentStatusFreshnessRequested = false - if (nextState !== initialState) { - storeSet(buildAgentStatusBatchPatch(initialState, nextState), false) - } - if (generatedTabTitleUpdates.length > 0) { - storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) - } - if (freshnessRequested) { - freshness.scheduleDeferred() - } - for (const effect of effects) { - effect() - } - return result - } finally { - batchedAgentStatusState = null - batchedAgentStatusEffects = null - batchedGeneratedTabTitleUpdates = null - batchedAgentStatusFreshnessRequested = false - } - } - - // Why: scheduler is process-lifetime-scoped (no dispose) because the store is a - // module-level singleton with no teardown lifecycle anywhere in the codebase. - const freshness = createFreshnessScheduler({ - getEntries: () => Object.values(get().agentStatusByPaneKey), - bumpEpochs: () => { - // Why: freshness is time-based — bump both epochs at the stale boundary to force selector - // recompute and re-sort even with no new output, since staleness can change worktree ordering. - set((s) => ({ - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - })) + let composedActions: + | Pick + | undefined + const runtime = createAgentStatusRuntime(storeSet, storeGet, () => { + if (!composedActions) { + throw new Error('agent-status actions are not initialized') } + return composedActions }) - const requestAgentStatusFreshness = (acceptedInBatch: boolean): void => { - if (batchedAgentStatusState !== null) { - batchedAgentStatusFreshnessRequested ||= acceptedInBatch - return - } - freshness.scheduleDeferred() + const actions = { + ...createAgentStatusAuthorityActions(runtime), + ...createAgentStatusCleanupActions(runtime), + ...createAgentStatusDropActions(runtime), + ...createAgentStatusWorktreeDropActions(runtime), + ...createAgentStatusLaunchActions(runtime), + ...createAgentStatusLiveActions(runtime), + ...createAgentStatusOrchestrationActions(runtime), + ...createAgentStatusProviderSessionActions(runtime), + ...createAgentStatusRecoveryActions(runtime), + ...createAgentStatusRetentionActions(runtime) + } as AgentStatusSlice + composedActions = { + setAgentStatus: actions.setAgentStatus, + recordAgentProviderSession: actions.recordAgentProviderSession } - - const clearSleepingAgentSessionsByPaneKey = (paneKeys: readonly string[]): void => { - if (paneKeys.length === 0) { - return - } - const uniquePaneKeys = new Set(paneKeys) - set((s) => { - let nextSleeping = s.sleepingAgentSessionsByPaneKey - let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - for (const paneKey of uniquePaneKeys) { - if (paneKey in nextSleeping) { - if (nextSleeping === s.sleepingAgentSessionsByPaneKey) { - nextSleeping = { ...nextSleeping } - } - delete nextSleeping[paneKey] - } - if (paneKey in nextLaunchConfigs) { - if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) { - nextLaunchConfigs = { ...nextLaunchConfigs } - } - delete nextLaunchConfigs[paneKey] - } - } - if ( - nextSleeping === s.sleepingAgentSessionsByPaneKey && - nextLaunchConfigs === s.agentLaunchConfigByPaneKey - ) { - return s - } - return { - sleepingAgentSessionsByPaneKey: nextSleeping, - agentLaunchConfigByPaneKey: nextLaunchConfigs - } - }) - } - return { + ...actions, agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, migrationUnsupportedByPtyId: {}, @@ -1626,1863 +103,6 @@ export const createAgentStatusSlice: StateCreator freshness.schedule(), - - retireAgentPaneAuthority: (paneKey, options) => { - const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey) - const retiredPaneKeySet = new Set(retiredPaneKeys) - for (const key of retiredPaneKeys) { - rendererAgentStatusObservations.forget(key) - } - let hadLive = false - set((s) => { - const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) - hadLive = retiredLivePaneKeys.length > 0 - let nextRetentionSuppressedPaneKeys = removePaneKeys( - s.retentionSuppressedPaneKeys, - retiredPaneKeySet - ) - if ( - retiredLivePaneKeys.length > 0 && - nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys - ) { - nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys } - } - for (const key of retiredLivePaneKeys) { - nextRetentionSuppressedPaneKeys[key] = true - } - return { - agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet), - runtimeAgentOrchestrationByPaneKey: removePaneKeys( - s.runtimeAgentOrchestrationByPaneKey, - retiredPaneKeySet - ), - retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet), - sleepingAgentSessionsByPaneKey: options?.preserveSleepingAgentSession - ? s.sleepingAgentSessionsByPaneKey - : removePaneKeys(s.sleepingAgentSessionsByPaneKey, retiredPaneKeySet), - agentLaunchConfigByPaneKey: removePaneKeys( - s.agentLaunchConfigByPaneKey, - retiredPaneKeySet - ), - acknowledgedAgentsByPaneKey: removePaneKeys( - s.acknowledgedAgentsByPaneKey, - retiredPaneKeySet - ), - paneForegroundAgentByPaneKey: removePaneKeys( - s.paneForegroundAgentByPaneKey, - retiredPaneKeySet - ), - unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet), - unreadAgentCompletionPanes: removePaneKeys( - s.unreadAgentCompletionPanes, - retiredPaneKeySet - ), - lastTerminalInputAtByPaneKey: removePaneKeys( - s.lastTerminalInputAtByPaneKey, - retiredPaneKeySet - ), - cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet), - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys( - s.recentlyRetiredAgentStatusPaneKeys, - retiredPaneKeys - ), - agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (hadLive) { - freshness.scheduleDeferred() - } - if (typeof window !== 'undefined') { - window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey) - } - }, - - // Why: the tombstone claims this pane is gone; a live PTY binding to it proves - // otherwise. Lift the fence on that proof rather than on the next hook event — - // a pane re-attached mid-turn or while idle emits no new-turn event, so a - // turn-triggered revival leaves exactly the reported permanent suppression - // (STA-4114). This deliberately does NOT restore the rows retirement dropped; - // those are genuinely stale. It only re-opens the pane to future status. - restoreAgentPaneAuthority: (paneKey) => { - const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - // Why: a closed tab is a stronger, separate claim — re-attach must not undo it. - if ( - isRecentlyClosedAgentStatusTab( - get().recentlyClosedAgentStatusTabIds, - getTabIdFromPaneKey(ownerPaneKey) - ) - ) { - return - } - set((s) => { - const restorable = [paneKey, ownerPaneKey].filter( - (key) => key in s.recentlyRetiredAgentStatusPaneKeys - ) - if (restorable.length === 0) { - return s - } - const next = { ...s.recentlyRetiredAgentStatusPaneKeys } - for (const key of restorable) { - delete next[key] - } - return { recentlyRetiredAgentStatusPaneKeys: next } - }) - // Why: deliberately OUTSIDE the guard above, and not gated on having cleared - // anything here. This map is not a mirror of main's — main fences panes the - // renderer never hears about (retirePtyAgentLaunchAuthority on command-finished - // and PTY exit calls the hook server directly, and nothing pushes that back), and - // this map is per-window and non-persisted, so a renderer reload empties it while - // main's survives. Gating the send on a local tombstone reintroduces STA-4114 for - // exactly those panes. The send is idempotent and main refuses closed tabs itself. - if (typeof window !== 'undefined') { - window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey) - } - }, - - transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => { - const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId }) - if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) { - return - } - const from = transfer.previousOwnerPaneKey - const to = transfer.ownerPaneKey - // Why: the moved row carries the observation stamped for its OLD key; renderer-authored - // observations for the new key must sort after it, not race it. - rendererAgentStatusObservations.forget(from) - rendererAgentStatusObservations.rebind(to) - const targetTabId = getTabIdFromPaneKey(to) ?? undefined - const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined - set((s) => ({ - agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({ - ...entry, - paneKey: to, - tabId: targetTabId - })), - // Why: retention/sidebar consumers gate on the epoch; a moved live row is a - // pane-key change they must observe, not a silent remap. - ...(from in s.agentStatusByPaneKey - ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } - : {}), - runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord( - s.runtimeAgentOrchestrationByPaneKey, - from, - to - ), - retainedAgentsByPaneKey: movePaneKeyedRecord( - s.retainedAgentsByPaneKey, - from, - to, - (retained) => ({ - ...retained, - entry: { ...retained.entry, paneKey: to, tabId: targetTabId }, - tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab - }) - ), - sleepingAgentSessionsByPaneKey: movePaneKeyedRecord( - s.sleepingAgentSessionsByPaneKey, - from, - to, - (record) => ({ ...record, paneKey: to, tabId: targetTabId }) - ), - agentLaunchConfigByPaneKey: movePaneKeyedRecord( - s.agentLaunchConfigByPaneKey, - from, - to, - (entry) => ({ - ...entry, - identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId } - }) - ), - acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to), - paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to), - unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to), - unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to), - lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to), - cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to), - retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to) - })) - if (typeof window !== 'undefined') { - window.api?.agentStatus?.transferPaneAuthority?.({ - fromPaneKey: from, - toPaneKey: to, - ...(transfer.ptyId ? { ptyId: transfer.ptyId } : {}) - }) - } - }, - - setRuntimeAgentOrchestrationByPaneKey: (entries) => { - const generatedTitleUpdates: AgentStatusEntry[] = [] - set((s) => { - const runtimeMapChanged = !orchestrationMapsEqual( - s.runtimeAgentOrchestrationByPaneKey, - entries - ) - let nextLive = s.agentStatusByPaneKey - let liveChanged = false - let nextRetained = s.retainedAgentsByPaneKey - let retainedChanged = false - - for (const [paneKey, runtimeOrchestration] of Object.entries(entries)) { - const liveEntry = nextLive[paneKey] - if (liveEntry) { - const merged = mergeCurrentOrchestrationContext( - liveEntry.orchestration, - runtimeOrchestration - ) - if (merged !== liveEntry.orchestration) { - if (!liveChanged) { - nextLive = { ...nextLive } - liveChanged = true - } - const nextEntry = { ...liveEntry, orchestration: merged } - nextLive[paneKey] = nextEntry - // Why: only replace titles when labels match the live dispatch taskId; sticky completed context must not rename a later turn. - if ( - (merged.displayName?.trim() || merged.taskTitle?.trim()) && - orchestrationLabelsMatchLiveDispatch({ - prompt: nextEntry.prompt, - orchestration: merged - }) - ) { - generatedTitleUpdates.push(nextEntry) - } - } - } - - const retainedEntry = nextRetained[paneKey] - if (retainedEntry) { - const merged = mergeCurrentOrchestrationContext( - retainedEntry.entry.orchestration, - runtimeOrchestration - ) - if (merged !== retainedEntry.entry.orchestration) { - if (!retainedChanged) { - nextRetained = { ...nextRetained } - retainedChanged = true - } - nextRetained[paneKey] = { - ...retainedEntry, - entry: { ...retainedEntry.entry, orchestration: merged } - } - } - } - } - - if (!runtimeMapChanged && !liveChanged && !retainedChanged) { - return s - } - - return { - ...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: entries } : {}), - ...(liveChanged ? { agentStatusByPaneKey: nextLive } : {}), - ...(retainedChanged ? { retainedAgentsByPaneKey: nextRetained } : {}), - ...(liveChanged ? { agentStatusEpoch: s.agentStatusEpoch + 1 } : {}) - } - }) - for (const entry of generatedTitleUpdates) { - get().setGeneratedTabTitleFromAgentPrompt( - entry.paneKey, - getAgentRowGeneratedTitleText(entry), - { - replaceExistingGeneratedTitle: true - } - ) - } - }, - - registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => { - set((s) => { - const copiedLaunchConfig = copyLaunchConfig(launchConfig) - const nextRegistryEntry: AgentLaunchConfigRegistryEntry = { - launchConfig: copiedLaunchConfig, - registeredAt: Date.now(), - identity: normalizeLaunchConfigRegistrationMetadata(paneKey, metadata) - } - const existingRegistryEntry = s.agentLaunchConfigByPaneKey[paneKey] - const registryChanged = !launchConfigRegistryEntriesEqual( - existingRegistryEntry, - nextRegistryEntry - ) - const existingEntry = s.agentStatusByPaneKey[paneKey] - const entryMatchesRegistry = registryEntryMatchesStatus({ - entry: nextRegistryEntry, - paneKey, - agentType: existingEntry?.agentType, - tabId: existingEntry?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, - terminalHandle: existingEntry?.terminalHandle, - launchToken: metadata?.launchToken, - providerSession: existingEntry?.providerSession, - existingProviderSession: existingEntry?.providerSession, - providerSessionChanged: false - }) - const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] - let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey - if (existingSleepingRecord && entryMatchesRegistry && existingEntry) { - const worktreeId = - existingEntry.worktreeId ?? - existingSleepingRecord.worktreeId ?? - findAgentPaneWorktreeId(s, paneKey) - const refreshedRecord = worktreeId - ? sleepingRecordFromEntry({ - state: s, - entry: existingEntry, - worktreeId, - capturedAt: existingSleepingRecord.capturedAt, - launchConfig: copiedLaunchConfig, - origin: existingSleepingRecord.origin - }) - : null - if (refreshedRecord) { - nextSleepingAgentSessions = { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: { - ...refreshedRecord, - capturedAt: existingSleepingRecord.capturedAt - } - } - } - } - if (!registryChanged && nextSleepingAgentSessions === s.sleepingAgentSessionsByPaneKey) { - return s - } - return { - ...(registryChanged - ? { - agentLaunchConfigByPaneKey: { - ...s.agentLaunchConfigByPaneKey, - [paneKey]: nextRegistryEntry - } - } - : {}), - ...(nextSleepingAgentSessions !== s.sleepingAgentSessionsByPaneKey - ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions } - : {}) - } - }) - }, - getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry), - getAgentLaunchConfigForStatusMetadata: (metadata) => - getLaunchConfigForStatusMetadata(get(), metadata), - - clearAgentLaunchConfig: (paneKey) => { - set((s) => { - if (!(paneKey in s.agentLaunchConfigByPaneKey)) { - return s - } - const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } - delete nextLaunchConfigs[paneKey] - return { agentLaunchConfigByPaneKey: nextLaunchConfigs } - }) - }, - - recordAgentProviderSession: (paneKey, agent, providerSession, timing, routing, metadata) => { - paneKey = resolveAgentPaneAuthorityKey(paneKey) - const updatedAt = timing?.updatedAt ?? Date.now() - if ( - paneKey in get().recentlyRetiredAgentStatusPaneKeys || - isRecentlyClosedAgentStatusTab( - get().recentlyClosedAgentStatusTabIds, - getTabIdFromPaneKey(paneKey) - ) || - !getAgentResumeArgv(agent, providerSession) - ) { - return - } - let removedLiveStatus = false - set((s) => { - const existingStatus = s.agentStatusByPaneKey[paneKey] - const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] - if ( - (existingStatus && updatedAt < existingStatus.updatedAt) || - (existingRecord && updatedAt < existingRecord.updatedAt) - ) { - return s - } - const tabId = routing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? existingRecord?.tabId - const worktreeId = - routing?.worktreeId ?? - existingStatus?.worktreeId ?? - existingRecord?.worktreeId ?? - findAgentPaneWorktreeId(s, paneKey) - if (!worktreeId) { - return s - } - const registryEntry = s.agentLaunchConfigByPaneKey[paneKey] - const registryMatches = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey, - agentType: agent, - tabId, - terminalHandle: undefined, - launchToken: metadata?.launchToken, - providerSession, - existingProviderSession: existingRecord?.providerSession, - providerSessionChanged: false - }) - const existingRecordMatchesProviderSession = - existingRecord?.agent === agent && - agentProviderSessionsEqual(agent, existingRecord.providerSession, providerSession) - // Why: provider-session heartbeats can arrive after the turn is complete; preserve the - // completed checkpoint so a late heartbeat cannot make it eligible for ghost resume. - const preservesCompletedRecoveryRecord = - existingRecordMatchesProviderSession && existingRecord?.state === 'done' - // Why: an explicit quit capture must remain the resume handle until a new provider session replaces it. - const preservesQuitOrigin = - existingRecordMatchesProviderSession && existingRecord?.origin === 'quit' - const launchConfig = - (registryMatches ? registryEntry?.launchConfig : undefined) ?? - (existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined) - const record: SleepingAgentSessionRecord = { - paneKey, - ...(tabId ? { tabId } : {}), - worktreeId, - agent, - providerSession, - prompt: '', - // Why: durable process/session identity, not visible turn state; a non-done value keeps cold restore eligible. - state: preservesCompletedRecoveryRecord ? 'done' : 'working', - capturedAt: updatedAt, - updatedAt, - ...(existingStatus?.terminalTitle - ? { terminalTitle: existingStatus.terminalTitle } - : existingRecord?.terminalTitle - ? { terminalTitle: existingRecord.terminalTitle } - : {}), - ...(routing?.connectionId !== undefined - ? { connectionId: routing.connectionId } - : existingRecord?.connectionId !== undefined - ? { connectionId: existingRecord.connectionId } - : {}), - ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(existingRecordMatchesProviderSession && - existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } - : {}), - ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined - ? { interrupted: existingRecord.interrupted } - : {}), - origin: preservesQuitOrigin ? 'quit' : 'live' - } - removedLiveStatus = existingStatus !== undefined - const nextLive = removedLiveStatus ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (removedLiveStatus) { - delete nextLive[paneKey] - } - const nextRetained = - paneKey in s.retainedAgentsByPaneKey - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (nextRetained !== s.retainedAgentsByPaneKey) { - delete nextRetained[paneKey] - } - // Why: on identity mismatch the sleeping record drops its launch config, so clear the stale - // registry entry too, else a later return to the old identity reuses stale args/env. - let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - if (registryMatches && registryEntry) { - nextLaunchConfigs = { - ...nextLaunchConfigs, - [paneKey]: { - ...registryEntry, - identity: { ...registryEntry.identity, providerSession } - } - } - } else if (registryEntry) { - nextLaunchConfigs = { ...nextLaunchConfigs } - delete nextLaunchConfigs[paneKey] - } - return { - agentStatusByPaneKey: nextLive, - retainedAgentsByPaneKey: nextRetained, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: record - }, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - acknowledgedAgentsByPaneKey: removePaneKeys( - s.acknowledgedAgentsByPaneKey, - new Set([paneKey]) - ), - unreadAgentCompletionPanes: removePaneKeys( - s.unreadAgentCompletionPanes, - new Set([paneKey]) - ), - agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (removedLiveStatus) { - requestAgentStatusFreshness(true) - } - }, - - setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => { - paneKey = resolveAgentPaneAuthorityKey(paneKey) - const updatedAt = timing?.updatedAt ?? Date.now() - if ( - paneKey in get().recentlyRetiredAgentStatusPaneKeys || - // Why: a closed tab is no longer a valid destination for hook replays or late status events. - isRecentlyClosedAgentStatusTab( - get().recentlyClosedAgentStatusTabIds, - getTabIdFromPaneKey(paneKey) - ) - ) { - return - } - let completionRefreshWorktreeId: string | null = null - let suppressedInheritedTerminalStatus = false - const generatedTitleEntry: { current: AgentStatusEntry | null } = { current: null } - set((s) => { - const existing = s.agentStatusByPaneKey[paneKey] - // Why: snapshots and live pushes share one timestamp source, so equal timestamps carry - // identical data; strict < preserves same-millisecond live-after-live updates. - if (existing && updatedAt < existing.updatedAt) { - return s - } - // Why: terminalTitle labels the pane itself, not the turn, so a missing title means "no update" — - // preserve the prior value to avoid flicker (unlike tool/prompt fields, which clear on a fresh turn). - const effectiveTitle = terminalTitle ?? existing?.terminalTitle - - // Rolling log of state transitions for the dashboard's activity blocks; push only on - // real state changes to avoid dupes from prompt-only pings within the same state. - // A session-boundary 'done' (idle connect, STA-3386) is not a turn event — keep it - // out of history so activity feeds and unread counts never surface it. The inverse - // also holds: a boundary landing on a REAL done (resume//clear right after a finish) - // must push that completion into history, or the finished timestamp and unread badge - // lose the turn the moment the flag overwrites the live entry. - let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] - // Why: a batched burst can fold a whole done→working turn into one publication, so a - // completion-reactive subscriber never sees `lastAssistantMessage` while state is `done`. - // One slot per entry, not one per history row — 20 transcripts per live status OOMs (#9872). - let lastCompletedAssistantMessage = existing?.lastCompletedAssistantMessage - const boundaryLandsOnRealDone = - existing?.state === 'done' && - existing.sessionBoundary !== true && - payload.state === 'done' && - payload.sessionBoundary === true - if ( - existing && - (existing.state !== payload.state || boundaryLandsOnRealDone) && - !(existing.state === 'done' && existing.sessionBoundary === true) - ) { - history = [ - ...history, - { - state: existing.state, - prompt: existing.prompt, - // Why: use stateStartedAt (not updatedAt) so the row reflects when the state was first reported, not the latest within-state ping. - startedAt: existing.stateStartedAt, - // Why: preserve the interrupt flag on the historical `done` entry so activity-block views can render past cancellations. - interrupted: existing.interrupted - } - ] - if (history.length > AGENT_STATE_HISTORY_MAX) { - history = history.slice(history.length - AGENT_STATE_HISTORY_MAX) - } - if (existing.state === 'done') { - // The push above just moved this completion out of the live entry; a done that - // carried no message must clear the slot, or a stale prior turn leaks forward. - lastCompletedAssistantMessage = existing.lastAssistantMessage - } - } - - const identity = resolveAgentStatusIdentity({ - existing: existing - ? { - agentType: existing.agentType, - state: existing.state, - updatedAt: existing.updatedAt, - restoredUnconfirmed: existing.restoredUnconfirmed - } - : undefined, - incoming: payload.agentType, - now: updatedAt - }) - // Why: Command Code has no UserPromptSubmit; a fresh transcript prompt while still `working` is the smart-sort turn boundary. - const commandCodeNewTurn = - existing !== undefined && - isCommandCodeNewTurnWhileWorking({ - agentType: identity.agentType, - previousState: existing.state, - incomingState: payload.state, - previousPrompt: existing.prompt, - incomingPrompt: payload.prompt, - previousPromptInteractionKey: existing.promptInteractionKey, - incomingPromptInteractionKey: payload.promptInteractionKey - }) - const promptInteractionKey = - payload.promptInteractionKey ?? - (payload.prompt === existing?.prompt ? existing?.promptInteractionKey : undefined) - // Why: prefer main's authoritative stateStartedAt (attachStatusTiming persists it across - // same-state pings and restart); fall back to existing only when main sent no timing, updatedAt for a new pane. - const stateStartedAt = - timing?.stateStartedAt ?? - (commandCodeNewTurn - ? updatedAt - : existing && existing.state === payload.state - ? existing.stateStartedAt - : updatedAt) - if ( - existing && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: payload.state - }) - ) { - suppressedInheritedTerminalStatus = true - return s - } - - // Why: tool/assistant fields arrive pre-merged and authoritative from main (resolveToolState - // in server.ts), so write them through directly — no fallback — so UserPromptSubmit clears stale tool lines. - const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[paneKey] - const runtimeMergedOrchestration = runtimeOrchestration - ? mergeCurrentOrchestrationContext(existing?.orchestration, runtimeOrchestration) - : undefined - const payloadMergedOrchestration = payload.orchestration - ? mergeCurrentOrchestrationContext( - runtimeMergedOrchestration ?? existing?.orchestration, - payload.orchestration - ) - : undefined - const completedFallbackOrchestration = - payload.state === 'done' ? existing?.orchestration : undefined - const orchestration = - payloadMergedOrchestration ?? runtimeMergedOrchestration ?? completedFallbackOrchestration - // Why: waiting/blocked are still the same resumable turn; child permission hooks omit the root session id. - // Completing a turn does not end the provider session either — the TUI stays alive and resumable at its - // prompt — so `done` must carry the id through, including done→done (OSC 9999 repaints and reconnect - // snapshot replays both re-deliver a metadata-less `done` onto an already-done row). Without that, every - // surface keyed on the id — mobile Chat UI transcripts, the resumable recovery anchor below — loses the - // session while the agent sits idle, which is precisely when it is read (#10630). Only a new turn - // (done→working) still drops it, so a reused pane cannot inherit a finished session. - const canReuseExistingProviderSession = - existing?.agentType === identity.agentType && - (existing.state !== 'done' || payload.state === 'done') - const providerSession = - metadata?.providerSession ?? - (canReuseExistingProviderSession ? existing.providerSession : undefined) - const existingProviderSession = canReuseExistingProviderSession - ? existing.providerSession - : undefined - const providerSessionChanged = - Boolean(metadata?.providerSession && existingProviderSession) && - !agentProviderSessionsEqual( - identity.agentType, - metadata?.providerSession, - existingProviderSession - ) - const statusTabId = - routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined - const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle - const registryEntry = s.agentLaunchConfigByPaneKey[paneKey] - const matchedRegistryLaunchConfig = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey, - agentType: identity.agentType, - tabId: statusTabId, - terminalHandle: statusTerminalHandle, - launchToken: metadata?.launchToken, - providerSession, - existingProviderSession, - providerSessionChanged - }) - ? registryEntry?.launchConfig - : undefined - const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] - // Why: a completed turn leaves the TUI session alive and resumable at its prompt for any - // resumable agent (Claude/Codex/Pi/…), not just Pi — so keep its persisted recovery anchor - // even when done. Else a cold restore after an abrupt app death (macOS logout, #9454) drops - // the pane to a bare shell instead of `--resume`-ing the agent logged in. - const retainsResumableRecoveryIdentity = - payload.state === 'done' && - isResumableTuiAgent(identity.agentType) && - providerSession !== undefined && - getAgentResumeArgv(identity.agentType, providerSession) !== null - const matchedSleepingLaunchConfig = - (payload.state !== 'done' || retainsResumableRecoveryIdentity) && - existingSleepingRecord?.launchConfig && - existingSleepingRecord.agent === identity.agentType && - providerSession && - agentProviderSessionsEqual( - identity.agentType, - existingSleepingRecord.providerSession, - providerSession - ) - ? existingSleepingRecord.launchConfig - : undefined - // Why: on a reused pane key, once the provider session changes the old launch registry must not bleed options into the new session. - const launchConfigSource = - (payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken - ? metadata?.launchConfig - : undefined) ?? - matchedRegistryLaunchConfig ?? - matchedSleepingLaunchConfig - const entry: AgentStatusEntry = { - state: payload.state, - workingMode: payload.workingMode, - prompt: payload.prompt, - updatedAt, - stateStartedAt, - agentType: identity.agentType, - model: - payload.model ?? - (existing?.agentType === identity.agentType ? existing.model : undefined), - paneKey, - terminalHandle: statusTerminalHandle, - worktreeId: - routing?.worktreeId ?? - existing?.worktreeId ?? - findAgentPaneWorktreeId(s, paneKey) ?? - undefined, - ...(routing?.connectionId !== undefined - ? { connectionId: routing.connectionId } - : existing?.connectionId !== undefined - ? { connectionId: existing.connectionId } - : s.sleepingAgentSessionsByPaneKey[paneKey]?.connectionId !== undefined - ? { connectionId: s.sleepingAgentSessionsByPaneKey[paneKey].connectionId } - : {}), - tabId: statusTabId, - terminalTitle: effectiveTitle, - stateHistory: history, - toolName: payload.toolName, - toolInput: payload.toolInput, - // Why: full untruncated AskUserQuestion JSON so mobile/web can render the live prompt - // card; parseAgentStatusPayload clears it on tool/state change. - interactivePrompt: payload.interactivePrompt, - lastAssistantMessage: payload.lastAssistantMessage, - lastAssistantMessageIsToolOutput: payload.lastAssistantMessageIsToolOutput, - ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), - // Why: reused panes can start non-orchestrated work; only final done rows keep the - // previous lineage fallback so completed children stay grouped. - orchestration, - // Why: reuse the prior array ref when the roster is unchanged so identity-comparing subscribers skip re-renders. - subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) - ? existing?.subagents - : payload.subagents, - ...(providerSession ? { providerSession } : {}), - ...(metadata?.terminalResumeEligible === false - ? { terminalResumeEligible: false as const } - : {}), - ...(promptInteractionKey ? { promptInteractionKey } : {}), - ...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - // Why: `updatedAt` cannot order two writes inside one millisecond — and the accept check - // above admits equal timestamps — so a deferred process-exit drop needs a token ordered by - // construction to tell "the pane reported again" from "an unrelated field moved". Every - // field-level rewrite of a row spreads it forward, so only a real report re-stamps it. - // - // Derived from the row it replaces, not from a module counter: there is then nothing for a - // sibling teardown path to reset (the bug this replaced), and a batched burst lands the - // same ordinals as the equivalent sequential writes. - acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1, - // Why: never inherited from `existing` — an unstamped write is an unstamped - // observation, not the previous one repeated. - ...(payload.observation ? { observation: payload.observation } : {}), - // Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly. - interrupted: payload.interrupted, - // Why: done→done repaints (OSC 9999, reconnect snapshot replays) re-deliver a - // metadata-less `done`; preserving the flag there keeps completion-reactive - // consumers from treating the still-idle session as newly finished. Turn evidence - // (an assistant message or a changed prompt) proves a REAL completion — never - // carry the flag over one, or a genuine finish could be silently suppressed. - sessionBoundary: - payload.sessionBoundary ?? - (existing?.state === 'done' && - payload.state === 'done' && - payload.lastAssistantMessage === undefined && - payload.prompt === existing.prompt - ? existing.sessionBoundary - : undefined) - } - // Why: a boundary `done` becoming a REAL completion does not advance - // `stateStartedAt`, so hibernation would still judge the row by its ancient - // anchor. Stamp it here, synchronously — sampling on the 60s coordinator tick - // misses a boundary written and cleared between two samples. - if ( - entry.state === 'done' && - entry.sessionBoundary !== true && - existing?.sessionBoundary === true - ) { - recordHibernationBoundaryResolved(paneKey, updatedAt) - } - generatedTitleEntry.current = entry - if ( - isAgentCompletionState(entry.state) && - entry.sessionBoundary !== true && - existing !== undefined && - !isAgentCompletionState(existing.state) - ) { - completionRefreshWorktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, paneKey) - } - // Why: emit a global tick only when an entry appears, changes state, crosses stale→fresh, - // or is a same-state `done` update — same-state working pings must not fan out to aggregates. - const wasFresh = - !!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS) - // Why: a late main-process attribution stamp can change which workspace stays visible without changing agent state. - const attributionChanged = - existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId - // Why: main can advance stateStartedAt on a same-state turn boundary the renderer - // missed; treat that as sort-relevant so smart sort never goes stale. - // Non-Command-Code agents never advance stateStartedAt at a fixed state, so this stays CC-scoped. - const sameStateStateStartedAtChanged = - !!existing && - existing.state === payload.state && - entry.stateStartedAt !== existing.stateStartedAt - const sameStateDoneAttentionChanged = - existing?.state === 'done' && - entry.state === 'done' && - agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) - const workingModeChanged = existing?.workingMode !== entry.workingMode - const sortRelevantChange = - !existing || - existing.state !== payload.state || - !wasFresh || - attributionChanged || - commandCodeNewTurn || - sameStateStateStartedAtChanged || - sameStateDoneAttentionChanged - const doneRetentionFieldsChanged = - existing?.state === 'done' && - entry.state === 'done' && - (entry.prompt !== existing.prompt || - entry.updatedAt !== existing.updatedAt || - entry.stateStartedAt !== existing.stateStartedAt || - entry.agentType !== existing.agentType || - entry.model !== existing.model || - entry.terminalTitle !== existing.terminalTitle || - entry.toolName !== existing.toolName || - entry.toolInput !== existing.toolInput || - entry.lastAssistantMessage !== existing.lastAssistantMessage || - entry.lastAssistantMessageIsToolOutput !== existing.lastAssistantMessageIsToolOutput || - entry.orchestration !== existing.orchestration || - entry.subagents !== existing.subagents || - entry.providerSession !== existing.providerSession || - entry.interrupted !== existing.interrupted) - const retentionRelevantChange = - sortRelevantChange || - attributionChanged || - workingModeChanged || - doneRetentionFieldsChanged - // Why: a fresh status means the agent is live again — lift its one-shot retention suppressor. - // Clone the map only when a suppressor exists, else every high-frequency ping churns the ref. - const hasSuppressor = paneKey in s.retentionSuppressedPaneKeys - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (hasSuppressor) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - delete nextRetentionSuppressedPaneKeys[paneKey] - } - // Why: pane keys are reused across turns, so a fresh live row makes any retained snapshot stale — drop it so it doesn't render beside the live row. - const hasRetainedSnapshot = paneKey in s.retainedAgentsByPaneKey - const nextRetainedAgents = hasRetainedSnapshot - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (hasRetainedSnapshot) { - delete nextRetainedAgents[paneKey] - } - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - const liveRecoveryWorktreeId = - entry.state === 'done' && !retainsResumableRecoveryIdentity - ? null - : (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey)) - const liveRecoveryRecord = liveRecoveryWorktreeId - ? sleepingRecordFromEntry({ - state: s, - // Why: keep the resume identity of a finished turn without its text, - // but never restate `done` as pending work — the resume sweep reads - // that state to tell an interrupted agent from a completed one, and - // a lie there respawns every finished agent whose pane was killed. - entry: retainsResumableRecoveryIdentity - ? { ...entry, prompt: '', lastAssistantMessage: undefined } - : entry, - worktreeId: liveRecoveryWorktreeId, - capturedAt: updatedAt, - launchConfig: launchConfigSource, - origin: 'live' - }) - : null - let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey - let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - if ( - matchedRegistryLaunchConfig && - registryEntry && - providerSession && - !agentProviderSessionsEqual( - identity.agentType, - registryEntry.identity.providerSession, - providerSession - ) - ) { - nextLaunchConfigs = { - ...nextLaunchConfigs, - [paneKey]: { - ...registryEntry, - identity: { - ...registryEntry.identity, - providerSession - } - } - } - } - // Why: launch tokens can outlive an Orca-started TUI in the shell; once the session is done they must no longer authorize config reuse. - // A session-boundary done is the session CONNECTING (STA-3386) — deleting here would strip - // the pane's registered-launch-agent identity evidence the moment a resumed TUI sits idle. - if ( - (providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) && - paneKey in s.agentLaunchConfigByPaneKey - ) { - nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } - delete nextLaunchConfigs[paneKey] - } - if (liveRecoveryRecord) { - if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) { - nextSleepingAgentSessions = { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: liveRecoveryRecord - } - } - } else if (existingSleepingRecord) { - nextSleepingAgentSessions = { ...s.sleepingAgentSessionsByPaneKey } - delete nextSleepingAgentSessions[paneKey] - } - const nextLive = { ...s.agentStatusByPaneKey, [paneKey]: entry } - // Why: cap the live map so a huge map's per-ping spread copy can't OOM the renderer (#9872). - const evictedPaneKeys = capLiveAgentStatusesInPlace( - nextLive, - paneKey, - () => classifyPaneKeyLiveness(s), - updatedAt - ) - const evictedOrphans = evictedPaneKeys.length > 0 - if (evictedOrphans) { - const evictedPaneKeySet = new Set(evictedPaneKeys) - nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evictedPaneKeySet) - nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evictedPaneKeySet) - } - return { - agentStatusByPaneKey: nextLive, - retainedAgentsByPaneKey: nextRetainedAgents, - sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - agentStatusEpoch: - retentionRelevantChange || migrationUnsupported.changed || evictedOrphans - ? s.agentStatusEpoch + 1 - : s.agentStatusEpoch, - sortEpoch: - sortRelevantChange || migrationUnsupported.changed || evictedOrphans - ? s.sortEpoch + 1 - : s.sortEpoch - } - }) - if (suppressedInheritedTerminalStatus) { - return - } - const entryForGeneratedTitle = generatedTitleEntry.current - if (entryForGeneratedTitle) { - // Why: sticky orchestration (~30m) can outlive the dispatch turn, so replace the title on matching labels or a re-dispatch's mismatched taskId. - const hasMatchingOrchestrationLabels = Boolean( - (entryForGeneratedTitle.orchestration?.displayName?.trim() || - entryForGeneratedTitle.orchestration?.taskTitle?.trim()) && - orchestrationLabelsMatchLiveDispatch(entryForGeneratedTitle) - ) - const liveIsDispatchPrompt = isOrcaDispatchPrompt(entryForGeneratedTitle.prompt) - const liveDispatchTaskId = liveIsDispatchPrompt - ? getOrcaDispatchTaskId(entryForGeneratedTitle.prompt) - : null - const stickyOrchestrationTaskId = - entryForGeneratedTitle.orchestration?.taskId?.trim() || null - const isNewDispatchAgainstStickyOrchestration = Boolean( - liveDispatchTaskId && - stickyOrchestrationTaskId && - liveDispatchTaskId !== stickyOrchestrationTaskId - ) - const shouldReplaceGeneratedTitle = - hasMatchingOrchestrationLabels || isNewDispatchAgainstStickyOrchestration - // Why: setAgentStatus is high-frequency, so only parse dispatch preambles when a title write is actually possible. - const mayWriteGeneratedTitle = - get().settings?.tabAutoGenerateTitle === true && - (shouldReplaceGeneratedTitle || - !agentStatusTabAlreadyHasProtectedOrGeneratedTitle( - get(), - entryForGeneratedTitle.tabId ?? getTabIdFromPaneKey(paneKey), - entryForGeneratedTitle.worktreeId - )) - const generatedTitlePrompt = - liveIsDispatchPrompt && mayWriteGeneratedTitle - ? getAgentRowGeneratedTitleText(entryForGeneratedTitle) - : entryForGeneratedTitle.prompt - if (shouldReplaceGeneratedTitle) { - applyGeneratedTabTitleUpdate({ - paneKey, - prompt: generatedTitlePrompt, - options: { - replaceExistingGeneratedTitle: true - } - }) - } else { - applyGeneratedTabTitleUpdate({ paneKey, prompt: generatedTitlePrompt }) - } - } - // Why: batches coalesce accepted updates; standalone calls keep their existing deferred scheduling. - requestAgentStatusFreshness(generatedTitleEntry.current !== null) - if (completionRefreshWorktreeId) { - const worktreeId = completionRefreshWorktreeId - // Why: agents can create a PR via `gh pr create`, bypassing Orca's flow and leaving a stale "no PR" cache entry in place. - queueMicrotask(() => get().refreshGitHubForWorktreeIfStale(worktreeId)) - } - }, - - setAgentStatuses: (updates) => - updates.length === 0 - ? [] - : transactAgentStatuses((transaction) => updates.map(transaction.apply)), - - transactAgentStatuses, - - setMigrationUnsupportedPty: (entry) => { - set((s) => { - const existing = s.migrationUnsupportedByPtyId[entry.ptyId] - if (existing && entry.updatedAt < existing.updatedAt) { - return s - } - return { - migrationUnsupportedByPtyId: { - ...s.migrationUnsupportedByPtyId, - [entry.ptyId]: entry - }, - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - }, - - clearMigrationUnsupportedPty: (ptyId) => { - if (!(ptyId in get().migrationUnsupportedByPtyId)) { - return - } - set((s) => { - const next = { ...s.migrationUnsupportedByPtyId } - delete next[ptyId] - return { - migrationUnsupportedByPtyId: next, - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - }, - - removeAgentStatus: (paneKey) => { - if ( - !(paneKey in get().agentStatusByPaneKey) && - !(paneKey in get().agentLaunchConfigByPaneKey) && - !Object.values(get().migrationUnsupportedByPtyId).some((entry) => entry.paneKey === paneKey) - ) { - return - } - set((s) => { - const hasLive = paneKey in s.agentStatusByPaneKey - const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (hasLive) { - delete next[paneKey] - } - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - // Why: drop the ack entry with the pane so a future paneKey collision can't inherit a stale ack that suppresses "unvisited" signals. - let nextAck = s.acknowledgedAgentsByPaneKey - if (paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } - // Why: bump sortEpoch with agentStatusEpoch — removing an agent can change worktree sort order (same as setAgentStatus). - return { - agentStatusByPaneKey: next, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - freshness.scheduleDeferred() - }, - - removeAgentStatusByTabPrefix: (tabIdPrefix) => { - const prefix = `${tabIdPrefix}:` - const currentKeys = Object.keys(get().agentStatusByPaneKey) - const toRemove = currentKeys.filter((k) => k.startsWith(prefix)) - const launchConfigKeys = Object.keys(get().agentLaunchConfigByPaneKey).filter((k) => - k.startsWith(prefix) - ) - const hasMigrationUnsupported = Object.values(get().migrationUnsupportedByPtyId).some( - (entry) => entry.paneKey?.startsWith(prefix) - ) - if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) { - return - } - set((s) => { - const next = { ...s.agentStatusByPaneKey } - for (const key of toRemove) { - delete next[key] - } - const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey?.startsWith(prefix) ?? false - ) - // See removeAgentStatus for rationale on ack cleanup. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter((k) => k.startsWith(prefix)) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const k of ackKeys) { - delete nextAck[k] - } - } - // Why: bump sortEpoch with agentStatusEpoch — removing agents can change worktree sort order (same as setAgentStatus). - return { - agentStatusByPaneKey: next, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - freshness.scheduleDeferred() - }, - - clearTransientAgentStatuses: (connectionId, clearedAt) => { - if (connectionId.length === 0 || !Number.isFinite(clearedAt)) { - return - } - let removed = false - set((s) => { - const worktreeIdsOnConnection = collectWorktreeIdsForConnection(s, connectionId) - let next: Record | null = null - for (const [paneKey, existing] of Object.entries(s.agentStatusByPaneKey)) { - if (existing.updatedAt > clearedAt) { - continue - } - // Why: clear rows stamped for this connection, plus UNSTAMPED (never-stamped) worktree - // rows unambiguously on it (#9030). An explicit stamp — another host's connectionId, or a - // local `null` — is authoritative and never overridden by worktree inference. - const belongsToConnection = - existing.connectionId === connectionId || - (existing.connectionId === undefined && - existing.worktreeId !== undefined && - worktreeIdsOnConnection.has(existing.worktreeId)) - if (!belongsToConnection) { - continue - } - next ??= { ...s.agentStatusByPaneKey } - delete next[paneKey] - } - const wasAlreadyBlocked = connectionId in s.transientClearedAgentStatusConnectionIds - if (!next && wasAlreadyBlocked) { - return s - } - removed = next !== null - // Why: transport loss is reversible. Keep launch, resume, retention, - // and acknowledgement maps intact for same-pane relay replay. - return { - ...(next - ? { - agentStatusByPaneKey: next, - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - : {}), - transientClearedAgentStatusConnectionIds: wasAlreadyBlocked - ? s.transientClearedAgentStatusConnectionIds - : { ...s.transientClearedAgentStatusConnectionIds, [connectionId]: true } - } - }) - if (removed) { - freshness.scheduleDeferred() - } - }, - - dropAgentStatus: (paneKey) => { - // Why: zustand set is synchronous, so capture liveExisted once inside the callback instead of double-reading the store. - let liveExisted = false - set((s) => { - const hasLive = paneKey in s.agentStatusByPaneKey - liveExisted = hasLive - const hasRetained = paneKey in s.retainedAgentsByPaneKey - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - // See removeAgentStatus for ack-cleanup rationale; the ack entry is owned by the pane lifecycle regardless of live/retained state. - let nextAck = s.acknowledgedAgentsByPaneKey - if (paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - // Why: short-circuit when there's nothing to change, but still flush a pending ack or launch-config cleanup if one is present. - if (!hasLive && !hasRetained && !migrationUnsupported.changed) { - if (hasLaunchConfig) { - return { - agentLaunchConfigByPaneKey: nextLaunchConfigs, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}) - } - } - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { acknowledgedAgentsByPaneKey: nextAck } - } - return s - } - - const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (hasLive) { - delete nextLive[paneKey] - } - const nextRetained = hasRetained - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (hasRetained) { - delete nextRetained[paneKey] - } - - // Why: explicit teardown must not let retention sync resurrect this row — plant a one-shot suppressor, but only when hasLive (a retained-only key has no live→gone transition to consume it, so it leaks) and not already present (re-spreading spuriously re-renders subscribers). - const needsSuppressorWrite = hasLive && !(paneKey in s.retentionSuppressedPaneKeys) - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - ...(needsSuppressorWrite - ? { - retentionSuppressedPaneKeys: { - ...s.retentionSuppressedPaneKeys, - [paneKey]: true - } - } - : {}), - agentStatusEpoch: - hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - // Why: mirrors removeAgentStatus — dropping a live agent changes its worktree sort score, so bump sortEpoch to recompute the sidebar smart-sort. - sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - }) - // Why: freshness.schedule only matters when the live map changed, so gate on the live presence observed inside set() — no-op/retained-only drops skip it. - if (liveExisted) { - freshness.scheduleDeferred() - } - // Why: propagate the dismissal to the main-process hook cache so the on-disk cache doesn't re-hydrate this row on next launch. Fire-and-forget. - // Why: the typeof window guard keeps the slice usable from the node test env, where window is undefined. - if (typeof window !== 'undefined') { - window.api?.agentStatus?.drop?.(paneKey) - } - }, - - dropAgentStatusByTabPrefix: (tabIdPrefix, opts) => { - const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix) - let hadLive = false - set((s) => { - const dropped = buildAgentStatusTabPrefixDropPatch( - s, - tabIdPrefix, - retiredAliasPaneKeys, - opts - ) - hadLive = dropped.hadLive - return dropped.patch - }) - if (hadLive) { - freshness.scheduleDeferred() - } - if (typeof window !== 'undefined') { - window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix) - } - }, - - dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => { - let hadLive = false - set((s) => { - const liveEntry = s.agentStatusByPaneKey[paneKey] - const hasLive = liveEntry !== undefined - const hasRetained = paneKey in s.retainedAgentsByPaneKey - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - const retainedEvidence = new Map() - for (const retained of opts?.retainedCompletionEvidence ?? []) { - if ( - retained.entry.paneKey === paneKey && - !liveEntry && - shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained) - ) { - retainedEvidence.set(paneKey, retained) - } - } - if ( - liveEntry?.state === 'done' && - liveEntry.agentType !== undefined && - liveEntry.interrupted !== true - ) { - retainedEvidence.set( - paneKey, - retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType) - ) - } - const keepsCompletionEvidence = retainedEvidence.has(paneKey) - let nextAck = s.acknowledgedAgentsByPaneKey - if (!keepsCompletionEvidence && paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } - if ( - !hasLive && - !hasRetained && - !hasLaunchConfig && - !migrationUnsupported.changed && - !keepsCompletionEvidence - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { acknowledgedAgentsByPaneKey: nextAck } - } - return s - } - hadLive = hasLive - - const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (hasLive) { - delete nextLive[paneKey] - } - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - - const nextRetained = - hasRetained || keepsCompletionEvidence - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (hasRetained && !keepsCompletionEvidence) { - delete nextRetained[paneKey] - } - for (const [key, retained] of retainedEvidence) { - if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) { - nextRetained[key] = retained - } - } - - const needsSuppressor = - hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys) - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - ...(needsSuppressor - ? { - retentionSuppressedPaneKeys: { - ...s.retentionSuppressedPaneKeys, - [paneKey]: true - } - } - : {}), - agentStatusEpoch: - hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (hadLive) { - freshness.scheduleDeferred() - } - }, - - dropAgentStatusByWorktree: (worktreeId, opts) => { - let hadLive = false - set((s) => { - const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( - ([paneKey, entry]) => - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) - const liveKeys = liveEntries.map(([paneKey]) => paneKey) - const liveKeySet = new Set(liveKeys) - const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( - (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) - ) - const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) - .filter( - ([paneKey, retained]) => - retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) - .map(([paneKey]) => paneKey) - const retainedKeySet = new Set(retainedKeys) - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => - entry.worktreeId === worktreeId || - (entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false) - ) - const allowedPaneKeys = normalizePaneKeySet(opts?.sleepingPaneKeys) - const preserveHibernatedEvidence = - opts?.shutdownReason === 'auto-hibernate-completed-agent' && - allowedPaneKeys !== null && - allowedPaneKeys.size > 0 - const liveEntryByPaneKey = new Map(liveEntries) - const retainedEvidence = new Map() - if (preserveHibernatedEvidence) { - for (const retained of opts?.retainedCompletionEvidence ?? []) { - if ( - allowedPaneKeys.has(retained.entry.paneKey) && - !liveEntryByPaneKey.has(retained.entry.paneKey) && - shouldReplaceRetainedWithLive(retainedEvidence.get(retained.entry.paneKey), retained) - ) { - retainedEvidence.set(retained.entry.paneKey, retained) - } - } - for (const [paneKey, entry] of liveEntries) { - const agentType = entry.agentType - if ( - allowedPaneKeys.has(paneKey) && - entry.state === 'done' && - agentType !== undefined && - entry.interrupted !== true - ) { - retainedEvidence.set( - paneKey, - retainedAgentEntryFromLive(s, worktreeId, entry, agentType) - ) - } - } - } - const retainedEvidenceKeys = new Set(retainedEvidence.keys()) - // See removeAgentStatus for ack-cleanup rationale; auto-hibernated completion evidence keeps its read state so a slept card doesn't turn bold again. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter( - (k) => - !retainedEvidenceKeys.has(k) && - (paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || - liveKeySet.has(k) || - retainedKeySet.has(k)) - ) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const key of ackKeys) { - delete nextAck[key] - } - } - // Mirror dropAgentStatusByTabPrefix: when nothing live/retained changed, return just the ack delta (or s) to avoid full-state re-renders. - if ( - liveKeys.length === 0 && - launchConfigKeys.length === 0 && - retainedKeys.length === 0 && - retainedEvidence.size === 0 && - !migrationUnsupported.changed - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { acknowledgedAgentsByPaneKey: nextAck } - } - return s - } - hadLive = liveKeys.length > 0 - - const nextLive = - liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - for (const key of liveKeys) { - delete nextLive[key] - } - const nextLaunchConfigs = - launchConfigKeys.length > 0 - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - - const nextRetained = - retainedKeys.length > 0 || retainedEvidence.size > 0 - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - for (const key of retainedKeys) { - if (!retainedEvidenceKeys.has(key)) { - delete nextRetained[key] - } - } - for (const [paneKey, retained] of retainedEvidence) { - if (shouldReplaceRetainedWithLive(nextRetained[paneKey], retained)) { - nextRetained[paneKey] = retained - } - } - - // Why: suppress live rows on teardown, but skip auto-hibernated `done` rows — they become retained evidence a suppressor would erase next sync. - const suppressorAdds = liveKeys.filter( - (k) => !retainedEvidenceKeys.has(k) && !(k in s.retentionSuppressedPaneKeys) - ) - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (suppressorAdds.length > 0) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - for (const key of suppressorAdds) { - nextRetentionSuppressedPaneKeys[key] = true - } - } - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - agentStatusEpoch: - hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (hadLive) { - freshness.scheduleDeferred() - } - }, - - captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => { - set((s) => { - const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { - paneKeys, - captureMode: 'manual-worktree-sleep' - }) - const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep( - s.sleepingAgentSessionsByPaneKey, - worktreeId, - paneKeys, - records - ) - const next: Record = { ...replaced.records } - let changed = replaced.changed - - for (const record of Object.values(records)) { - if (next[record.paneKey] !== record) { - next[record.paneKey] = record - changed = true - } - } - - return changed ? { sleepingAgentSessionsByPaneKey: next } : s - }) - }, - - captureAllSleepingAgentSessions: (mode) => { - // Why: periodic checkpoints and quit flushes both persist provider ids, but only a confirmed quit may claim quit precedence. - set((s) => { - const capturedAt = Date.now() - const origin = mode === 'quit' ? ('quit' as const) : ('live' as const) - const next: Record = { - ...s.sleepingAgentSessionsByPaneKey - } - let changed = false - for (const entry of Object.values(s.agentStatusByPaneKey)) { - if (entry.state === 'done') { - const existing = next[entry.paneKey] - if (!isCompletedPiCompatibleAgentWithLiveRecoveryRecord(entry, existing)) { - continue - } - if (mode === 'periodic') { - continue - } - const record = { ...existing, capturedAt, origin } - if (!sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { - next[entry.paneKey] = record - changed = true - } - continue - } - const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey) - if (!worktreeId) { - continue - } - const record = sleepingRecordFromEntry({ - state: s, - entry, - worktreeId, - capturedAt, - launchConfig: getLaunchConfigForEntry(s, entry), - origin - }) - const existing = next[entry.paneKey] - // Why: a periodic timer must not downgrade a confirmed-quit shutdown snapshot; a live hook event supersedes it elsewhere. - if ( - mode === 'periodic' && - existing?.origin === 'quit' && - record && - recoveryRecordTargetsSameSession(existing, record) - ) { - continue - } - if (record && !sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { - next[record.paneKey] = record - changed = true - } - } - return changed ? { sleepingAgentSessionsByPaneKey: next } : s - }) - }, - - clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), - clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { - set((s) => { - const current = s.sleepingAgentSessionsByPaneKey[paneKey] - if ( - !current || - (blocked - ? current.automaticResumeBlockedBy === 'legacy-orchestration-worker' - : current.automaticResumeBlockedBy === undefined) - ) { - return s - } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy - } - return { - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: next - } - } - }) - }, - - clearSleepingAgentSessionsByWorktree: (worktreeId) => { - set((s) => { - let changed = false - const next: Record = {} - const launchConfigKeysToRemove: string[] = [] - for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { - if (record.worktreeId === worktreeId) { - changed = true - launchConfigKeysToRemove.push(paneKey) - continue - } - next[paneKey] = record - } - const nextLaunchConfigs = - launchConfigKeysToRemove.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : null - if (nextLaunchConfigs) { - for (const paneKey of launchConfigKeysToRemove) { - delete nextLaunchConfigs[paneKey] - } - } - return changed - ? { - sleepingAgentSessionsByPaneKey: next, - ...(nextLaunchConfigs ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {}) - } - : s - }) - }, - - pruneSleepingAgentSessions: (validWorktreeIds) => { - set((s) => { - let changed = false - const next: Record = {} - const launchConfigKeysToRemove: string[] = [] - for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { - if (!validWorktreeIds.has(record.worktreeId)) { - changed = true - launchConfigKeysToRemove.push(paneKey) - continue - } - next[paneKey] = record - } - const nextLaunchConfigs = - launchConfigKeysToRemove.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : null - if (nextLaunchConfigs) { - for (const paneKey of launchConfigKeysToRemove) { - delete nextLaunchConfigs[paneKey] - } - } - return changed - ? { - sleepingAgentSessionsByPaneKey: next, - ...(nextLaunchConfigs ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {}) - } - : s - }) - }, - - retainAgents: (entries) => { - // Why: retained entries are a pure read-overlay (no epoch bump needed); batch into one set so multi-agent disappearance is atomic. - if (entries.length === 0) { - return - } - set((s) => { - // Why: skip reallocation when every entry is already present by reference — consumers select on map identity, so a spurious realloc forces re-renders. - let changed = false - for (const retained of entries) { - if (s.retainedAgentsByPaneKey[retained.entry.paneKey] !== retained) { - changed = true - break - } - } - if (!changed) { - return s - } - const next = { ...s.retainedAgentsByPaneKey } - for (const retained of entries) { - const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[retained.entry.paneKey] - const mergedOrchestration = runtimeOrchestration - ? mergeCurrentOrchestrationContext(retained.entry.orchestration, runtimeOrchestration) - : retained.entry.orchestration - const entry = - mergedOrchestration !== retained.entry.orchestration - ? { ...retained.entry, orchestration: mergedOrchestration } - : retained.entry - // INVARIANT: map key equals retained.entry.paneKey, so callers look up retained rows by the same paneKey as agentStatusByPaneKey. - next[retained.entry.paneKey] = - entry === retained.entry ? retained : { ...retained, entry } - } - // Why: cap the map so a long multi-agent session can't leak the renderer heap (retainAgents is the only growth path); evicts oldest-retained first. - return { retainedAgentsByPaneKey: capRetainedAgents(next) } - }) - }, - - dismissRetainedAgent: (paneKey) => { - // Why: no epoch bump (mirrors retainAgents) — retained rows are a pure read-overlay that don't affect smart-sort; selectors re-render on map identity. - set((s) => { - if (!(paneKey in s.retainedAgentsByPaneKey)) { - return s - } - const next = { ...s.retainedAgentsByPaneKey } - delete next[paneKey] - // Why: mirror dropAgentStatus — plant a one-shot suppressor only when a live entry coexists, so the retention sync doesn't resurrect this dismissed row (gate on hasLive, else it leaks). - const hasLive = paneKey in s.agentStatusByPaneKey - if (!hasLive || paneKey in s.retentionSuppressedPaneKeys) { - return { retainedAgentsByPaneKey: next } - } - return { - retainedAgentsByPaneKey: next, - retentionSuppressedPaneKeys: { - ...s.retentionSuppressedPaneKeys, - [paneKey]: true - } - } - }) - }, - - dismissRetainedAgentsByWorktree: (worktreeId) => { - // Why: collect removed paneKeys inside set, then fan out window.api drop so the on-disk cache doesn't resurrect the dismissed rows on next launch. - const dismissedPaneKeys: string[] = [] - set((s) => { - let changed = false - const next: Record = {} - // Why: mirror dismissRetainedAgent — plant a suppressor only for dismissed paneKeys that also have a live entry, else the next live→gone transition re-retains the row (a retained-only suppressor leaks). - const toSuppress: string[] = [] - for (const [key, ra] of Object.entries(s.retainedAgentsByPaneKey)) { - if (ra.worktreeId === worktreeId) { - changed = true - dismissedPaneKeys.push(key) - if (key in s.agentStatusByPaneKey && !(key in s.retentionSuppressedPaneKeys)) { - toSuppress.push(key) - } - continue - } - next[key] = ra - } - if (!changed) { - return s - } - if (toSuppress.length === 0) { - return { retainedAgentsByPaneKey: next } - } - const nextSuppressed = { ...s.retentionSuppressedPaneKeys } - for (const key of toSuppress) { - nextSuppressed[key] = true - } - return { - retainedAgentsByPaneKey: next, - retentionSuppressedPaneKeys: nextSuppressed - } - }) - if (typeof window !== 'undefined') { - for (const paneKey of dismissedPaneKeys) { - window.api?.agentStatus?.drop?.(paneKey) - } - } - }, - - pruneRetainedAgents: (validWorktreeIds) => { - // Why: intentionally leaves retentionSuppressedPaneKeys — paneKeys are minted fresh on worktree re-create, so stale suppressors can never match a future live entry. - set((s) => { - let changed = false - const next: Record = {} - for (const [key, ra] of Object.entries(s.retainedAgentsByPaneKey)) { - if (!validWorktreeIds.has(ra.worktreeId)) { - changed = true - continue - } - next[key] = ra - } - return changed ? { retainedAgentsByPaneKey: next } : s - }) - }, - - clearRetentionSuppressedPaneKeys: (paneKeys) => { - set((s) => { - let changed = false - const next = { ...s.retentionSuppressedPaneKeys } - for (const paneKey of paneKeys) { - if (!(paneKey in next)) { - continue - } - delete next[paneKey] - changed = true - } - return changed ? { retentionSuppressedPaneKeys: next } : s - }) - } + recentlyRetiredAgentStatusPaneKeys: {} } } diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 2668df36fb9..424384e9a42 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1,2676 +1,18 @@ -/* eslint-disable max-lines */ -import type { StateCreator } from 'zustand' -import type { AppState } from '../types' -import type { - BrowserCertificateFailure, - BrowserCookieImportResult, - BrowserCookieImportSummary, - BrowserHistoryEntry, - BrowserLoadError, - BrowserPage, - BrowserPageDocLocation, +export type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + BrowserCookieImportExecutionResult, + BrowserTabPageState, + BrowserPageConversionLeg, + BrowserPageConversionTarget, BrowserSessionProfile, - BrowserSessionProfileCreateOptions, - BrowserViewportPresetId, - BrowserWorkspace -} from '../../../../shared/browser-workspace-types' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { - GRAB_BUDGET, - type BrowserAnnotationIntent, - type BrowserPageAnnotation -} from '../../../../shared/browser-grab-types' -import { - clearClientHostedBrowserCloseIntents, - isDurableClientHostedBrowserHandle, - recordClientHostedBrowserCloseIntents, - type ClientHostedBrowserCloseIntentsByEnvironment, - type PendingClientHostedBrowserClose -} from '@/runtime/client-hosted-browser-close-intents' -import { isBrowserPageDefinitivelyGone } from '@/runtime/client-hosted-browser-close-intent-replay' -import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { redactKagiSessionToken } from '../../../../shared/browser-url' -import { - MAX_BROWSER_HISTORY_ENTRIES, - normalizeBrowserHistoryEntries, - normalizeBrowserHistoryUrl -} from '../../../../shared/workspace-session-browser-history' -import { - MAX_WORKSPACE_DOC_HISTORY_ENTRIES, - normalizeWorkspaceDocHistoryEntries, - normalizeWorkspaceDocHistoryTitle, - type WorkspaceDocHistoryEntry -} from '../../../../shared/workspace-doc-history' -import { browserPageDocLocationsEqual } from '../../../../shared/browser-page-doc-location' -import { destroyWorkspaceWebviews } from './browser-webview-cleanup' -import { - browserWorkspaceMirrorFieldsEqual, - buildBrowserPage, - buildWorkspaceFromPage, - findPage, - findWorkspace, - mirrorWorkspaceFromActivePage, - normalizeBrowserTitle, - normalizeUrl -} from './browser-page-records' -import { - planBrowserPageConversion, - type BrowserPageConversionLeg, - type BrowserPageConversionTarget -} from './browser-page-conversion' -import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' -import { - getRecentlyClosedTabPosition, - restoreRecentlyClosedTabPosition, - pushRecentlyClosedTabKind -} from './recently-closed-tabs' -import { pickNeighbor } from './tab-group-state' -import type { RecentlyClosedTabPosition } from './recently-closed-tabs' -import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' -import type { - BrowserDetectProfilesResult, - BrowserProfileClearDefaultCookiesResult, - BrowserProfileCreateResult, - BrowserProfileDeleteResult, - BrowserProfileImportFromBrowserResult, - BrowserProfileListResult -} from '../../../../shared/runtime-types' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { translate } from '@/i18n/i18n' -import { - getSettingsFocusedExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' -import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' -import { - getExecutionHostIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' -import { - addAdditionalValidWorkspaceKeys, - type WorkspaceSessionHydrationOptions -} from '@/lib/workspace-session-hydration-keys' -import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' -import { - assertManagedBrowserMaterializationAllowed, - getClientCreationActionPolicy -} from '@/lib/client-creation-action-policy' - -type CreateBrowserTabOptions = { - activate?: boolean - browserPageId?: string - title?: string - sessionProfileId?: string | null - sessionPartition?: string | null - // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. - targetGroupId?: string - // Explicit "New Tab" focuses the address bar even with a real home URL; link-opened tabs leave it unset. - focusAddressBar?: boolean - browserRuntimeEnvironmentId?: string | null - /** Creates a page that shows a workspace document instead of a URL. */ - docLocation?: BrowserPageDocLocation -} - -type CreateBrowserPageOptions = { - activate?: boolean - title?: string - browserRuntimeEnvironmentId?: string | null - /** Creates a page that shows a workspace document instead of a URL. */ - docLocation?: BrowserPageDocLocation -} - -type BrowserTabPageState = { - title?: string - loading?: boolean - faviconUrl?: string | null - canGoBack?: boolean - canGoForward?: boolean - loadError?: BrowserLoadError | null -} - -type SetBrowserPageUrlOptions = { - preserveLoadError?: boolean -} - -type ClosedBrowserWorkspaceSnapshot = { - workspace: BrowserWorkspace - pages: BrowserPage[] - position?: RecentlyClosedTabPosition -} - -function sanitizeBrowserPageAnnotation(annotation: BrowserPageAnnotation): BrowserPageAnnotation { - return { - ...annotation, - comment: - annotation.comment.length > GRAB_BUDGET.annotationCommentMaxLength - ? annotation.comment.slice(0, GRAB_BUDGET.annotationCommentMaxLength) - : annotation.comment, - payload: { - ...annotation.payload, - // Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note. - screenshot: null - } - } -} - -export type RemoteBrowserPageHandle = { - environmentId: string - remotePageId: string - placement?: RuntimeBrowserPlacement - /** Optimistically staged by this client; the host has not published the page yet. */ - staged?: true - /** - * This client expects to host the staged page itself. The real placement is minted host-side and - * only arrives with the snapshot, so the pane needs this to mount as the right kind of pane from - * the first frame instead of swapping components at adoption. - */ - stagedClientHosted?: true - /** - * Rebuilt at hydration from the persisted page row rather than observed from a host snapshot. The - * page id is real, but nothing has confirmed the host still has it, so the row is exempt from the - * absent-from-snapshot cull until the first snapshot that publishes it clears this. - */ - restoredFromSession?: true - /** The restored page was hosted by this desktop, so it must not restore as a streamed pane. */ - restoredClientHosted?: true -} - -/** Rebuild the remote page handles a restored session implies. No placement is seeded: the - * persisted generations belong to the host lease that died with the last run, and the first host - * snapshot is what supplies the live one. - * - * Client-hosted rows only. A server-hosted page lives on the runtime, which may have restarted or - * been redeployed while this desktop was closed; a seeded handle sends its pane down the adopt - * branch, and the browser_tab_not_found that comes back deletes the row. With no handle the pane - * creates a fresh page at the URL the row persisted, which is what the user left behind. */ -function buildRestoredRemoteBrowserPageHandles( - browserPagesByWorkspace: Record -): Record { - const handles: Record = {} - for (const pages of Object.values(browserPagesByWorkspace)) { - for (const page of pages) { - if ( - !page.browserRuntimeEnvironmentId || - !page.remoteBrowserPageId || - !page.remoteBrowserPageClientHosted - ) { - continue - } - handles[page.id] = { - environmentId: page.browserRuntimeEnvironmentId, - remotePageId: page.remoteBrowserPageId, - restoredFromSession: true, - restoredClientHosted: true - } - } - } - return handles -} - -export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & { - executionHostId: ExecutionHostId - executionHostLabel: string - // Why: for a remote environment the import silently runs on either machine; toasts must say which. - executionMachine: 'client' | 'remote' - executionRemoteEnvironment: boolean -} - -function retainCookieImportExecutionHost( - result: BrowserCookieImportResult, - executionHostId: ExecutionHostId, - executionHostLabel: string, - executionMachine: 'client' | 'remote' -): BrowserCookieImportExecutionResult { - return { - ...result, - executionHostId, - executionHostLabel, - executionMachine, - executionRemoteEnvironment: parseExecutionHostId(executionHostId)?.kind === 'runtime' - } -} - -export type BrowserSlice = { - browserTabsByWorktree: Record - browserPagesByWorkspace: Record - browserCertificateFailuresByPageId: Record - browserAnnotationsByPageId: Record - remoteBrowserPageHandlesByPageId: Record - /** - * Closes of client-hosted pages their owning runtime never heard, keyed by environment. - * - * Durable because the runtime persists the pages themselves: a close swallowed while the host - * was down would otherwise be undone by that host's next start. - */ - clientHostedBrowserCloseIntentsByEnvironment: ClientHostedBrowserCloseIntentsByEnvironment - recordClientHostedBrowserCloseIntents: ( - closes: readonly PendingClientHostedBrowserClose[] - ) => void - clearClientHostedBrowserCloseIntents: ( - environmentId: string, - browserPageIds: readonly string[] - ) => void - activeBrowserTabId: string | null - activeBrowserTabIdByWorktree: Record - recentlyClosedBrowserTabsByWorktree: Record - recentlyClosedBrowserPagesByWorkspace: Record - pendingAddressBarFocusByTabId: Record - pendingAddressBarFocusByPageId: Record - createBrowserTab: ( - worktreeId: string, - url: string, - options?: CreateBrowserTabOptions - ) => BrowserWorkspace - openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise - /** `profileId: null` uses the workspace default profile. */ - openBrowserProfileTabInActiveWorkspace: ( - url: string, - profileId: string | null - ) => Promise - closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void - shutdownWorktreeBrowsers: (worktreeId: string) => Promise - reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null - setActiveBrowserTab: (tabId: string) => void - createBrowserPage: ( - workspaceId: string, - url: string, - options?: CreateBrowserPageOptions - ) => BrowserPage | null - closeBrowserPage: (pageId: string) => void - // Why replacement and not mutation: a page's kind (doc vs web) is immutable for its life, so the - // address bar converts by replacing the page — fresh id, same workspace row — keeping the two - // main-process registry halves disjoint by construction. - convertBrowserPage: ( - pageId: string, - target: BrowserPageConversionTarget, - options?: { leg?: BrowserPageConversionLeg } - ) => BrowserPage | null - reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null - setActiveBrowserPage: (workspaceId: string, pageId: string) => void - // Focus that never yanks the user across worktrees: per-worktree slots always update, globals only when targeting the active worktree. - focusBrowserTabInWorktree: ( - worktreeId: string, - browserPageId: string, - options?: { surfacePane?: boolean } - ) => void - consumeAddressBarFocusRequest: (pageId: string) => boolean - updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void - updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void - setBrowserPageCertificateFailure: ( - pageId: string, - failure: BrowserCertificateFailure | null - ) => void - setBrowserTabUrl: (pageId: string, url: string) => void - setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void - setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void - removeRemoteBrowserPageHandle: ( - pageId: string, - remotePageId?: string - ) => RemoteBrowserPageHandle | null - setBrowserPageViewportPreset: ( - pageId: string, - viewportPresetId: BrowserViewportPresetId | null - ) => void - addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void - updateBrowserPageAnnotation: ( - pageId: string, - annotationId: string, - patch: { comment: string; intent: BrowserAnnotationIntent } - ) => void - deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void - clearBrowserPageAnnotations: (pageId: string) => void - hydrateBrowserSession: ( - session: WorkspaceSessionState, - options?: WorkspaceSessionHydrationOptions - ) => void - switchBrowserTabProfile: ( - workspaceId: string, - profileId: string | null, - sessionPartition?: string | null - ) => void - browserSessionProfiles: BrowserSessionProfile[] - browserSessionProfilesByHostId: Partial> - browserSessionHostIdOverride: ExecutionHostId | null - setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise - browserSessionImportState: { - profileId: string - status: 'idle' | 'importing' | 'success' | 'error' - summary: BrowserCookieImportSummary | null - error: string | null - } | null - fetchBrowserSessionProfiles: () => Promise - createBrowserSessionProfile: ( - scope: 'isolated' | 'imported', - label: string, - options?: BrowserSessionProfileCreateOptions - ) => Promise - deleteBrowserSessionProfile: (profileId: string) => Promise - importCookiesToProfile: (profileId: string) => Promise - clearBrowserSessionImportState: () => void - detectedBrowsers: { - family: string - label: string - profiles: { name: string; directory: string }[] - selectedProfile: string - }[] - detectedBrowsersLoaded: boolean - // Why: which machine answered detection for a remote environment, so import menus can say where - // the import will read and store; null while browser settings target the local host. - detectedBrowsersHost: { machine: 'client' | 'remote'; hostLabel: string } | null - fetchDetectedBrowsers: () => Promise - importCookiesFromBrowser: ( - profileId: string, - browserFamily: string, - browserProfile?: string - ) => Promise - clearDefaultSessionCookies: () => Promise - browserUrlHistory: BrowserHistoryEntry[] - addBrowserHistoryEntry: (url: string, title: string) => void - workspaceDocHistory: WorkspaceDocHistoryEntry[] - /** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */ - recordWorkspaceDocVisit: ( - docLocation: BrowserPageDocLocation, - title: string | null, - options?: { bump?: boolean } - ) => void - clearBrowserHistory: () => void - defaultBrowserSessionProfileId: string | null - defaultBrowserSessionProfileIdByHostId: Partial> - setDefaultBrowserSessionProfileId: (profileId: string | null) => void -} - -function getBrowserSettingsHostId( - state: Pick -): ExecutionHostId { - return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings) -} - -function getBrowserSettingsRuntimeEnvironmentId( - state: Pick -): string | null { - const parsed = parseExecutionHostId(getBrowserSettingsHostId(state)) - return parsed?.kind === 'runtime' ? parsed.environmentId : null -} - -function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { - return getExecutionHostIdForWorktree(state, worktreeId) -} - -function getBrowserSessionProfileHostId( - state: AppState, - worktreeId: string, - browserRuntimeEnvironmentId: string | null | undefined -): ExecutionHostId { - if (browserRuntimeEnvironmentId === null) { - return LOCAL_EXECUTION_HOST_ID - } - if (browserRuntimeEnvironmentId !== undefined) { - const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim() - return runtimeEnvironmentId - ? toRuntimeExecutionHostId(runtimeEnvironmentId) - : LOCAL_EXECUTION_HOST_ID - } - return getBrowserWorktreeHostId(state, worktreeId) -} - -export function isLocalBrowserPageOwner( - state: AppState, - worktreeId: string, - browserRuntimeEnvironmentId: string | null | undefined -): boolean { - return ( - parseExecutionHostId( - getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId) - )?.kind !== 'runtime' - ) -} - -function profileListByHostUpdate( - state: Pick< - AppState, - 'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings' - >, - profiles: BrowserSessionProfile[], - hostId: ExecutionHostId = getBrowserSettingsHostId(state) -): Partial { - return { - ...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}), - browserSessionProfilesByHostId: { - ...state.browserSessionProfilesByHostId, - [hostId]: profiles - } - } -} - -function getBrowserProfilesForHost( - state: AppState, - hostId: ExecutionHostId -): BrowserSessionProfile[] { - return ( - state.browserSessionProfilesByHostId[hostId] ?? - (getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : []) - ) -} - -function getDefaultBrowserProfileForHost(state: AppState, hostId: ExecutionHostId): string | null { - return ( - state.defaultBrowserSessionProfileIdByHostId[hostId] ?? - (getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null) - ) -} - -function browserImportStateForHostUpdate( - state: AppState, - hostId: ExecutionHostId, - browserSessionImportState: BrowserSlice['browserSessionImportState'] -): Partial { - return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} -} - -function closeRemoteBrowserPageInOwningEnvironment( - worktreeId: string, - handle: RemoteBrowserPageHandle, - // Why a callback and not the store: this is module scope, and the recording action lives on the - // slice this file is still defining. - recordCloseIntents: (closes: readonly PendingClientHostedBrowserClose[]) => void -): void { - const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } - void callRuntimeRpc( - target, - 'browser.tabClose', - { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, - { timeoutMs: 15_000 } - ).catch((error) => { - // A close the runtime never heard is a resurrection waiting to happen: the runtime persists - // client-hosted pages, so a durable intent replays this same RPC on the next reconnect. - // A definitive page-unknown answer means there is nothing left to resurrect. - if (!isDurableClientHostedBrowserHandle(handle) || isBrowserPageDefinitivelyGone(error)) { - return - } - recordCloseIntents([ - { environmentId: handle.environmentId, browserPageId: handle.remotePageId, worktreeId } - ]) - }) -} - -function getFallbackTabTypeForWorktree( - worktreeId: string, - openFiles: AppState['openFiles'], - terminalTabsByWorktree: AppState['tabsByWorktree'], - browserTabsByWorktree?: AppState['browserTabsByWorktree'] -): AppState['activeTabType'] { - if (openFiles.some((file) => file.worktreeId === worktreeId)) { - return 'editor' - } - if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) { - return 'browser' - } - if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) { - return 'terminal' - } - return 'terminal' -} - -export const createBrowserSlice: StateCreator = (set, get) => ({ - browserTabsByWorktree: {}, - browserPagesByWorkspace: {}, - browserCertificateFailuresByPageId: {}, - browserAnnotationsByPageId: {}, - remoteBrowserPageHandlesByPageId: {}, - clientHostedBrowserCloseIntentsByEnvironment: {}, - activeBrowserTabId: null, - activeBrowserTabIdByWorktree: {}, - recentlyClosedBrowserTabsByWorktree: {}, - recentlyClosedBrowserPagesByWorkspace: {}, - pendingAddressBarFocusByTabId: {}, - pendingAddressBarFocusByPageId: {}, - browserSessionProfiles: [], - browserSessionProfilesByHostId: {}, - browserSessionHostIdOverride: null, - browserSessionImportState: null, - browserUrlHistory: [], - workspaceDocHistory: [], - defaultBrowserSessionProfileId: null, - defaultBrowserSessionProfileIdByHostId: {}, - - recordClientHostedBrowserCloseIntents: (closes) => { - set((s) => { - const next = recordClientHostedBrowserCloseIntents( - s.clientHostedBrowserCloseIntentsByEnvironment, - closes, - Date.now() - ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} - }) - }, - - clearClientHostedBrowserCloseIntents: (environmentId, browserPageIds) => { - set((s) => { - const next = clearClientHostedBrowserCloseIntents( - s.clientHostedBrowserCloseIntentsByEnvironment, - { environmentId, browserPageIds, now: Date.now() } - ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} - }) - }, - - setBrowserSessionHostId: async (hostId) => { - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind !== 'local' && parsed?.kind !== 'runtime') { - return - } - const nextHostId = parsed.id - set((s) => ({ - browserSessionHostIdOverride: nextHostId, - browserSessionProfiles: s.browserSessionProfilesByHostId[nextHostId] ?? [], - defaultBrowserSessionProfileId: s.defaultBrowserSessionProfileIdByHostId[nextHostId] ?? null, - browserSessionImportState: null, - detectedBrowsers: [], - detectedBrowsersLoaded: false, - detectedBrowsersHost: null - })) - await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()]) - }, - - setDefaultBrowserSessionProfileId: (profileId) => { - set((s) => ({ - defaultBrowserSessionProfileId: profileId, - defaultBrowserSessionProfileIdByHostId: { - ...s.defaultBrowserSessionProfileIdByHostId, - [getBrowserSettingsHostId(s)]: profileId - } - })) - }, - - createBrowserTab: (worktreeId, url, options) => { - assertManagedBrowserMaterializationAllowed(get(), options?.browserRuntimeEnvironmentId) - const workspaceId = createBrowserUuid() - const browserPageId = options?.browserPageId - if ( - browserPageId && - (findWorkspace(get().browserTabsByWorktree, browserPageId) || - findPage(get().browserPagesByWorkspace, browserPageId)) - ) { - throw new Error(`Browser page ${browserPageId} already exists`) - } - const page = buildBrowserPage( - workspaceId, - worktreeId, - url, - options?.title, - options?.browserRuntimeEnvironmentId, - browserPageId, - options?.docLocation - ) - // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. - const sessionProfileId = - options?.sessionProfileId !== undefined - ? options.sessionProfileId - : (get().defaultBrowserSessionProfileIdByHostId[ - getBrowserSessionProfileHostId(get(), worktreeId, options?.browserRuntimeEnvironmentId) - ] ?? get().defaultBrowserSessionProfileId) - const browserTab = buildWorkspaceFromPage( - workspaceId, - worktreeId, - page, - [page.id], - sessionProfileId, - options?.sessionPartition - ) - - set((s) => { - const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] - const nextTabBarOrder = (() => { - const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] - const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = s.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((file) => file.id) - const browserIds = existingTabs.map((tab) => tab.id) - const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) - const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) - const inBase = new Set(base) - for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { - if (!inBase.has(entryId)) { - base.push(entryId) - inBase.add(entryId) - } - } - base.push(workspaceId) - return base - })() - - const shouldActivate = options?.activate ?? true - const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId - const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID - const shouldFocusAddressBar = - (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && - // Why the doc check and not just the url: a document page is blank by construction, and - // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. - !page.docLocation && - (options?.focusAddressBar ?? - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) - - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [worktreeId]: [...existingTabs, browserTab] - }, - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspaceId]: [page] - }, - tabBarOrderByWorktree: { - ...s.tabBarOrderByWorktree, - [worktreeId]: nextTabBarOrder - }, - activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: { - ...s.activeBrowserTabIdByWorktree, - [worktreeId]: shouldActivate - ? workspaceId - : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) - }, - activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, - activeTabTypeByWorktree: shouldActivate - ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } - : s.activeTabTypeByWorktree, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [workspaceId]: true, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId - } - }) - - const state = get() - const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( - (t) => t.contentType === 'browser' && t.entityId === workspaceId - ) - if (!alreadyHasUnifiedTab) { - state.createUnifiedTab(worktreeId, 'browser', { - entityId: workspaceId, - label: browserTab.title, - targetGroupId: options?.targetGroupId, - activate: options?.activate ?? true - }) - } - return browserTab - }, - - openNewBrowserTabInActiveWorkspace: async (groupId) => { - const state = get() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - return - } - const browserAvailability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] - if (browserAvailability.state !== 'enabled') { - throw new Error(browserAvailability.reason) - } - const defaultUrl = state.browserDefaultUrl ?? 'about:blank' - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (browserAvailability.provider === 'paired-runtime') { - if (!runtimeEnvironmentId) { - throw new Error('The paired runtime browser provider is unavailable.') - } - const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') - try { - const created = await createWebRuntimeSessionBrowserTab({ - worktreeId, - environmentId: runtimeEnvironmentId, - url: defaultUrl, - // Why: desktop pane groups are client-owned — the local reconciler only honors a - // recorded clientTargetGroupId; targetGroupId steers snapshot-driven clients instead. - targetGroupId: groupId, - clientTargetGroupId: groupId - }) - if (created) { - get().recordFeatureInteraction('browser-tab-created') - return - } - } catch (error) { - // Why: browser.headless.v1 remotes succeed above, so a failure here is real; surface it instead of a confusing local-tab fallback (split ownership). - console.warn( - '[browser] remote browser tab creation failed:', - error instanceof Error ? error.message : String(error) - ) - throw error - } - throw new Error('The paired runtime could not create a managed browser tab.') - } - get().createBrowserTab(worktreeId, defaultUrl, { - title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), - focusAddressBar: true, - ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}), - targetGroupId: groupId - }) - get().recordFeatureInteraction('browser-tab-created') - }, - - openBrowserProfileTabInActiveWorkspace: async (url, profileId) => { - const state = get() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - return false - } - const browserAvailability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] - if (browserAvailability.state !== 'enabled') { - return false - } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (browserAvailability.provider === 'paired-runtime') { - if (!runtimeEnvironmentId) { - return false - } - const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') - try { - return await createWebRuntimeSessionBrowserTab({ - worktreeId, - environmentId: runtimeEnvironmentId, - url, - profileId - }) - } catch (error) { - console.warn( - '[browser] remote profile tab creation failed:', - error instanceof Error ? error.message : String(error) - ) - return false - } - } - get().createBrowserTab(worktreeId, url, { - activate: true, - sessionProfileId: profileId, - ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) - }) - return true - }, - closeBrowserTab: (tabId, options) => { - // Why: a cleanup close unwinds a tab that never finished being created — it owns no host - // page to close and must not enter the reopen stack as if the user had closed something. - const isCleanup = options?.reason === 'cleanup' - let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - // Why collected rather than released inside the reducer: revoking is main-process work, and a - // grant is the only authority the preview scheme honors — a closed document must stop being - // readable, and it must stop being readable even if the reducer bails out below. - let docPageIdsToRelease: string[] = [] - let activeBrowserWorktreeIdToNotify: string | null = null - set((s) => { - let owningWorktreeId: string | null = null - let closedWorkspace: BrowserWorkspace | null = null - const nextBrowserTabsByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const removedTab = tabs.find((tab) => tab.id === tabId) ?? null - const filtered = tabs.filter((tab) => tab.id !== tabId) - if (filtered.length !== tabs.length) { - owningWorktreeId = worktreeId - closedWorkspace = removedTab - } - if (filtered.length > 0) { - nextBrowserTabsByWorktree[worktreeId] = filtered - } - } - if (!owningWorktreeId || !closedWorkspace) { - return s - } - - const closedPages = s.browserPagesByWorkspace[tabId] ?? [] - const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } - delete nextBrowserPagesByWorkspace[tabId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - for (const page of closedPages) { - delete nextBrowserAnnotationsByPageId[page.id] - delete nextBrowserCertificateFailuresByPageId[page.id] - } - docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) - remotePagesToClose = isCleanup - ? [] - : closedPages.flatMap((page) => { - const handle = s.remoteBrowserPageHandlesByPageId[page.id] - return handle ? [{ worktreeId: page.worktreeId, handle }] : [] - }) - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - for (const page of closedPages) { - delete nextRemoteBrowserPageHandlesByPageId[page.id] - } - - const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { - const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) - nextActiveBrowserTabIdByWorktree[owningWorktreeId] = - (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) - ? neighborId - : remainingBrowserTabs[0]?.id) ?? null - } - - const nextTabBarOrder = { - ...s.tabBarOrderByWorktree, - [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( - (entryId) => entryId !== tabId - ) - } - - const isActiveTabInOwningWorktree = - s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId - if (isActiveTabInOwningWorktree) { - activeBrowserWorktreeIdToNotify = owningWorktreeId - } - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - let nextActiveTabType = s.activeTabType - if (remainingBrowserTabs.length === 0) { - const fallbackTabType = getFallbackTabTypeForWorktree( - owningWorktreeId, - s.openFiles, - s.tabsByWorktree - ) - nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType - if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { - nextActiveTabType = fallbackTabType - } - } - - const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } - if (!isCleanup) { - const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] - const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) - nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ - { - workspace: closedWorkspace, - pages: closedPages, - ...(position ? { position } : {}) - }, - ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) - ].slice(0, 10) - } - const nextRecentlyClosedTabKindsByWorktree = isCleanup - ? s.recentlyClosedTabKindsByWorktree - : pushRecentlyClosedTabKind(s.recentlyClosedTabKindsByWorktree, owningWorktreeId, 'browser') - - const nextRecentlyClosedBrowserPagesByWorkspace = { - ...s.recentlyClosedBrowserPagesByWorkspace - } - delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] - - const nextPendingAddressBarFocusByPageId = Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pageId]) => !closedPages.some((page) => page.id === pageId) - ) - ) - const nextPendingAddressBarFocusByTabId = Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId) - ) - ) - - return { - browserTabsByWorktree: nextBrowserTabsByWorktree, - browserPagesByWorkspace: nextBrowserPagesByWorkspace, - activeBrowserTabId: - s.activeBrowserTabId === tabId - ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) - : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - tabBarOrderByWorktree: nextTabBarOrder, - activeTabType: nextActiveTabType, - pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, - recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId - } - }) - - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - - for (const docPageId of docPageIdsToRelease) { - releaseDocPreviewGrant(docPageId) - } - - for (const tabs of Object.values(get().unifiedTabsByWorktree)) { - const workspaceItem = tabs.find( - (entry) => entry.contentType === 'browser' && entry.entityId === tabId - ) - if (workspaceItem) { - get().closeUnifiedTab( - workspaceItem.id, - isCleanup ? { preserveWorktreeSelection: true, recordInteraction: false } : undefined - ) - } - } - - // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. - if (activeBrowserWorktreeIdToNotify) { - const state = get() - const activeWorkspaceId = state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] - const activeWorkspace = activeWorkspaceId - ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) - : null - const activePage = activeWorkspace?.activePageId - ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( - (page) => page.id === activeWorkspace.activePageId - ) - : undefined - if ( - activeWorkspace?.activePageId && - isLocalBrowserPageOwner( - state, - activeBrowserWorktreeIdToNotify, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser - .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) - .catch(() => {}) - } - } - }, - - shutdownWorktreeBrowsers: async (worktreeId) => { - const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] - // Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. - const hadBrowserTabs = workspaces.length > 0 - for (const workspace of workspaces) { - const browserPagesByWorkspace = get().browserPagesByWorkspace - get().closeBrowserTab(workspace.id) - destroyWorkspaceWebviews(browserPagesByWorkspace, workspace.id) - } - set((s) => { - const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } - delete nextBrowserTabsByWorktree[worktreeId] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - delete nextActiveBrowserTabIdByWorktree[worktreeId] - // Why: reset the global browser surface only when the shut-down worktree is the active one AND had tabs. - const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs - return { - browserTabsByWorktree: nextBrowserTabsByWorktree, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - ...(shouldResetGlobalBrowser - ? { activeBrowserTabId: null, activeTabType: 'terminal' as const } - : {}) - } - }) - }, - - reopenClosedBrowserTab: (worktreeId) => { - // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same entry (TOCTOU). - let entryToRestore: ClosedBrowserWorkspaceSnapshot | undefined - - set((s) => { - const recentlyClosed = s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] - entryToRestore = recentlyClosed[0] - if (!entryToRestore) { - return s - } - return { - recentlyClosedBrowserTabsByWorktree: { - ...s.recentlyClosedBrowserTabsByWorktree, - [worktreeId]: recentlyClosed.slice(1) - } - } - }) - - if (!entryToRestore) { - return null - } - - const snap = entryToRestore.workspace - const pages = entryToRestore.pages - const sessionProfileId = snap.sessionProfileId ?? null - const sessionPartition = snap.sessionPartition ?? null - - if (pages.length === 0) { - const restored = get().createBrowserTab(worktreeId, snap.url, { - title: snap.title, - activate: true, - sessionProfileId, - sessionPartition, - ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), - targetGroupId: entryToRestore.position?.groupId - }) - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null - } - - // Why: append remaining pages in original order so multi-page workspaces preserve their page sequence. - const [firstPage, ...restPages] = pages - const restored = get().createBrowserTab(worktreeId, firstPage.url, { - title: firstPage.title, - activate: true, - sessionProfileId, - sessionPartition, - targetGroupId: entryToRestore.position?.groupId, - browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, - ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) - }) - - for (const p of restPages) { - get().createBrowserPage(restored.id, p.url, { - activate: false, - title: p.title, - browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, - ...(p.docLocation ? { docLocation: p.docLocation } : {}) - }) - } - - // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. - const activePageId = snap.activePageId - if (activePageId) { - const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] - const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) - const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null - if (targetPage && targetPage.id !== restoredPages[0]?.id) { - get().setActiveBrowserPage(restored.id, targetPage.id) - } - } - - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - - return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null - }, - - setActiveBrowserTab: (tabId) => { - set((s) => { - const browserTab = findWorkspace(s.browserTabsByWorktree, tabId) - if (!browserTab) { - return s - } - return { - activeBrowserTabId: tabId, - activeBrowserTabIdByWorktree: { - ...s.activeBrowserTabIdByWorktree, - [browserTab.worktreeId]: tabId - }, - activeTabType: 'browser', - activeTabTypeByWorktree: { - ...s.activeTabTypeByWorktree, - [browserTab.worktreeId]: 'browser' - } - } - }) - - // Why: notify the CDP bridge of the active guest; it keys on page IDs not workspace IDs, so resolve the workspace's active page. - const workspace = findWorkspace(get().browserTabsByWorktree, tabId) - const activePage = workspace?.activePageId - ? (get().browserPagesByWorkspace[workspace.id] ?? []).find( - (page) => page.id === workspace.activePageId - ) - : undefined - if ( - workspace?.activePageId && - isLocalBrowserPageOwner( - get(), - workspace.worktreeId, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser - .notifyActiveTabChanged({ browserPageId: workspace.activePageId }) - .catch(() => {}) - } - - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === tabId) - if (item) { - get().activateTab(item.id) - } - }, - - createBrowserPage: (workspaceId, url, options) => { - const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (!workspace) { - return null - } - const page = buildBrowserPage( - workspaceId, - workspace.worktreeId, - url, - options?.title, - options?.browserRuntimeEnvironmentId, - undefined, - options?.docLocation - ) - - set((s) => { - const pages = s.browserPagesByWorkspace[workspaceId] ?? [] - const shouldActivate = options?.activate ?? true - const nextPages = [...pages, page] - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const shouldUpdateGlobalActiveSurface = - shouldActivate && - s.activeWorktreeId === workspace.worktreeId && - s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId - const shouldFocusAddressBar = - shouldUpdateGlobalActiveSurface && - !page.docLocation && - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) - - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspaceId]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspaceId ? nextWorkspace : tab) - ) - }, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId - } - }) - - const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (nextWorkspace?.activePageId === page.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) - if (item) { - get().setTabLabel(item.id, page.title) - } - } - return page - }, - - closeBrowserPage: (pageId) => { - let closedWorkspaceIdForLabel: string | null = null - let docPageIdToRelease: string | null = null - const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - closedWorkspaceIdForLabel = page.workspaceId - docPageIdToRelease = page.docLocation ? page.id : null - const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] - const nextPages = currentPages.filter((entry) => entry.id !== pageId) - const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) - const nextActivePageId = - workspace.activePageId === pageId - ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) - : workspace.activePageId - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: nextActivePageId, - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] - if (remoteHandle) { - remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) - } - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - delete nextRemoteBrowserPageHandlesByPageId[pageId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - delete nextBrowserAnnotationsByPageId[pageId] - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - delete nextBrowserCertificateFailuresByPageId[pageId] - - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - }, - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspace.id]: [ - page, - ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( - (entry) => entry.id !== page.id - ) - ].slice(0, 10) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId - } - }) - - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - - const closedWorkspaceId = closedWorkspaceIdForLabel - if (!closedWorkspaceId) { - return - } - const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) - if (item && workspace) { - get().setTabLabel(item.id, workspace.title) - } - }, - - convertBrowserPage: (pageId, target, options) => { - // Why the same assert as createBrowserTab: a conversion materializes a browser surface the - // same way a creation does, and a paired web client that cannot host one must refuse here too. - // Ownership is resolved ONCE, on the plan's own terms — property-present-undefined means - // worktree-inferred — so the assert and the plan cannot disagree about what is being built. - const oldPageForOwnership = findPage(get().browserPagesByWorkspace, pageId) - const declaredOwnership = - target.kind === 'workspace-doc' - ? null - : 'browserRuntimeEnvironmentId' in target - ? target.browserRuntimeEnvironmentId - : (oldPageForOwnership?.browserRuntimeEnvironmentId ?? null) - assertManagedBrowserMaterializationAllowed( - get(), - declaredOwnership !== undefined - ? declaredOwnership - : oldPageForOwnership - ? (getRuntimeEnvironmentIdForWorktree(get(), oldPageForOwnership.worktreeId) ?? null) - : null - ) - let converted: BrowserPage | null = null - let docPageIdToRelease: string | null = null - let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null - set((s) => { - const plan = planBrowserPageConversion(s, pageId, target, options) - if (!plan) { - return s - } - converted = plan.newPage - // Why collected rather than released inside the reducer: revoking is main-process work, and - // it must happen exactly once even if a later set() retries the reducer. - if (plan.oldPage.docLocation) { - docPageIdToRelease = plan.oldPage.id - } - const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] - if (remoteHandle) { - remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } - } - const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } - delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - delete nextBrowserAnnotationsByPageId[plan.oldPage.id] - const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } - delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [plan.workspace.id]: plan.nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [plan.workspace.worktreeId]: ( - s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] - ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId - } - }) - // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does - // not track, so the initializers' null narrowing would otherwise read these as never. - const newPage = converted as BrowserPage | null - const remoteClose = remotePageToClose as { - worktreeId: string - handle: RemoteBrowserPageHandle - } | null - if (!newPage) { - return null - } - if (remoteClose) { - closeRemoteBrowserPageInOwningEnvironment( - remoteClose.worktreeId, - remoteClose.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - // Why after the reducer: a closed document must stop being readable, and the grant is the only - // authority the preview scheme honors — but the store row has to stop naming it first. - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) - if (workspaceAfter?.activePageId === newPage.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId) - if (item) { - get().setTabLabel(item.id, newPage.title) - } - } - return newPage - }, - - reopenClosedBrowserPage: (workspaceId) => { - // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same page (TOCTOU). - let pageToRestore: BrowserPage | undefined - - set((s) => { - const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] - pageToRestore = recentlyClosed[0] - if (!pageToRestore) { - return s - } - return { - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspaceId]: recentlyClosed.slice(1) - } - } - }) - - if (!pageToRestore) { - return null - } - - return get().createBrowserPage(workspaceId, pageToRestore.url, { - title: pageToRestore.title, - activate: true, - browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId, - ...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {}) - }) - }, - - setActiveBrowserPage: (workspaceId, pageId) => { - set((s) => { - const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId) - if (!workspace) { - return s - } - const pages = s.browserPagesByWorkspace[workspaceId] ?? [] - if (!pages.some((page) => page.id === pageId)) { - return s - } - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: pageId - }, - pages - ) - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspaceId ? nextWorkspace : tab) - ) - } - } - }) - - // Why: switching the active page changes which guest webContents the CDP bridge targets for agent commands. - const activePage = (get().browserPagesByWorkspace[workspaceId] ?? []).find( - (page) => page.id === pageId - ) - const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if ( - workspace && - isLocalBrowserPageOwner( - get(), - workspace.worktreeId, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) - } - if (!workspace) { - return - } - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) - if (item) { - get().setTabLabel(item.id, workspace.title) - } - }, - - focusBrowserTabInWorktree: (worktreeId, browserPageId, options) => { - // Why: bridge targets a browserPageId but tabs activate a workspace; find the owning workspace (they differ for multi-page tabs). - const tabsForWorktree = get().browserTabsByWorktree[worktreeId] ?? [] - const workspace = tabsForWorktree.find((tab) => (tab.pageIds ?? []).includes(browserPageId)) - if (!workspace) { - // Best-effort: worktree state may not be hydrated yet, or the page closed between bridge switch and this IPC arriving. - return - } - // Default true: the only caller (tab switch --focus) wants the pane surfaced; false is an opt-out for pre-staging callers. - const surfacePane = options?.surfacePane ?? true - const pages = get().browserPagesByWorkspace[workspace.id] ?? [] - const nextWorkspace = mirrorWorkspaceFromActivePage( - { ...workspace, activePageId: browserPageId }, - pages - ) - // TODO: duplicates setActiveBrowserTab/Page; can't reuse (they touch globals unconditionally). Extract a per-worktree-only helper. - set((s) => { - const isActiveWorktree = s.activeWorktreeId === worktreeId - // Per-worktree slots: always update — safe pre-staging, only visible when user navigates here. - const nextTabsByWorktree = { - ...s.browserTabsByWorktree, - [worktreeId]: tabsForWorktree.map((tab) => (tab.id === workspace.id ? nextWorkspace : tab)) - } - const nextActiveTabIdByWorktree = { - ...s.activeBrowserTabIdByWorktree, - [worktreeId]: workspace.id - } - const nextActiveTabTypeByWorktree = surfacePane - ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' as const } - : s.activeTabTypeByWorktree - // Globals: mutate only when the targeted worktree is active — keeps cross-worktree --focus silent. - return { - browserTabsByWorktree: nextTabsByWorktree, - activeBrowserTabIdByWorktree: nextActiveTabIdByWorktree, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - activeBrowserTabId: isActiveWorktree ? workspace.id : s.activeBrowserTabId, - activeTabType: isActiveWorktree && surfacePane ? 'browser' : s.activeTabType - } - }) - - // Why: notify the CDP bridge which guest webContents is active so agent commands target the correct page. - const focusedPage = pages.find((page) => page.id === browserPageId) - if ( - isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {}) - } - - // Why: sync the unified-tab strip's active entry; activateTab only mutates per-worktree slices, so it's cross-worktree-safe. - const item = (get().unifiedTabsByWorktree[worktreeId] ?? []).find( - (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id - ) - if (item) { - get().activateTab(item.id) - } - }, - - consumeAddressBarFocusRequest: (pageId) => { - const state = get() - if ( - !state.pendingAddressBarFocusByPageId[pageId] && - !state.pendingAddressBarFocusByTabId[pageId] - ) { - return false - } - - set((s) => { - const nextByPageId = { ...s.pendingAddressBarFocusByPageId } - delete nextByPageId[pageId] - const nextByTabId = { ...s.pendingAddressBarFocusByTabId } - delete nextByTabId[pageId] - return { - pendingAddressBarFocusByPageId: nextByPageId, - pendingAddressBarFocusByTabId: nextByTabId - } - }) - - return true - }, - - updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates), - - updateBrowserPageState: (pageId, updates) => { - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - const nextPage = { - ...page, - title: - updates.title === undefined - ? page.title - : normalizeBrowserTitle(updates.title, page.url, page.docLocation), - loading: updates.loading ?? page.loading, - faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl, - canGoBack: updates.canGoBack ?? page.canGoBack, - canGoForward: updates.canGoForward ?? page.canGoForward, - loadError: updates.loadError === undefined ? page.loadError : updates.loadError - } - const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? [] - const unifiedIndex = - workspace.activePageId === pageId && updates.title !== undefined - ? unifiedTabs.findIndex( - (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id - ) - : -1 - const unifiedLabelNeedsRepair = - unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title - const pageStateUnchanged = - nextPage.title === page.title && - nextPage.loading === page.loading && - nextPage.faviconUrl === page.faviconUrl && - nextPage.canGoBack === page.canGoBack && - nextPage.canGoForward === page.canGoForward && - nextPage.loadError === page.loadError - const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] - const mirroredWorkspace = pageStateUnchanged - ? mirrorWorkspaceFromActivePage(workspace, currentPages) - : null - const workspaceNeedsRepair = - mirroredWorkspace !== null && - !browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace) - if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) { - return s - } - if (pageStateUnchanged) { - const nextState: Partial = {} - if (workspaceNeedsRepair && mirroredWorkspace) { - nextState.browserTabsByWorktree = { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? mirroredWorkspace : tab) - ) - } - } - if (unifiedLabelNeedsRepair) { - nextState.unifiedTabsByWorktree = { - ...s.unifiedTabsByWorktree, - [workspace.worktreeId]: unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, label: nextPage.title } : entry - ) - } - } - return nextState - } - const nextPages = currentPages.map((entry) => (entry.id === pageId ? nextPage : entry)) - const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) - const nextState: Partial = { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - } - } - if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) { - nextState.browserTabsByWorktree = { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - } - } - if (workspace.activePageId === pageId && updates.title !== undefined && unifiedIndex !== -1) { - if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) { - nextState.unifiedTabsByWorktree = { - ...s.unifiedTabsByWorktree, - [workspace.worktreeId]: unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry - ) - } - } - } - return nextState - }) - if (updates.loadError === null) { - get().setBrowserPageCertificateFailure(pageId, null) - } - }, - - setBrowserPageCertificateFailure: (pageId, failure) => { - set((s) => { - const current = s.browserCertificateFailuresByPageId[pageId] - if (failure === null) { - if (!current) { - return s - } - const nextFailures = { ...s.browserCertificateFailuresByPageId } - delete nextFailures[pageId] - return { browserCertificateFailuresByPageId: nextFailures } - } - if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { - return s - } - return { - browserCertificateFailuresByPageId: { - ...s.browserCertificateFailuresByPageId, - [pageId]: failure - } - } - }) - }, - - setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), - - setBrowserPageUrl: (pageId, url, options) => { - const nextUrl = normalizeUrl(url) - if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) { - const currentPage = findPage(get().browserPagesByWorkspace, pageId) - if (currentPage) { - get().recordFeatureInteraction?.('browser') - } - } - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - // Why a document page keeps its blank url here too: this is the third door onto a page's url, - // and a document's url is blank by construction. A grant committed here would reach - // persistence, the publish boundary and the address bar, exactly as at the other two doors. - const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl - // Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers. - const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl - const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => - entry.id === pageId - ? { - ...entry, - url: nextPageUrl, - title: normalizeBrowserTitle(entry.title, nextPageUrl, entry.docLocation), - // Why not simply true: a document page's guest is inert, so there is nothing to wait - // for and the loading affordance would never clear. - loading: !entry.docLocation, - loadError: options?.preserveLoadError ? entry.loadError : null - } - : entry - ) - const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) - const nextBrowserAnnotationsByPageId = shouldClearAnnotations - ? { ...s.browserAnnotationsByPageId } - : s.browserAnnotationsByPageId - if (shouldClearAnnotations) { - delete nextBrowserAnnotationsByPageId[pageId] - } - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - }, - ...(shouldClearAnnotations - ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } - : {}) - } - }) - get().setBrowserPageCertificateFailure(pageId, null) - }, - - setRemoteBrowserPageHandle: (pageId, handle) => { - set((s) => ({ - remoteBrowserPageHandlesByPageId: { - ...s.remoteBrowserPageHandlesByPageId, - [pageId]: handle - } - })) - }, - - removeRemoteBrowserPageHandle: (pageId, remotePageId) => { - let removedHandle: RemoteBrowserPageHandle | null = null - set((s) => { - const current = s.remoteBrowserPageHandlesByPageId[pageId] - if (!current || (remotePageId && current.remotePageId !== remotePageId)) { - return s - } - removedHandle = current - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - delete nextRemoteBrowserPageHandlesByPageId[pageId] - return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } - }) - return removedHandle - }, - - // viewportPresetId is intentionally page-local (no workspace-layer UI consumer); do NOT add mirrorWorkspaceFromActivePage here. - setBrowserPageViewportPreset: (pageId, viewportPresetId) => - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => - entry.id === pageId ? { ...entry, viewportPresetId } : entry - ) - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - } - } - }), - - addBrowserPageAnnotation: (annotation) => - set((s) => { - const existing = s.browserAnnotationsByPageId[annotation.browserPageId] ?? [] - const next = [...existing, sanitizeBrowserPageAnnotation(annotation)].slice( - -GRAB_BUDGET.annotationsMaxPerPage - ) - return { - browserAnnotationsByPageId: { - ...s.browserAnnotationsByPageId, - [annotation.browserPageId]: next - } - } - }), - - updateBrowserPageAnnotation: (pageId, annotationId, patch) => - set((s) => { - const existing = s.browserAnnotationsByPageId[pageId] ?? [] - const target = existing.find((annotation) => annotation.id === annotationId) - if (!target) { - return s - } - const updated = sanitizeBrowserPageAnnotation({ ...target, ...patch }) - return { - browserAnnotationsByPageId: { - ...s.browserAnnotationsByPageId, - [pageId]: existing.map((annotation) => - annotation.id === annotationId ? updated : annotation - ) - } - } - }), - - deleteBrowserPageAnnotation: (pageId, annotationId) => - set((s) => { - const existing = s.browserAnnotationsByPageId[pageId] ?? [] - const next = existing.filter((annotation) => annotation.id !== annotationId) - if (next.length === existing.length) { - return s - } - const nextByPageId = { ...s.browserAnnotationsByPageId } - if (next.length > 0) { - nextByPageId[pageId] = next - } else { - delete nextByPageId[pageId] - } - return { browserAnnotationsByPageId: nextByPageId } - }), - - clearBrowserPageAnnotations: (pageId) => - set((s) => { - if (!s.browserAnnotationsByPageId[pageId]?.length) { - return s - } - const nextByPageId = { ...s.browserAnnotationsByPageId } - delete nextByPageId[pageId] - return { browserAnnotationsByPageId: nextByPageId } - }), - - hydrateBrowserSession: (session, options) => { - const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} - const currentState = get() - const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration( - currentState, - Object.keys(persistedTabsByWorktree) - ) - validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of currentState.folderWorkspaces) { - validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options) - - // Why: destroy dropped workspaces' webviews before the pure reducer; no-op today (boot registry empty), defends future re-hydration callers. - const droppedWorkspaceIds: string[] = [] - for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { - if (!validWorktreeIdsForCleanup.has(worktreeId)) { - for (const tab of tabs) { - droppedWorkspaceIds.push(tab.id) - } - } - } - for (const workspaceId of droppedWorkspaceIds) { - destroyWorkspaceWebviews(currentState.browserPagesByWorkspace, workspaceId) - } - - set((s) => { - const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} - const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} - const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} - const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( - s, - Object.keys(persistedTabsByWorktree) - ) - validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of s.folderWorkspaces) { - validWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - - const browserTabsByWorktree: Record = {} - const browserPagesByWorkspace: Record = {} - - for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { - if (!validWorktreeIds.has(worktreeId)) { - continue - } - const hydratedTabs: BrowserWorkspace[] = [] - for (const tab of tabs) { - // Salvage can leave an empty page array; hydrate it like a missing array. - const storedPages = persistedPagesByWorkspace[tab.id] - const persistedPages = storedPages?.length - ? storedPages - : [ - { - id: createBrowserUuid(), - workspaceId: tab.id, - worktreeId, - url: normalizeUrl(tab.url), - title: tab.title, - loading: false, - faviconUrl: tab.faviconUrl ?? null, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward, - loadError: tab.loadError ?? null, - createdAt: tab.createdAt, - // Why the tab's copy is authority here: this branch runs when the page array was - // salvaged away, and without it a restored document page comes back as a blank - // New Tab under a strip entry still naming the document. - docLocation: tab.docLocation ?? null - } satisfies BrowserPage - ] - const nextPages = persistedPages.map((page) => { - // Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping. - const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } = - page as typeof page & { - allowWindowClose?: boolean - } - return { - ...persistedPage, - workspaceId: tab.id, - worktreeId, - // Why re-asserted on restore: the same invariant creation enforces. A session written - // by an older or hand-edited build could carry a grant URL here, and it would name a - // grant that died with the process that minted it. - url: page.docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(page.url), - loading: false, - loadError: page.loadError ?? null - } - }) - browserPagesByWorkspace[tab.id] = nextPages - hydratedTabs.push( - mirrorWorkspaceFromActivePage( - { - ...tab, - activePageId: nextPages.some((page) => page.id === tab.activePageId) - ? (tab.activePageId ?? nextPages[0]?.id ?? null) - : (nextPages[0]?.id ?? null), - pageIds: nextPages.map((page) => page.id) - }, - nextPages - ) - ) - } - if (hydratedTabs.length > 0) { - browserTabsByWorktree[worktreeId] = hydratedTabs - } - } - - const validBrowserTabIds = new Set( - Object.values(browserTabsByWorktree) - .flat() - .map((tab) => tab.id) - ) - - const activeBrowserTabIdByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) { - const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId] - activeBrowserTabIdByWorktree[worktreeId] = - persistedTabId && validBrowserTabIds.has(persistedTabId) - ? persistedTabId - : (tabs[0]?.id ?? null) - } - - const activeWorktreeId = s.activeWorktreeId - const activeBrowserTabId = - activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId] - ? activeBrowserTabIdByWorktree[activeWorktreeId] - : null - - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - for (const worktreeId of validWorktreeIds) { - const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0 - if ( - persistedActiveTabTypeByWorktree[worktreeId] === 'browser' && - hasBrowserTabs && - !nextActiveTabTypeByWorktree[worktreeId] - ) { - nextActiveTabTypeByWorktree[worktreeId] = 'browser' - continue - } - if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) { - nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree( - worktreeId, - s.openFiles, - s.tabsByWorktree, - browserTabsByWorktree - ) - } - } - - const activeTabType = (() => { - if (!activeWorktreeId) { - return s.activeTabType - } - const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId] - if (restoredTabType === 'browser' && activeBrowserTabId) { - return 'browser' - } - if ( - restoredTabType === 'editor' && - s.openFiles.some((file) => file.worktreeId === activeWorktreeId) - ) { - return 'editor' - } - return getFallbackTabTypeForWorktree( - activeWorktreeId, - s.openFiles, - s.tabsByWorktree, - browserTabsByWorktree - ) - })() - - return { - browserTabsByWorktree, - browserPagesByWorkspace, - activeBrowserTabIdByWorktree, - activeBrowserTabId, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - activeTabType, - remoteBrowserPageHandlesByPageId: - buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace), - browserCertificateFailuresByPageId: {}, - browserAnnotationsByPageId: {}, - browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), - workspaceDocHistory: normalizeWorkspaceDocHistoryEntries(session.workspaceDocHistory ?? []), - // Why restored before the rows are: a close the host never heard must outlive the relaunch - // that also restores the row it closed, or the restore silently wins. - clientHostedBrowserCloseIntentsByEnvironment: - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - } - }) - - const state = get() - // Why here and not in the startup chain: the seeded handles are the only record that this - // desktop was hosting pages, and the runtime only hands them back once it sees an attach. - void ensureBrowserClientHostsForRestoredPages(state) - for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) { - for (const bt of browserTabs) { - const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( - (t) => t.contentType === 'browser' && t.entityId === bt.id - ) - if (!exists) { - state.createUnifiedTab(worktreeId, 'browser', { - entityId: bt.id, - label: bt.title, - recordInteraction: false - }) - } - } - } - }, - - switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { - set((s) => { - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const tabIndex = tabs.findIndex((t) => t.id === workspaceId) - if (tabIndex !== -1) { - const updatedTabs = [...tabs] - updatedTabs[tabIndex] = { - ...updatedTabs[tabIndex], - sessionProfileId: profileId, - sessionPartition: sessionPartition ?? null - } - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [worktreeId]: updatedTabs - } - } - } - } - return {} - }) - }, - - fetchBrowserSessionProfiles: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileList', - undefined, - { timeoutMs: 15_000 } - ) - // Why: client-hosted imports never touch the server, so the server's - // records can't carry the "imported from Chrome" badge — this desktop - // remembers what it imported into each environment's jar and overlays it. - const clientImportSources = await window.api.browser - .sessionClientRouteImportSources?.({ environmentId: runtimeEnvironmentId }) - .catch(() => ({})) - const profiles = result.profiles.map((profile) => - !profile.source && clientImportSources?.[profile.id] - ? { ...profile, source: clientImportSources[profile.id] } - : profile - ) - set((s) => profileListByHostUpdate(s, profiles, hostId)) - } catch { - set((s) => profileListByHostUpdate(s, [], hostId)) - } - return - } - try { - const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] - set((s) => profileListByHostUpdate(s, profiles, hostId)) - } catch { - /* best-effort — stale profile list is preferable to a crash */ - } - }, - - createBrowserSessionProfile: async (scope, label, options) => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileCreate', - { scope, label, ...options }, - { timeoutMs: 15_000 } - ) - const profile = result.profile - if (profile) { - set((s) => ({ - ...profileListByHostUpdate( - s, - [...getBrowserProfilesForHost(s, hostId), profile], - hostId - ) - })) - } - return profile - } catch { - return null - } - } - try { - const profile = (await window.api.browser.sessionCreateProfile({ - scope, - label, - ...options - })) as BrowserSessionProfile | null - if (profile) { - set((s) => ({ - ...profileListByHostUpdate(s, [...getBrowserProfilesForHost(s, hostId), profile], hostId) - })) - } - return profile - } catch { - return null - } - }, - - deleteBrowserSessionProfile: async (profileId) => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileDelete', - { profileId }, - { timeoutMs: 15_000 } - ) - if (result.deleted) { - set((s) => ({ - ...profileListByHostUpdate( - s, - getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), - hostId - ), - ...(getDefaultBrowserProfileForHost(s, hostId) === profileId - ? { - ...(getBrowserSettingsHostId(s) === hostId - ? { defaultBrowserSessionProfileId: null } - : {}), - defaultBrowserSessionProfileIdByHostId: { - ...s.defaultBrowserSessionProfileIdByHostId, - [hostId]: null - } - } - : {}) - })) - } - return result.deleted - } catch { - return false - } - } - try { - const ok = await window.api.browser.sessionDeleteProfile({ profileId }) - if (ok) { - set((s) => ({ - ...profileListByHostUpdate( - s, - getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), - hostId - ), - ...(getDefaultBrowserProfileForHost(s, hostId) === profileId - ? { - ...(getBrowserSettingsHostId(s) === hostId - ? { defaultBrowserSessionProfileId: null } - : {}), - defaultBrowserSessionProfileIdByHostId: { - ...s.defaultBrowserSessionProfileIdByHostId, - [hostId]: null - } - } - : {}) - })) - } - return ok - } catch { - return false - } - }, - - importCookiesToProfile: async (profileId) => { - const initialState = get() - const hostId = getBrowserSettingsHostId(initialState) - const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) - if (getBrowserSettingsRuntimeEnvironmentId(initialState)) { - const reason = translate( - 'auto.store.slices.browser.remoteCookieImportUnavailable', - 'Manual cookie file import is unavailable while a remote runtime is active.' - ) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - 'client' - ) - } - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'importing', - summary: null, - error: null - }) - ) - try { - const result = (await window.api.browser.sessionImportCookies({ - profileId - })) as BrowserCookieImportResult - if (result.ok) { - get().recordFeatureInteraction?.('cookie-import') - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'success', - summary: result.summary, - error: null - }) - ) - if (getBrowserSettingsHostId(get()) === hostId) { - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) - } - } else { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: result.reason === 'canceled' ? 'idle' : 'error', - summary: null, - error: result.reason === 'canceled' ? null : result.reason - }) - ) - } - return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') - } catch (err) { - const reason = String((err as Error)?.message ?? err) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - 'client' - ) - } - }, - - clearBrowserSessionImportState: () => { - set({ browserSessionImportState: null }) - }, - - detectedBrowsers: [], - detectedBrowsersLoaded: false, - detectedBrowsersHost: null, - - fetchDetectedBrowsers: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - const hostLabel = selectExecutionHostDisplayLabel(get(), hostId) - try { - // Why: the import runs on whichever machine hosts the pages, so the picker must offer that - // machine's browsers -- client-hosted means this desktop, not the (usually headless) remote. - const clientHostBrowsers = await window.api.browser.sessionDetectBrowsersForClientHost({ - environmentId: runtimeEnvironmentId - }) - const browsers = - clientHostBrowsers ?? - ( - await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileDetectBrowsers', - undefined, - { timeoutMs: 15_000 } - ) - ).browsers - // Why: retain which machine answered so import menus can say where imports read and store. - const detectedBrowsersHost = { - machine: clientHostBrowsers ? ('client' as const) : ('remote' as const), - hostLabel - } - set((s) => - getBrowserSettingsHostId(s) === hostId - ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } - : {} - ) - } catch { - set((s) => - getBrowserSettingsHostId(s) === hostId - ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} - ) - } - return - } - if (get().detectedBrowsersLoaded) { - return - } - try { - const browsers = (await window.api.browser.sessionDetectBrowsers()) as { - family: string - label: string - profiles: { name: string; directory: string }[] - selectedProfile: string - }[] - set((s) => - getBrowserSettingsHostId(s) === hostId - ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} - ) - } catch { - /* best-effort — empty list is acceptable fallback */ - set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) - } - }, - - importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { - const initialState = get() - const hostId = getBrowserSettingsHostId(initialState) - const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(initialState) - if (runtimeEnvironmentId) { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'importing', - summary: null, - error: null - }) - ) - // Why: tracked across the try so a failed fallback RPC still reports the machine it ran on. - let ranOnClient = false - try { - // Why: client-hosted pages render on this desktop, so their logins live - // here -- detecting and importing on the headless remote finds nothing. - const clientHostResult = await window.api.browser.sessionImportFromBrowserForClientHost({ - environmentId: runtimeEnvironmentId, - profileId, - browserFamily, - browserProfile - }) - ranOnClient = clientHostResult != null - const result = - clientHostResult ?? - (await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileImportFromBrowser', - { profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true }, - { timeoutMs: 30_000 } - )) - if (result.ok) { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'success', - summary: result.summary, - error: null - }) - ) - if (getBrowserSettingsHostId(get()) === hostId) { - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) - } - } else { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: result.reason - }) - ) - } - return retainCookieImportExecutionHost( - result, - hostId, - executionHostLabel, - ranOnClient ? 'client' : 'remote' - ) - } catch (err) { - const reason = String((err as Error)?.message ?? err) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - ranOnClient ? 'client' : 'remote' - ) - } - } - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'importing', - summary: null, - error: null - }) - ) - try { - const result = (await window.api.browser.sessionImportFromBrowser({ - profileId, - browserFamily, - browserProfile - })) as BrowserCookieImportResult - if (result.ok) { - get().recordFeatureInteraction?.('cookie-import') - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'success', - summary: result.summary, - error: null - }) - ) - if (getBrowserSettingsHostId(get()) === hostId) { - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) - } - } else { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: result.reason - }) - ) - } - return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') - } catch (err) { - const reason = String((err as Error)?.message ?? err) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - 'client' - ) - } - }, - - clearDefaultSessionCookies: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileClearDefaultCookies', - undefined, - { timeoutMs: 15_000 } - ) - if (result.cleared && getBrowserSettingsHostId(get()) === hostId) { - await get().fetchBrowserSessionProfiles() - } - return result.cleared - } catch { - return false - } - } - try { - const ok = await window.api.browser.sessionClearDefaultCookies() - if (ok && getBrowserSettingsHostId(get()) === hostId) { - get().recordFeatureInteraction?.('cookie-import') - await get().fetchBrowserSessionProfiles() - } - return ok - } catch { - return false - } - }, - - recordWorkspaceDocVisit: (docLocation, title, options) => { - const bump = options?.bump ?? true - set((s) => { - const now = Date.now() - const existing = s.workspaceDocHistory.find((entry) => - browserPageDocLocationsEqual(entry.docLocation, docLocation) - ) - if (!existing && !bump) { - // A title refresh for a document never visited records nothing. - return s - } - const normalizedTitle = normalizeWorkspaceDocHistoryTitle( - title ?? existing?.title, - docLocation - ) - const next: WorkspaceDocHistoryEntry[] = existing - ? s.workspaceDocHistory.map((entry) => - entry === existing - ? { - ...entry, - title: normalizedTitle, - ...(bump ? { lastVisitedAt: now, visitCount: entry.visitCount + 1 } : {}) - } - : entry - ) - : [ - { docLocation, title: normalizedTitle, lastVisitedAt: now, visitCount: 1 }, - ...s.workspaceDocHistory - ] - return { - workspaceDocHistory: - next.length > MAX_WORKSPACE_DOC_HISTORY_ENTRIES - ? normalizeWorkspaceDocHistoryEntries(next) - : next - } - }) - }, - - addBrowserHistoryEntry: (url, title) => { - const safeUrl = redactKagiSessionToken(url) - if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) { - return - } - const normalized = normalizeBrowserHistoryUrl(safeUrl) - set((s) => { - const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized) - let next: BrowserHistoryEntry[] = existing - ? s.browserUrlHistory.map((entry) => - entry === existing - ? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 } - : entry - ) - : [ - { - url: safeUrl, - normalizedUrl: normalized, - title, - lastVisitedAt: Date.now(), - visitCount: 1 - }, - ...s.browserUrlHistory - ] - if (next.length > MAX_BROWSER_HISTORY_ENTRIES) { - next = next - .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) - .slice(0, MAX_BROWSER_HISTORY_ENTRIES) - } - return { browserUrlHistory: next } - }) - }, - - // One clear for both sources: the dropdown presents them as one history. - clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] }) -}) + CreateBrowserTabOptions, + CreateBrowserPageOptions, + SetBrowserPageUrlOptions, + ClosedBrowserWorkspaceSnapshot, + RemoteBrowserPageHandle +} from './browser/browser-slice-contract' +export { createBrowserSlice } from './browser/create-browser-slice' +export { isLocalBrowserPageOwner } from './browser/browser-host-state' +export { sanitizeBrowserPageAnnotation } from './browser/browser-page-annotation' diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts new file mode 100644 index 00000000000..c70b93f6eb2 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -0,0 +1,250 @@ +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { findWorkspace } from '../browser-page-records' +import { pickNeighbor } from '../tab-group-state' +import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../recently-closed-tabs' +import { getFallbackTabTypeForWorktree, isLocalBrowserPageOwner } from './browser-host-state' +import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close' +import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' +import { destroyWorkspaceWebviews } from '../browser-webview-cleanup' + +export function createBrowserCloseActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + closeBrowserTab: (tabId, options) => { + // Why: a cleanup close unwinds a tab that never finished being created — it owns no host + // page to close and must not enter the reopen stack as if the user had closed something. + const isCleanup = options?.reason === 'cleanup' + let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + // Why collected rather than released inside the reducer: revoking is main-process work, and a + // grant is the only authority the preview scheme honors — a closed document must stop being + // readable, and it must stop being readable even if the reducer bails out below. + let docPageIdsToRelease: string[] = [] + let activeBrowserWorktreeIdToNotify: string | null = null + set((s) => { + let owningWorktreeId: string | null = null + let closedWorkspace: BrowserWorkspace | null = null + const nextBrowserTabsByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const removedTab = tabs.find((tab) => tab.id === tabId) ?? null + const filtered = tabs.filter((tab) => tab.id !== tabId) + if (filtered.length !== tabs.length) { + owningWorktreeId = worktreeId + closedWorkspace = removedTab + } + if (filtered.length > 0) { + nextBrowserTabsByWorktree[worktreeId] = filtered + } + } + if (!owningWorktreeId || !closedWorkspace) { + return s + } + + const closedPages = s.browserPagesByWorkspace[tabId] ?? [] + const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } + delete nextBrowserPagesByWorkspace[tabId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + for (const page of closedPages) { + delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserCertificateFailuresByPageId[page.id] + } + docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) + remotePagesToClose = isCleanup + ? [] + : closedPages.flatMap((page) => { + const handle = s.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [{ worktreeId: page.worktreeId, handle }] : [] + }) + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + for (const page of closedPages) { + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } + + const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { + const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) + nextActiveBrowserTabIdByWorktree[owningWorktreeId] = + (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) + ? neighborId + : remainingBrowserTabs[0]?.id) ?? null + } + + const nextTabBarOrder = { + ...s.tabBarOrderByWorktree, + [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( + (entryId) => entryId !== tabId + ) + } + + const isActiveTabInOwningWorktree = + s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId + if (isActiveTabInOwningWorktree) { + activeBrowserWorktreeIdToNotify = owningWorktreeId + } + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + let nextActiveTabType = s.activeTabType + if (remainingBrowserTabs.length === 0) { + const fallbackTabType = getFallbackTabTypeForWorktree( + owningWorktreeId, + s.openFiles, + s.tabsByWorktree + ) + nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType + if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { + nextActiveTabType = fallbackTabType + } + } + + const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } + if (!isCleanup) { + const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] + const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ + { + workspace: closedWorkspace, + pages: closedPages, + ...(position ? { position } : {}) + }, + ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) + ].slice(0, 10) + } + const nextRecentlyClosedTabKindsByWorktree = isCleanup + ? s.recentlyClosedTabKindsByWorktree + : pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + owningWorktreeId, + 'browser' + ) + + const nextRecentlyClosedBrowserPagesByWorkspace = { + ...s.recentlyClosedBrowserPagesByWorkspace + } + delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] + + const nextPendingAddressBarFocusByPageId = Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pageId]) => !closedPages.some((page) => page.id === pageId) + ) + ) + const nextPendingAddressBarFocusByTabId = Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId) + ) + ) + + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + browserPagesByWorkspace: nextBrowserPagesByWorkspace, + activeBrowserTabId: + s.activeBrowserTabId === tabId + ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) + : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrder, + activeTabType: nextActiveTabType, + pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + + for (const docPageId of docPageIdsToRelease) { + releaseDocPreviewGrant(docPageId) + } + + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'browser' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab( + workspaceItem.id, + isCleanup ? { preserveWorktreeSelection: true, recordInteraction: false } : undefined + ) + } + } + + // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. + if (activeBrowserWorktreeIdToNotify) { + const state = get() + const activeWorkspaceId = + state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] + const activeWorkspace = activeWorkspaceId + ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) + : null + const activePage = activeWorkspace?.activePageId + ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( + (page) => page.id === activeWorkspace.activePageId + ) + : undefined + if ( + activeWorkspace?.activePageId && + isLocalBrowserPageOwner( + state, + activeBrowserWorktreeIdToNotify, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser + .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) + .catch(() => {}) + } + } + }, + + shutdownWorktreeBrowsers: async (worktreeId) => { + const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] + // Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. + const hadBrowserTabs = workspaces.length > 0 + for (const workspace of workspaces) { + const browserPagesByWorkspace = get().browserPagesByWorkspace + get().closeBrowserTab(workspace.id) + destroyWorkspaceWebviews(browserPagesByWorkspace, workspace.id) + } + set((s) => { + const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } + delete nextBrowserTabsByWorktree[worktreeId] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + delete nextActiveBrowserTabIdByWorktree[worktreeId] + // Why: reset the global browser surface only when the shut-down worktree is the active one AND had tabs. + const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + ...(shouldResetGlobalBrowser + ? { activeBrowserTabId: null, activeTabType: 'terminal' as const } + : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts b/src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts new file mode 100644 index 00000000000..99ad8a753e3 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts @@ -0,0 +1,193 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { BrowserCookieImportResult } from '../../../../../shared/browser-workspace-types' +import type { + BrowserProfileImportFromBrowserResult, + BrowserProfileClearDefaultCookiesResult +} from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { + getBrowserSettingsHostId, + getBrowserSettingsRuntimeEnvironmentId, + browserImportStateForHostUpdate, + retainCookieImportExecutionHost +} from './browser-host-state' + +export function createBrowserCookieImportActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { + const initialState = get() + const hostId = getBrowserSettingsHostId(initialState) + const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(initialState) + if (runtimeEnvironmentId) { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'importing', + summary: null, + error: null + }) + ) + // Why: tracked across the try so a failed fallback RPC still reports the machine it ran on. + let ranOnClient = false + try { + // Why: client-hosted pages render on this desktop, so their logins live + // here -- detecting and importing on the headless remote finds nothing. + const clientHostResult = await window.api.browser.sessionImportFromBrowserForClientHost({ + environmentId: runtimeEnvironmentId, + profileId, + browserFamily, + browserProfile + }) + ranOnClient = clientHostResult != null + const result = + clientHostResult ?? + (await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileImportFromBrowser', + { profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true }, + { timeoutMs: 30_000 } + )) + if (result.ok) { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'success', + summary: result.summary, + error: null + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } + } else { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: result.reason + }) + ) + } + return retainCookieImportExecutionHost( + result, + hostId, + executionHostLabel, + ranOnClient ? 'client' : 'remote' + ) + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + ranOnClient ? 'client' : 'remote' + ) + } + } + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'importing', + summary: null, + error: null + }) + ) + try { + const result = (await window.api.browser.sessionImportFromBrowser({ + profileId, + browserFamily, + browserProfile + })) as BrowserCookieImportResult + if (result.ok) { + get().recordFeatureInteraction?.('cookie-import') + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'success', + summary: result.summary, + error: null + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } + } else { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: result.reason + }) + ) + } + return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + 'client' + ) + } + }, + + clearDefaultSessionCookies: async () => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileClearDefaultCookies', + undefined, + { timeoutMs: 15_000 } + ) + if (result.cleared && getBrowserSettingsHostId(get()) === hostId) { + await get().fetchBrowserSessionProfiles() + } + return result.cleared + } catch { + return false + } + } + try { + const ok = await window.api.browser.sessionClearDefaultCookies() + if (ok && getBrowserSettingsHostId(get()) === hostId) { + get().recordFeatureInteraction?.('cookie-import') + await get().fetchBrowserSessionProfiles() + } + return ok + } catch { + return false + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-history-actions.ts b/src/renderer/src/store/slices/browser/browser-history-actions.ts new file mode 100644 index 00000000000..97ebf8b873b --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-history-actions.ts @@ -0,0 +1,99 @@ +import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types' +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' +import { redactKagiSessionToken } from '../../../../../shared/browser-url' +import { + MAX_BROWSER_HISTORY_ENTRIES, + normalizeBrowserHistoryUrl +} from '../../../../../shared/workspace-session-browser-history' +import { + MAX_WORKSPACE_DOC_HISTORY_ENTRIES, + normalizeWorkspaceDocHistoryEntries, + normalizeWorkspaceDocHistoryTitle +} from '../../../../../shared/workspace-doc-history' +import { browserPageDocLocationsEqual } from '../../../../../shared/browser-page-doc-location' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' + +export function createBrowserHistoryActions( + set: BrowserSliceSet, + _get: BrowserSliceGet +): Pick< + BrowserSlice, + 'recordWorkspaceDocVisit' | 'addBrowserHistoryEntry' | 'clearBrowserHistory' +> { + return { + recordWorkspaceDocVisit: (docLocation, title, options) => { + const bump = options?.bump ?? true + set((s) => { + const now = Date.now() + const existing = s.workspaceDocHistory.find((entry) => + browserPageDocLocationsEqual(entry.docLocation, docLocation) + ) + if (!existing && !bump) { + // A title refresh for a document never visited records nothing. + return s + } + const normalizedTitle = normalizeWorkspaceDocHistoryTitle( + title ?? existing?.title, + docLocation + ) + const next: WorkspaceDocHistoryEntry[] = existing + ? s.workspaceDocHistory.map((entry) => + entry === existing + ? { + ...entry, + title: normalizedTitle, + ...(bump ? { lastVisitedAt: now, visitCount: entry.visitCount + 1 } : {}) + } + : entry + ) + : [ + { docLocation, title: normalizedTitle, lastVisitedAt: now, visitCount: 1 }, + ...s.workspaceDocHistory + ] + return { + workspaceDocHistory: + next.length > MAX_WORKSPACE_DOC_HISTORY_ENTRIES + ? normalizeWorkspaceDocHistoryEntries(next) + : next + } + }) + }, + + addBrowserHistoryEntry: (url, title) => { + const safeUrl = redactKagiSessionToken(url) + if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) { + return + } + const normalized = normalizeBrowserHistoryUrl(safeUrl) + set((s) => { + const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized) + let next: BrowserHistoryEntry[] = existing + ? s.browserUrlHistory.map((entry) => + entry === existing + ? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 } + : entry + ) + : [ + { + url: safeUrl, + normalizedUrl: normalized, + title, + lastVisitedAt: Date.now(), + visitCount: 1 + }, + ...s.browserUrlHistory + ] + if (next.length > MAX_BROWSER_HISTORY_ENTRIES) { + next = next + .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .slice(0, MAX_BROWSER_HISTORY_ENTRIES) + } + return { browserUrlHistory: next } + }) + }, + + // One clear for both sources: the dropdown presents them as one history. + clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] }) + } +} diff --git a/src/renderer/src/store/slices/browser/browser-host-actions.ts b/src/renderer/src/store/slices/browser/browser-host-actions.ts new file mode 100644 index 00000000000..2977717d38c --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-host-actions.ts @@ -0,0 +1,70 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { + clearClientHostedBrowserCloseIntents, + recordClientHostedBrowserCloseIntents +} from '@/runtime/client-hosted-browser-close-intents' +import { getBrowserSettingsHostId } from './browser-host-state' +import { parseExecutionHostId } from '../../../../../shared/execution-host' + +export function createBrowserHostActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'recordClientHostedBrowserCloseIntents' + | 'clearClientHostedBrowserCloseIntents' + | 'setBrowserSessionHostId' + | 'setDefaultBrowserSessionProfileId' +> { + return { + recordClientHostedBrowserCloseIntents: (closes) => { + set((s) => { + const next = recordClientHostedBrowserCloseIntents( + s.clientHostedBrowserCloseIntentsByEnvironment, + closes, + Date.now() + ) + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + }) + }, + + clearClientHostedBrowserCloseIntents: (environmentId, browserPageIds) => { + set((s) => { + const next = clearClientHostedBrowserCloseIntents( + s.clientHostedBrowserCloseIntentsByEnvironment, + { environmentId, browserPageIds, now: Date.now() } + ) + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + }) + }, + + setBrowserSessionHostId: async (hostId) => { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'local' && parsed?.kind !== 'runtime') { + return + } + const nextHostId = parsed.id + set((s) => ({ + browserSessionHostIdOverride: nextHostId, + browserSessionProfiles: s.browserSessionProfilesByHostId[nextHostId] ?? [], + defaultBrowserSessionProfileId: + s.defaultBrowserSessionProfileIdByHostId[nextHostId] ?? null, + browserSessionImportState: null, + detectedBrowsers: [], + detectedBrowsersLoaded: false, + detectedBrowsersHost: null + })) + await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()]) + }, + + setDefaultBrowserSessionProfileId: (profileId) => { + set((s) => ({ + defaultBrowserSessionProfileId: profileId, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: profileId + } + })) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts new file mode 100644 index 00000000000..1b4b3a70e9a --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -0,0 +1,175 @@ +import type { AppState } from '../../types' +import type { + BrowserCookieImportResult, + BrowserPage, + BrowserSessionProfile +} from '../../../../../shared/browser-workspace-types' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../../shared/execution-host' +import type { + BrowserSlice, + BrowserCookieImportExecutionResult, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' + +/** Rebuild the remote page handles a restored session implies. No placement is seeded: the + * persisted generations belong to the host lease that died with the last run, and the first host + * snapshot is what supplies the live one. + * + * Client-hosted rows only. A server-hosted page lives on the runtime, which may have restarted or + * been redeployed while this desktop was closed; a seeded handle sends its pane down the adopt + * branch, and the browser_tab_not_found that comes back deletes the row. With no handle the pane + * creates a fresh page at the URL the row persisted, which is what the user left behind. */ +export function buildRestoredRemoteBrowserPageHandles( + browserPagesByWorkspace: Record +): Record { + const handles: Record = {} + for (const pages of Object.values(browserPagesByWorkspace)) { + for (const page of pages) { + if ( + !page.browserRuntimeEnvironmentId || + !page.remoteBrowserPageId || + !page.remoteBrowserPageClientHosted + ) { + continue + } + handles[page.id] = { + environmentId: page.browserRuntimeEnvironmentId, + remotePageId: page.remoteBrowserPageId, + restoredFromSession: true, + restoredClientHosted: true + } + } + } + return handles +} + +export function retainCookieImportExecutionHost( + result: BrowserCookieImportResult, + executionHostId: ExecutionHostId, + executionHostLabel: string, + executionMachine: 'client' | 'remote' +): BrowserCookieImportExecutionResult { + return { + ...result, + executionHostId, + executionHostLabel, + executionMachine, + executionRemoteEnvironment: parseExecutionHostId(executionHostId)?.kind === 'runtime' + } +} + +export function getBrowserSettingsHostId( + state: Pick +): ExecutionHostId { + return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings) +} + +export function getBrowserSettingsRuntimeEnvironmentId( + state: Pick +): string | null { + const parsed = parseExecutionHostId(getBrowserSettingsHostId(state)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null +} + +export function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { + return getExecutionHostIdForWorktree(state, worktreeId) +} + +export function getBrowserSessionProfileHostId( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): ExecutionHostId { + if (browserRuntimeEnvironmentId === null) { + return LOCAL_EXECUTION_HOST_ID + } + if (browserRuntimeEnvironmentId !== undefined) { + const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim() + return runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + } + return getBrowserWorktreeHostId(state, worktreeId) +} + +export function isLocalBrowserPageOwner( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): boolean { + return ( + parseExecutionHostId( + getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId) + )?.kind !== 'runtime' + ) +} + +export function profileListByHostUpdate( + state: Pick< + AppState, + 'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings' + >, + profiles: BrowserSessionProfile[], + hostId: ExecutionHostId = getBrowserSettingsHostId(state) +): Partial { + return { + ...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}), + browserSessionProfilesByHostId: { + ...state.browserSessionProfilesByHostId, + [hostId]: profiles + } + } +} + +export function getBrowserProfilesForHost( + state: AppState, + hostId: ExecutionHostId +): BrowserSessionProfile[] { + return ( + state.browserSessionProfilesByHostId[hostId] ?? + (getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : []) + ) +} + +export function getDefaultBrowserProfileForHost( + state: AppState, + hostId: ExecutionHostId +): string | null { + return ( + state.defaultBrowserSessionProfileIdByHostId[hostId] ?? + (getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null) + ) +} + +export function browserImportStateForHostUpdate( + state: AppState, + hostId: ExecutionHostId, + browserSessionImportState: BrowserSlice['browserSessionImportState'] +): Partial { + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} +} + +export function getFallbackTabTypeForWorktree( + worktreeId: string, + openFiles: AppState['openFiles'], + terminalTabsByWorktree: AppState['tabsByWorktree'], + browserTabsByWorktree?: AppState['browserTabsByWorktree'] +): AppState['activeTabType'] { + if (openFiles.some((file) => file.worktreeId === worktreeId)) { + return 'editor' + } + if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) { + return 'browser' + } + if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) { + return 'terminal' + } + return 'terminal' +} diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts new file mode 100644 index 00000000000..a2ed3f5bbb1 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -0,0 +1,264 @@ +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { + FLOATING_TERMINAL_WORKTREE_ID, + ORCA_BROWSER_BLANK_URL +} from '../../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from '../degraded-repo-worktree-validity' +import { destroyWorkspaceWebviews } from '../browser-webview-cleanup' +import { mirrorWorkspaceFromActivePage, normalizeUrl } from '../browser-page-records' +import { + buildRestoredRemoteBrowserPageHandles, + getFallbackTabTypeForWorktree +} from './browser-host-state' +import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' +import { normalizeBrowserHistoryEntries } from '../../../../../shared/workspace-session-browser-history' +import { normalizeWorkspaceDocHistoryEntries } from '../../../../../shared/workspace-doc-history' + +export function createBrowserHydrationActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + hydrateBrowserSession: (session, options) => { + const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} + const currentState = get() + const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration( + currentState, + Object.keys(persistedTabsByWorktree) + ) + validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of currentState.folderWorkspaces) { + validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options) + + // Why: destroy dropped workspaces' webviews before the pure reducer; no-op today (boot registry empty), defends future re-hydration callers. + const droppedWorkspaceIds: string[] = [] + for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { + if (!validWorktreeIdsForCleanup.has(worktreeId)) { + for (const tab of tabs) { + droppedWorkspaceIds.push(tab.id) + } + } + } + for (const workspaceId of droppedWorkspaceIds) { + destroyWorkspaceWebviews(currentState.browserPagesByWorkspace, workspaceId) + } + + set((s) => { + const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} + const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} + const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + s, + Object.keys(persistedTabsByWorktree) + ) + validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) + + const browserTabsByWorktree: Record = {} + const browserPagesByWorkspace: Record = {} + + for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { + if (!validWorktreeIds.has(worktreeId)) { + continue + } + const hydratedTabs: BrowserWorkspace[] = [] + for (const tab of tabs) { + // Salvage can leave an empty page array; hydrate it like a missing array. + const storedPages = persistedPagesByWorkspace[tab.id] + const persistedPages = storedPages?.length + ? storedPages + : [ + { + id: createBrowserUuid(), + workspaceId: tab.id, + worktreeId, + url: normalizeUrl(tab.url), + title: tab.title, + loading: false, + faviconUrl: tab.faviconUrl ?? null, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + loadError: tab.loadError ?? null, + createdAt: tab.createdAt, + // Why the tab's copy is authority here: this branch runs when the page array was + // salvaged away, and without it a restored document page comes back as a blank + // New Tab under a strip entry still naming the document. + docLocation: tab.docLocation ?? null + } satisfies BrowserPage + ] + const nextPages = persistedPages.map((page) => { + // Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping. + const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } = + page as typeof page & { + allowWindowClose?: boolean + } + return { + ...persistedPage, + workspaceId: tab.id, + worktreeId, + // Why re-asserted on restore: the same invariant creation enforces. A session written + // by an older or hand-edited build could carry a grant URL here, and it would name a + // grant that died with the process that minted it. + url: page.docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(page.url), + loading: false, + loadError: page.loadError ?? null + } + }) + browserPagesByWorkspace[tab.id] = nextPages + hydratedTabs.push( + mirrorWorkspaceFromActivePage( + { + ...tab, + activePageId: nextPages.some((page) => page.id === tab.activePageId) + ? (tab.activePageId ?? nextPages[0]?.id ?? null) + : (nextPages[0]?.id ?? null), + pageIds: nextPages.map((page) => page.id) + }, + nextPages + ) + ) + } + if (hydratedTabs.length > 0) { + browserTabsByWorktree[worktreeId] = hydratedTabs + } + } + + const validBrowserTabIds = new Set( + Object.values(browserTabsByWorktree) + .flat() + .map((tab) => tab.id) + ) + + const activeBrowserTabIdByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) { + const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId] + activeBrowserTabIdByWorktree[worktreeId] = + persistedTabId && validBrowserTabIds.has(persistedTabId) + ? persistedTabId + : (tabs[0]?.id ?? null) + } + + const activeWorktreeId = s.activeWorktreeId + const activeBrowserTabId = + activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId] + ? activeBrowserTabIdByWorktree[activeWorktreeId] + : null + + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + for (const worktreeId of validWorktreeIds) { + const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0 + if ( + persistedActiveTabTypeByWorktree[worktreeId] === 'browser' && + hasBrowserTabs && + !nextActiveTabTypeByWorktree[worktreeId] + ) { + nextActiveTabTypeByWorktree[worktreeId] = 'browser' + continue + } + if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) { + nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree( + worktreeId, + s.openFiles, + s.tabsByWorktree, + browserTabsByWorktree + ) + } + } + + const activeTabType = (() => { + if (!activeWorktreeId) { + return s.activeTabType + } + const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId] + if (restoredTabType === 'browser' && activeBrowserTabId) { + return 'browser' + } + if ( + restoredTabType === 'editor' && + s.openFiles.some((file) => file.worktreeId === activeWorktreeId) + ) { + return 'editor' + } + return getFallbackTabTypeForWorktree( + activeWorktreeId, + s.openFiles, + s.tabsByWorktree, + browserTabsByWorktree + ) + })() + + return { + browserTabsByWorktree, + browserPagesByWorkspace, + activeBrowserTabIdByWorktree, + activeBrowserTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + activeTabType, + remoteBrowserPageHandlesByPageId: + buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace), + browserCertificateFailuresByPageId: {}, + browserAnnotationsByPageId: {}, + browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), + workspaceDocHistory: normalizeWorkspaceDocHistoryEntries( + session.workspaceDocHistory ?? [] + ), + // Why restored before the rows are: a close the host never heard must outlive the relaunch + // that also restores the row it closed, or the restore silently wins. + clientHostedBrowserCloseIntentsByEnvironment: + session.clientHostedBrowserCloseIntentsByEnvironment ?? {} + } + }) + + const state = get() + // Why here and not in the startup chain: the seeded handles are the only record that this + // desktop was hosting pages, and the runtime only hands them back once it sees an attach. + void ensureBrowserClientHostsForRestoredPages(state) + for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) { + for (const bt of browserTabs) { + const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === bt.id + ) + if (!exists) { + state.createUnifiedTab(worktreeId, 'browser', { + entityId: bt.id, + label: bt.title, + recordInteraction: false + }) + } + } + } + }, + + switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { + set((s) => { + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const tabIndex = tabs.findIndex((t) => t.id === workspaceId) + if (tabIndex !== -1) { + const updatedTabs = [...tabs] + updatedTabs[tabIndex] = { + ...updatedTabs[tabIndex], + sessionProfileId: profileId, + sessionPartition: sessionPartition ?? null + } + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: updatedTabs + } + } + } + } + return {} + }) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-annotation.ts b/src/renderer/src/store/slices/browser/browser-page-annotation.ts new file mode 100644 index 00000000000..c8be505ae2d --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-annotation.ts @@ -0,0 +1,18 @@ +import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../../shared/browser-grab-types' + +export function sanitizeBrowserPageAnnotation( + annotation: BrowserPageAnnotation +): BrowserPageAnnotation { + return { + ...annotation, + comment: + annotation.comment.length > GRAB_BUDGET.annotationCommentMaxLength + ? annotation.comment.slice(0, GRAB_BUDGET.annotationCommentMaxLength) + : annotation.comment, + payload: { + ...annotation.payload, + // Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note. + screenshot: null + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts new file mode 100644 index 00000000000..53156c04dd1 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts @@ -0,0 +1,212 @@ +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import type { BrowserPage } from '../../../../../shared/browser-workspace-types' +import { planBrowserPageConversion } from '../browser-page-conversion' +import { findPage, findWorkspace, mirrorWorkspaceFromActivePage } from '../browser-page-records' +import { assertManagedBrowserMaterializationAllowed } from '@/lib/client-creation-action-policy' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close' +import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' +import { isLocalBrowserPageOwner } from './browser-host-state' + +export function createBrowserPageConversionActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + convertBrowserPage: (pageId, target, options) => { + // Why the same assert as createBrowserTab: a conversion materializes a browser surface the + // same way a creation does, and a paired web client that cannot host one must refuse here too. + // Ownership is resolved ONCE, on the plan's own terms — property-present-undefined means + // worktree-inferred — so the assert and the plan cannot disagree about what is being built. + const oldPageForOwnership = findPage(get().browserPagesByWorkspace, pageId) + const declaredOwnership = + target.kind === 'workspace-doc' + ? null + : 'browserRuntimeEnvironmentId' in target + ? target.browserRuntimeEnvironmentId + : (oldPageForOwnership?.browserRuntimeEnvironmentId ?? null) + assertManagedBrowserMaterializationAllowed( + get(), + declaredOwnership !== undefined + ? declaredOwnership + : oldPageForOwnership + ? (getRuntimeEnvironmentIdForWorktree(get(), oldPageForOwnership.worktreeId) ?? null) + : null + ) + let converted: BrowserPage | null = null + let docPageIdToRelease: string | null = null + let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null + set((s) => { + const plan = planBrowserPageConversion(s, pageId, target, options) + if (!plan) { + return s + } + converted = plan.newPage + // Why collected rather than released inside the reducer: revoking is main-process work, and + // it must happen exactly once even if a later set() retries the reducer. + if (plan.oldPage.docLocation) { + docPageIdToRelease = plan.oldPage.id + } + const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] + if (remoteHandle) { + remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } + } + const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } + delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } + delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [plan.workspace.id]: plan.nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [plan.workspace.worktreeId]: ( + s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] + ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does + // not track, so the initializers' null narrowing would otherwise read these as never. + const newPage = converted as BrowserPage | null + const remoteClose = remotePageToClose as { + worktreeId: string + handle: RemoteBrowserPageHandle + } | null + if (!newPage) { + return null + } + if (remoteClose) { + closeRemoteBrowserPageInOwningEnvironment( + remoteClose.worktreeId, + remoteClose.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + // Why after the reducer: a closed document must stop being readable, and the grant is the only + // authority the preview scheme honors — but the store row has to stop naming it first. + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) + if (workspaceAfter?.activePageId === newPage.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find( + (entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId + ) + if (item) { + get().setTabLabel(item.id, newPage.title) + } + } + return newPage + }, + + reopenClosedBrowserPage: (workspaceId) => { + // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same page (TOCTOU). + let pageToRestore: BrowserPage | undefined + + set((s) => { + const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] + pageToRestore = recentlyClosed[0] + if (!pageToRestore) { + return s + } + return { + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspaceId]: recentlyClosed.slice(1) + } + } + }) + + if (!pageToRestore) { + return null + } + + return get().createBrowserPage(workspaceId, pageToRestore.url, { + title: pageToRestore.title, + activate: true, + browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId, + ...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {}) + }) + }, + + setActiveBrowserPage: (workspaceId, pageId) => { + set((s) => { + const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId) + if (!workspace) { + return s + } + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + if (!pages.some((page) => page.id === pageId)) { + return s + } + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: pageId + }, + pages + ) + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspaceId ? nextWorkspace : tab) + ) + } + } + }) + + // Why: switching the active page changes which guest webContents the CDP bridge targets for agent commands. + const activePage = (get().browserPagesByWorkspace[workspaceId] ?? []).find( + (page) => page.id === pageId + ) + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if ( + workspace && + isLocalBrowserPageOwner( + get(), + workspace.worktreeId, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) + } + if (!workspace) { + return + } + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, workspace.title) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts new file mode 100644 index 00000000000..7b15902ddee --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts @@ -0,0 +1,202 @@ +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import { + buildBrowserPage, + findPage, + findWorkspace, + mirrorWorkspaceFromActivePage +} from '../browser-page-records' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close' +import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' + +export function createBrowserPageCreateActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + createBrowserPage: (workspaceId, url, options) => { + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (!workspace) { + return null + } + const page = buildBrowserPage( + workspaceId, + workspace.worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId, + undefined, + options?.docLocation + ) + + set((s) => { + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + const shouldActivate = options?.activate ?? true + const nextPages = [...pages, page] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const shouldUpdateGlobalActiveSurface = + shouldActivate && + s.activeWorktreeId === workspace.worktreeId && + s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId + const shouldFocusAddressBar = + shouldUpdateGlobalActiveSurface && + !page.docLocation && + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) + + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspaceId ? nextWorkspace : tab) + ) + }, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } + }) + + const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (nextWorkspace?.activePageId === page.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, page.title) + } + } + return page + }, + + closeBrowserPage: (pageId) => { + let closedWorkspaceIdForLabel: string | null = null + let docPageIdToRelease: string | null = null + const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + closedWorkspaceIdForLabel = page.workspaceId + docPageIdToRelease = page.docLocation ? page.id : null + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const nextPages = currentPages.filter((entry) => entry.id !== pageId) + const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) + const nextActivePageId = + workspace.activePageId === pageId + ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) + : workspace.activePageId + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: nextActivePageId, + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] + if (remoteHandle) { + remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) + } + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + delete nextBrowserAnnotationsByPageId[pageId] + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + delete nextBrowserCertificateFailuresByPageId[pageId] + + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) + ) + }, + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspace.id]: [ + page, + ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( + (entry) => entry.id !== page.id + ) + ].slice(0, 10) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + + const closedWorkspaceId = closedWorkspaceIdForLabel + if (!closedWorkspaceId) { + return + } + const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) + if (item && workspace) { + get().setTabLabel(item.id, workspace.title) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-page-focus-actions.ts new file mode 100644 index 00000000000..c10dd6abb2e --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-focus-actions.ts @@ -0,0 +1,94 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { mirrorWorkspaceFromActivePage } from '../browser-page-records' +import { isLocalBrowserPageOwner } from './browser-host-state' + +export function createBrowserPageFocusActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + focusBrowserTabInWorktree: (worktreeId, browserPageId, options) => { + // Why: bridge targets a browserPageId but tabs activate a workspace; find the owning workspace (they differ for multi-page tabs). + const tabsForWorktree = get().browserTabsByWorktree[worktreeId] ?? [] + const workspace = tabsForWorktree.find((tab) => (tab.pageIds ?? []).includes(browserPageId)) + if (!workspace) { + // Best-effort: worktree state may not be hydrated yet, or the page closed between bridge switch and this IPC arriving. + return + } + // Default true: the only caller (tab switch --focus) wants the pane surfaced; false is an opt-out for pre-staging callers. + const surfacePane = options?.surfacePane ?? true + const pages = get().browserPagesByWorkspace[workspace.id] ?? [] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { ...workspace, activePageId: browserPageId }, + pages + ) + // TODO: duplicates setActiveBrowserTab/Page; can't reuse (they touch globals unconditionally). Extract a per-worktree-only helper. + set((s) => { + const isActiveWorktree = s.activeWorktreeId === worktreeId + // Per-worktree slots: always update — safe pre-staging, only visible when user navigates here. + const nextTabsByWorktree = { + ...s.browserTabsByWorktree, + [worktreeId]: tabsForWorktree.map((tab) => + tab.id === workspace.id ? nextWorkspace : tab + ) + } + const nextActiveTabIdByWorktree = { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: workspace.id + } + const nextActiveTabTypeByWorktree = surfacePane + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' as const } + : s.activeTabTypeByWorktree + // Globals: mutate only when the targeted worktree is active — keeps cross-worktree --focus silent. + return { + browserTabsByWorktree: nextTabsByWorktree, + activeBrowserTabIdByWorktree: nextActiveTabIdByWorktree, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + activeBrowserTabId: isActiveWorktree ? workspace.id : s.activeBrowserTabId, + activeTabType: isActiveWorktree && surfacePane ? 'browser' : s.activeTabType + } + }) + + // Why: notify the CDP bridge which guest webContents is active so agent commands target the correct page. + const focusedPage = pages.find((page) => page.id === browserPageId) + if ( + isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {}) + } + + // Why: sync the unified-tab strip's active entry; activateTab only mutates per-worktree slices, so it's cross-worktree-safe. + const item = (get().unifiedTabsByWorktree[worktreeId] ?? []).find( + (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id + ) + if (item) { + get().activateTab(item.id) + } + }, + + consumeAddressBarFocusRequest: (pageId) => { + const state = get() + if ( + !state.pendingAddressBarFocusByPageId[pageId] && + !state.pendingAddressBarFocusByTabId[pageId] + ) { + return false + } + + set((s) => { + const nextByPageId = { ...s.pendingAddressBarFocusByPageId } + delete nextByPageId[pageId] + const nextByTabId = { ...s.pendingAddressBarFocusByTabId } + delete nextByTabId[pageId] + return { + pendingAddressBarFocusByPageId: nextByPageId, + pendingAddressBarFocusByTabId: nextByTabId + } + }) + + return true + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts new file mode 100644 index 00000000000..7be8393b130 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts @@ -0,0 +1,131 @@ +import { GRAB_BUDGET } from '../../../../../shared/browser-grab-types' +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import { findPage, findWorkspace } from '../browser-page-records' +import { sanitizeBrowserPageAnnotation } from './browser-page-annotation' + +export function createBrowserPageMetadataActions( + set: BrowserSliceSet, + _get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'setRemoteBrowserPageHandle' + | 'removeRemoteBrowserPageHandle' + | 'setBrowserPageViewportPreset' + | 'addBrowserPageAnnotation' + | 'updateBrowserPageAnnotation' + | 'deleteBrowserPageAnnotation' + | 'clearBrowserPageAnnotations' +> { + return { + setRemoteBrowserPageHandle: (pageId, handle) => { + set((s) => ({ + remoteBrowserPageHandlesByPageId: { + ...s.remoteBrowserPageHandlesByPageId, + [pageId]: handle + } + })) + }, + + removeRemoteBrowserPageHandle: (pageId, remotePageId) => { + let removedHandle: RemoteBrowserPageHandle | null = null + set((s) => { + const current = s.remoteBrowserPageHandlesByPageId[pageId] + if (!current || (remotePageId && current.remotePageId !== remotePageId)) { + return s + } + removedHandle = current + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } + }) + return removedHandle + }, + + // viewportPresetId is intentionally page-local (no workspace-layer UI consumer); do NOT add mirrorWorkspaceFromActivePage here. + setBrowserPageViewportPreset: (pageId, viewportPresetId) => + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => + entry.id === pageId ? { ...entry, viewportPresetId } : entry + ) + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + } + } + }), + + addBrowserPageAnnotation: (annotation) => + set((s) => { + const existing = s.browserAnnotationsByPageId[annotation.browserPageId] ?? [] + const next = [...existing, sanitizeBrowserPageAnnotation(annotation)].slice( + -GRAB_BUDGET.annotationsMaxPerPage + ) + return { + browserAnnotationsByPageId: { + ...s.browserAnnotationsByPageId, + [annotation.browserPageId]: next + } + } + }), + + updateBrowserPageAnnotation: (pageId, annotationId, patch) => + set((s) => { + const existing = s.browserAnnotationsByPageId[pageId] ?? [] + const target = existing.find((annotation) => annotation.id === annotationId) + if (!target) { + return s + } + const updated = sanitizeBrowserPageAnnotation({ ...target, ...patch }) + return { + browserAnnotationsByPageId: { + ...s.browserAnnotationsByPageId, + [pageId]: existing.map((annotation) => + annotation.id === annotationId ? updated : annotation + ) + } + } + }), + + deleteBrowserPageAnnotation: (pageId, annotationId) => + set((s) => { + const existing = s.browserAnnotationsByPageId[pageId] ?? [] + const next = existing.filter((annotation) => annotation.id !== annotationId) + if (next.length === existing.length) { + return s + } + const nextByPageId = { ...s.browserAnnotationsByPageId } + if (next.length > 0) { + nextByPageId[pageId] = next + } else { + delete nextByPageId[pageId] + } + return { browserAnnotationsByPageId: nextByPageId } + }), + + clearBrowserPageAnnotations: (pageId) => + set((s) => { + if (!s.browserAnnotationsByPageId[pageId]?.length) { + return s + } + const nextByPageId = { ...s.browserAnnotationsByPageId } + delete nextByPageId[pageId] + return { browserAnnotationsByPageId: nextByPageId } + }) + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts new file mode 100644 index 00000000000..15e4892b351 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts @@ -0,0 +1,219 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { AppState } from '../../types' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { + browserWorkspaceMirrorFieldsEqual, + findPage, + findWorkspace, + mirrorWorkspaceFromActivePage, + normalizeBrowserTitle, + normalizeUrl +} from '../browser-page-records' + +export function createBrowserPageStateActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'updateBrowserTabPageState' + | 'updateBrowserPageState' + | 'setBrowserPageCertificateFailure' + | 'setBrowserTabUrl' + | 'setBrowserPageUrl' +> { + return { + updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates), + + updateBrowserPageState: (pageId, updates) => { + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const nextPage = { + ...page, + title: + updates.title === undefined + ? page.title + : normalizeBrowserTitle(updates.title, page.url, page.docLocation), + loading: updates.loading ?? page.loading, + faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl, + canGoBack: updates.canGoBack ?? page.canGoBack, + canGoForward: updates.canGoForward ?? page.canGoForward, + loadError: updates.loadError === undefined ? page.loadError : updates.loadError + } + const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? [] + const unifiedIndex = + workspace.activePageId === pageId && updates.title !== undefined + ? unifiedTabs.findIndex( + (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id + ) + : -1 + const unifiedLabelNeedsRepair = + unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title + const pageStateUnchanged = + nextPage.title === page.title && + nextPage.loading === page.loading && + nextPage.faviconUrl === page.faviconUrl && + nextPage.canGoBack === page.canGoBack && + nextPage.canGoForward === page.canGoForward && + nextPage.loadError === page.loadError + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const mirroredWorkspace = pageStateUnchanged + ? mirrorWorkspaceFromActivePage(workspace, currentPages) + : null + const workspaceNeedsRepair = + mirroredWorkspace !== null && + !browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace) + if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) { + return s + } + if (pageStateUnchanged) { + const nextState: Partial = {} + if (workspaceNeedsRepair && mirroredWorkspace) { + nextState.browserTabsByWorktree = { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? mirroredWorkspace : tab) + ) + } + } + if (unifiedLabelNeedsRepair) { + nextState.unifiedTabsByWorktree = { + ...s.unifiedTabsByWorktree, + [workspace.worktreeId]: unifiedTabs.map((entry, index) => + index === unifiedIndex ? { ...entry, label: nextPage.title } : entry + ) + } + } + return nextState + } + const nextPages = currentPages.map((entry) => (entry.id === pageId ? nextPage : entry)) + const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) + const nextState: Partial = { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + } + } + if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) { + nextState.browserTabsByWorktree = { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) + ) + } + } + if ( + workspace.activePageId === pageId && + updates.title !== undefined && + unifiedIndex !== -1 + ) { + if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) { + nextState.unifiedTabsByWorktree = { + ...s.unifiedTabsByWorktree, + [workspace.worktreeId]: unifiedTabs.map((entry, index) => + index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry + ) + } + } + } + return nextState + }) + if (updates.loadError === null) { + get().setBrowserPageCertificateFailure(pageId, null) + } + }, + + setBrowserPageCertificateFailure: (pageId, failure) => { + set((s) => { + const current = s.browserCertificateFailuresByPageId[pageId] + if (failure === null) { + if (!current) { + return s + } + const nextFailures = { ...s.browserCertificateFailuresByPageId } + delete nextFailures[pageId] + return { browserCertificateFailuresByPageId: nextFailures } + } + if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { + return s + } + return { + browserCertificateFailuresByPageId: { + ...s.browserCertificateFailuresByPageId, + [pageId]: failure + } + } + }) + }, + + setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), + + setBrowserPageUrl: (pageId, url, options) => { + const nextUrl = normalizeUrl(url) + if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) { + const currentPage = findPage(get().browserPagesByWorkspace, pageId) + if (currentPage) { + get().recordFeatureInteraction?.('browser') + } + } + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + // Why a document page keeps its blank url here too: this is the third door onto a page's url, + // and a document's url is blank by construction. A grant committed here would reach + // persistence, the publish boundary and the address bar, exactly as at the other two doors. + const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl + // Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers. + const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => + entry.id === pageId + ? { + ...entry, + url: nextPageUrl, + title: normalizeBrowserTitle(entry.title, nextPageUrl, entry.docLocation), + // Why not simply true: a document page's guest is inert, so there is nothing to wait + // for and the loading affordance would never clear. + loading: !entry.docLocation, + loadError: options?.preserveLoadError ? entry.loadError : null + } + : entry + ) + const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) + const nextBrowserAnnotationsByPageId = shouldClearAnnotations + ? { ...s.browserAnnotationsByPageId } + : s.browserAnnotationsByPageId + if (shouldClearAnnotations) { + delete nextBrowserAnnotationsByPageId[pageId] + } + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) + ) + }, + ...(shouldClearAnnotations + ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } + : {}) + } + }) + get().setBrowserPageCertificateFailure(pageId, null) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts new file mode 100644 index 00000000000..ec03690bf8e --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts @@ -0,0 +1,172 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { BrowserCookieImportResult } from '../../../../../shared/browser-workspace-types' +import type { BrowserDetectProfilesResult } from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { + getBrowserSettingsHostId, + getBrowserSettingsRuntimeEnvironmentId, + browserImportStateForHostUpdate, + retainCookieImportExecutionHost +} from './browser-host-state' + +export function createBrowserProfileImportActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + 'importCookiesToProfile' | 'clearBrowserSessionImportState' | 'fetchDetectedBrowsers' +> { + return { + importCookiesToProfile: async (profileId) => { + const initialState = get() + const hostId = getBrowserSettingsHostId(initialState) + const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) + if (getBrowserSettingsRuntimeEnvironmentId(initialState)) { + const reason = translate( + 'auto.store.slices.browser.remoteCookieImportUnavailable', + 'Manual cookie file import is unavailable while a remote runtime is active.' + ) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + 'client' + ) + } + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'importing', + summary: null, + error: null + }) + ) + try { + const result = (await window.api.browser.sessionImportCookies({ + profileId + })) as BrowserCookieImportResult + if (result.ok) { + get().recordFeatureInteraction?.('cookie-import') + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'success', + summary: result.summary, + error: null + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } + } else { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: result.reason === 'canceled' ? 'idle' : 'error', + summary: null, + error: result.reason === 'canceled' ? null : result.reason + }) + ) + } + return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + 'client' + ) + } + }, + + clearBrowserSessionImportState: () => { + set({ browserSessionImportState: null }) + }, + + fetchDetectedBrowsers: async () => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + const hostLabel = selectExecutionHostDisplayLabel(get(), hostId) + try { + // Why: the import runs on whichever machine hosts the pages, so the picker must offer that + // machine's browsers -- client-hosted means this desktop, not the (usually headless) remote. + const clientHostBrowsers = await window.api.browser.sessionDetectBrowsersForClientHost({ + environmentId: runtimeEnvironmentId + }) + const browsers = + clientHostBrowsers ?? + ( + await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileDetectBrowsers', + undefined, + { timeoutMs: 15_000 } + ) + ).browsers + // Why: retain which machine answered so import menus can say where imports read and store. + const detectedBrowsersHost = { + machine: clientHostBrowsers ? ('client' as const) : ('remote' as const), + hostLabel + } + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } + : {} + ) + } catch { + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } + : {} + ) + } + return + } + if (get().detectedBrowsersLoaded) { + return + } + try { + const browsers = (await window.api.browser.sessionDetectBrowsers()) as { + family: string + label: string + profiles: { name: string; directory: string }[] + selectedProfile: string + }[] + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { + detectedBrowsers: browsers, + detectedBrowsersLoaded: true, + detectedBrowsersHost: null + } + : {} + ) + } catch { + /* best-effort — empty list is acceptable fallback */ + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts new file mode 100644 index 00000000000..59340831411 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts @@ -0,0 +1,172 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { BrowserSessionProfile } from '../../../../../shared/browser-workspace-types' +import type { + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult +} from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getBrowserProfilesForHost, + getBrowserSettingsHostId, + getBrowserSettingsRuntimeEnvironmentId, + profileListByHostUpdate, + getDefaultBrowserProfileForHost +} from './browser-host-state' + +export function createBrowserProfileListActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + 'fetchBrowserSessionProfiles' | 'createBrowserSessionProfile' | 'deleteBrowserSessionProfile' +> { + return { + fetchBrowserSessionProfiles: async () => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileList', + undefined, + { timeoutMs: 15_000 } + ) + // Why: client-hosted imports never touch the server, so the server's + // records can't carry the "imported from Chrome" badge — this desktop + // remembers what it imported into each environment's jar and overlays it. + const clientImportSources = await window.api.browser + .sessionClientRouteImportSources?.({ environmentId: runtimeEnvironmentId }) + .catch(() => ({})) + const profiles = result.profiles.map((profile) => + !profile.source && clientImportSources?.[profile.id] + ? { ...profile, source: clientImportSources[profile.id] } + : profile + ) + set((s) => profileListByHostUpdate(s, profiles, hostId)) + } catch { + set((s) => profileListByHostUpdate(s, [], hostId)) + } + return + } + try { + const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] + set((s) => profileListByHostUpdate(s, profiles, hostId)) + } catch { + /* best-effort — stale profile list is preferable to a crash */ + } + }, + + createBrowserSessionProfile: async (scope, label, options) => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileCreate', + { scope, label, ...options }, + { timeoutMs: 15_000 } + ) + const profile = result.profile + if (profile) { + set((s) => ({ + ...profileListByHostUpdate( + s, + [...getBrowserProfilesForHost(s, hostId), profile], + hostId + ) + })) + } + return profile + } catch { + return null + } + } + try { + const profile = (await window.api.browser.sessionCreateProfile({ + scope, + label, + ...options + })) as BrowserSessionProfile | null + if (profile) { + set((s) => ({ + ...profileListByHostUpdate( + s, + [...getBrowserProfilesForHost(s, hostId), profile], + hostId + ) + })) + } + return profile + } catch { + return null + } + }, + + deleteBrowserSessionProfile: async (profileId) => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileDelete', + { profileId }, + { timeoutMs: 15_000 } + ) + if (result.deleted) { + set((s) => ({ + ...profileListByHostUpdate( + s, + getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), + hostId + ), + ...(getDefaultBrowserProfileForHost(s, hostId) === profileId + ? { + ...(getBrowserSettingsHostId(s) === hostId + ? { defaultBrowserSessionProfileId: null } + : {}), + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [hostId]: null + } + } + : {}) + })) + } + return result.deleted + } catch { + return false + } + } + try { + const ok = await window.api.browser.sessionDeleteProfile({ profileId }) + if (ok) { + set((s) => ({ + ...profileListByHostUpdate( + s, + getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), + hostId + ), + ...(getDefaultBrowserProfileForHost(s, hostId) === profileId + ? { + ...(getBrowserSettingsHostId(s) === hostId + ? { defaultBrowserSessionProfileId: null } + : {}), + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [hostId]: null + } + } + : {}) + })) + } + return ok + } catch { + return false + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-remote-close.ts b/src/renderer/src/store/slices/browser/browser-remote-close.ts new file mode 100644 index 00000000000..1bc03c1257f --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-remote-close.ts @@ -0,0 +1,33 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isBrowserPageDefinitivelyGone } from '@/runtime/client-hosted-browser-close-intent-replay' +import { isDurableClientHostedBrowserHandle } from '@/runtime/client-hosted-browser-close-intents' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { PendingClientHostedBrowserClose } from '@/runtime/client-hosted-browser-close-intents' +import type { RemoteBrowserPageHandle } from './browser-slice-contract' + +export function closeRemoteBrowserPageInOwningEnvironment( + worktreeId: string, + handle: RemoteBrowserPageHandle, + // Why a callback and not the store: this is module scope, and the recording action lives on the + // slice this file is still defining. + recordCloseIntents: (closes: readonly PendingClientHostedBrowserClose[]) => void +): void { + const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } + void callRuntimeRpc( + target, + 'browser.tabClose', + { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, + { timeoutMs: 15_000 } + ).catch((error) => { + // A close the runtime never heard is a resurrection waiting to happen: the runtime persists + // client-hosted pages, so a durable intent replays this same RPC on the next reconnect. + // A definitive page-unknown answer means there is nothing left to resurrect. + if (!isDurableClientHostedBrowserHandle(handle) || isBrowserPageDefinitivelyGone(error)) { + return + } + recordCloseIntents([ + { environmentId: handle.environmentId, browserPageId: handle.remotePageId, worktreeId } + ]) + }) +} diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts new file mode 100644 index 00000000000..c5577aaf0c4 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -0,0 +1,262 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { + BrowserCertificateFailure, + BrowserCookieImportResult, + BrowserCookieImportSummary, + BrowserHistoryEntry, + BrowserLoadError, + BrowserPage, + BrowserPageDocLocation, + BrowserSessionProfile, + BrowserSessionProfileCreateOptions, + BrowserViewportPresetId, + BrowserWorkspace +} from '../../../../../shared/browser-workspace-types' +import type { WorkspaceSessionState } from '../../../../../shared/workspace-session-state-types' +import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' +import type { + BrowserAnnotationIntent, + BrowserPageAnnotation +} from '../../../../../shared/browser-grab-types' +import type { + BrowserPageConversionLeg, + BrowserPageConversionTarget +} from '../browser-page-conversion' +import type { RecentlyClosedTabPosition } from '../recently-closed-tabs' +import type { + ClientHostedBrowserCloseIntentsByEnvironment, + PendingClientHostedBrowserClose +} from '@/runtime/client-hosted-browser-close-intents' +import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { RuntimeBrowserPlacement } from '../../../../../shared/runtime-browser-placement' + +export type CreateBrowserTabOptions = { + activate?: boolean + browserPageId?: string + title?: string + sessionProfileId?: string | null + sessionPartition?: string | null + // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. + targetGroupId?: string + // Explicit "New Tab" focuses the address bar even with a real home URL; link-opened tabs leave it unset. + focusAddressBar?: boolean + browserRuntimeEnvironmentId?: string | null + /** Creates a page that shows a workspace document instead of a URL. */ + docLocation?: BrowserPageDocLocation +} + +export type CreateBrowserPageOptions = { + activate?: boolean + title?: string + browserRuntimeEnvironmentId?: string | null + /** Creates a page that shows a workspace document instead of a URL. */ + docLocation?: BrowserPageDocLocation +} + +export type BrowserTabPageState = { + title?: string + loading?: boolean + faviconUrl?: string | null + canGoBack?: boolean + canGoForward?: boolean + loadError?: BrowserLoadError | null +} + +export type SetBrowserPageUrlOptions = { + preserveLoadError?: boolean +} + +export type ClosedBrowserWorkspaceSnapshot = { + workspace: BrowserWorkspace + pages: BrowserPage[] + position?: RecentlyClosedTabPosition +} + +export type RemoteBrowserPageHandle = { + environmentId: string + remotePageId: string + placement?: RuntimeBrowserPlacement + /** Optimistically staged by this client; the host has not published the page yet. */ + staged?: true + /** + * This client expects to host the staged page itself. The real placement is minted host-side and + * only arrives with the snapshot, so the pane needs this to mount as the right kind of pane from + * the first frame instead of swapping components at adoption. + */ + stagedClientHosted?: true + /** + * Rebuilt at hydration from the persisted page row rather than observed from a host snapshot. The + * page id is real, but nothing has confirmed the host still has it, so the row is exempt from the + * absent-from-snapshot cull until the first snapshot that publishes it clears this. + */ + restoredFromSession?: true + /** The restored page was hosted by this desktop, so it must not restore as a streamed pane. */ + restoredClientHosted?: true +} + +export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & { + executionHostId: ExecutionHostId + executionHostLabel: string + // Why: for a remote environment the import silently runs on either machine; toasts must say which. + executionMachine: 'client' | 'remote' + executionRemoteEnvironment: boolean +} + +export type BrowserSlice = { + browserTabsByWorktree: Record + browserPagesByWorkspace: Record + browserCertificateFailuresByPageId: Record + browserAnnotationsByPageId: Record + remoteBrowserPageHandlesByPageId: Record + /** + * Closes of client-hosted pages their owning runtime never heard, keyed by environment. + * + * Durable because the runtime persists the pages themselves: a close swallowed while the host + * was down would otherwise be undone by that host's next start. + */ + clientHostedBrowserCloseIntentsByEnvironment: ClientHostedBrowserCloseIntentsByEnvironment + recordClientHostedBrowserCloseIntents: ( + closes: readonly PendingClientHostedBrowserClose[] + ) => void + clearClientHostedBrowserCloseIntents: ( + environmentId: string, + browserPageIds: readonly string[] + ) => void + activeBrowserTabId: string | null + activeBrowserTabIdByWorktree: Record + recentlyClosedBrowserTabsByWorktree: Record + recentlyClosedBrowserPagesByWorkspace: Record + pendingAddressBarFocusByTabId: Record + pendingAddressBarFocusByPageId: Record + createBrowserTab: ( + worktreeId: string, + url: string, + options?: CreateBrowserTabOptions + ) => BrowserWorkspace + openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise + /** `profileId: null` uses the workspace default profile. */ + openBrowserProfileTabInActiveWorkspace: ( + url: string, + profileId: string | null + ) => Promise + closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void + shutdownWorktreeBrowsers: (worktreeId: string) => Promise + reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null + setActiveBrowserTab: (tabId: string) => void + createBrowserPage: ( + workspaceId: string, + url: string, + options?: CreateBrowserPageOptions + ) => BrowserPage | null + closeBrowserPage: (pageId: string) => void + // Why replacement and not mutation: a page's kind (doc vs web) is immutable for its life, so the + // address bar converts by replacing the page — fresh id, same workspace row — keeping the two + // main-process registry halves disjoint by construction. + convertBrowserPage: ( + pageId: string, + target: BrowserPageConversionTarget, + options?: { leg?: BrowserPageConversionLeg } + ) => BrowserPage | null + reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null + setActiveBrowserPage: (workspaceId: string, pageId: string) => void + // Focus that never yanks the user across worktrees: per-worktree slots always update, globals only when targeting the active worktree. + focusBrowserTabInWorktree: ( + worktreeId: string, + browserPageId: string, + options?: { surfacePane?: boolean } + ) => void + consumeAddressBarFocusRequest: (pageId: string) => boolean + updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void + updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void + setBrowserPageCertificateFailure: ( + pageId: string, + failure: BrowserCertificateFailure | null + ) => void + setBrowserTabUrl: (pageId: string, url: string) => void + setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void + setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void + removeRemoteBrowserPageHandle: ( + pageId: string, + remotePageId?: string + ) => RemoteBrowserPageHandle | null + setBrowserPageViewportPreset: ( + pageId: string, + viewportPresetId: BrowserViewportPresetId | null + ) => void + addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void + updateBrowserPageAnnotation: ( + pageId: string, + annotationId: string, + patch: { comment: string; intent: BrowserAnnotationIntent } + ) => void + deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void + clearBrowserPageAnnotations: (pageId: string) => void + hydrateBrowserSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void + switchBrowserTabProfile: ( + workspaceId: string, + profileId: string | null, + sessionPartition?: string | null + ) => void + browserSessionProfiles: BrowserSessionProfile[] + browserSessionProfilesByHostId: Partial> + browserSessionHostIdOverride: ExecutionHostId | null + setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise + browserSessionImportState: { + profileId: string + status: 'idle' | 'importing' | 'success' | 'error' + summary: BrowserCookieImportSummary | null + error: string | null + } | null + fetchBrowserSessionProfiles: () => Promise + createBrowserSessionProfile: ( + scope: 'isolated' | 'imported', + label: string, + options?: BrowserSessionProfileCreateOptions + ) => Promise + deleteBrowserSessionProfile: (profileId: string) => Promise + importCookiesToProfile: (profileId: string) => Promise + clearBrowserSessionImportState: () => void + detectedBrowsers: { + family: string + label: string + profiles: { name: string; directory: string }[] + selectedProfile: string + }[] + detectedBrowsersLoaded: boolean + // Why: which machine answered detection for a remote environment, so import menus can say where + // the import will read and store; null while browser settings target the local host. + detectedBrowsersHost: { machine: 'client' | 'remote'; hostLabel: string } | null + fetchDetectedBrowsers: () => Promise + importCookiesFromBrowser: ( + profileId: string, + browserFamily: string, + browserProfile?: string + ) => Promise + clearDefaultSessionCookies: () => Promise + browserUrlHistory: BrowserHistoryEntry[] + addBrowserHistoryEntry: (url: string, title: string) => void + workspaceDocHistory: WorkspaceDocHistoryEntry[] + /** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */ + recordWorkspaceDocVisit: ( + docLocation: BrowserPageDocLocation, + title: string | null, + options?: { bump?: boolean } + ) => void + clearBrowserHistory: () => void + defaultBrowserSessionProfileId: string | null + defaultBrowserSessionProfileIdByHostId: Partial> + setDefaultBrowserSessionProfileId: (profileId: string | null) => void +} + +type BrowserStateCreator = StateCreator +export type BrowserSliceSet = Parameters[0] +export type BrowserSliceGet = Parameters[1] + +// Keep the public slice surface's domain types available to existing consumers while the +// implementation is split across focused modules. +export type { BrowserPageConversionLeg, BrowserPageConversionTarget, BrowserSessionProfile } diff --git a/src/renderer/src/store/slices/browser/browser-tab-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-actions.ts new file mode 100644 index 00000000000..e388716e405 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -0,0 +1,253 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import { translate } from '@/i18n/i18n' +import { + FLOATING_TERMINAL_WORKTREE_ID, + ORCA_BROWSER_BLANK_URL +} from '../../../../../shared/constants' +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { + assertManagedBrowserMaterializationAllowed, + getClientCreationActionPolicy +} from '@/lib/client-creation-action-policy' +import { + buildBrowserPage, + buildWorkspaceFromPage, + findPage, + findWorkspace +} from '../browser-page-records' +import { getBrowserSessionProfileHostId } from './browser-host-state' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function createBrowserTabActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'createBrowserTab' + | 'openNewBrowserTabInActiveWorkspace' + | 'openBrowserProfileTabInActiveWorkspace' +> { + return { + createBrowserTab: (worktreeId, url, options) => { + assertManagedBrowserMaterializationAllowed(get(), options?.browserRuntimeEnvironmentId) + const workspaceId = createBrowserUuid() + const browserPageId = options?.browserPageId + if ( + browserPageId && + (findWorkspace(get().browserTabsByWorktree, browserPageId) || + findPage(get().browserPagesByWorkspace, browserPageId)) + ) { + throw new Error(`Browser page ${browserPageId} already exists`) + } + const page = buildBrowserPage( + workspaceId, + worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId, + browserPageId, + options?.docLocation + ) + // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. + const sessionProfileId = + options?.sessionProfileId !== undefined + ? options.sessionProfileId + : (get().defaultBrowserSessionProfileIdByHostId[ + getBrowserSessionProfileHostId( + get(), + worktreeId, + options?.browserRuntimeEnvironmentId + ) + ] ?? get().defaultBrowserSessionProfileId) + const browserTab = buildWorkspaceFromPage( + workspaceId, + worktreeId, + page, + [page.id], + sessionProfileId, + options?.sessionPartition + ) + + set((s) => { + const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] + const nextTabBarOrder = (() => { + const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] + const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = s.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((file) => file.id) + const browserIds = existingTabs.map((tab) => tab.id) + const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) + const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) + const inBase = new Set(base) + for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { + if (!inBase.has(entryId)) { + base.push(entryId) + inBase.add(entryId) + } + } + base.push(workspaceId) + return base + })() + + const shouldActivate = options?.activate ?? true + const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId + const shouldFocusFloatingTab = + shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const shouldFocusAddressBar = + (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && + // Why the doc check and not just the url: a document page is blank by construction, and + // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. + !page.docLocation && + (options?.focusAddressBar ?? + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) + + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: [...existingTabs, browserTab] + }, + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: [page] + }, + tabBarOrderByWorktree: { + ...s.tabBarOrderByWorktree, + [worktreeId]: nextTabBarOrder + }, + activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: shouldActivate + ? workspaceId + : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) + }, + activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, + activeTabTypeByWorktree: shouldActivate + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } + : s.activeTabTypeByWorktree, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [workspaceId]: true, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } + }) + + const state = get() + const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === workspaceId + ) + if (!alreadyHasUnifiedTab) { + state.createUnifiedTab(worktreeId, 'browser', { + entityId: workspaceId, + label: browserTab.title, + targetGroupId: options?.targetGroupId, + activate: options?.activate ?? true + }) + } + return browserTab + }, + + openNewBrowserTabInActiveWorkspace: async (groupId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + throw new Error(browserAvailability.reason) + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (browserAvailability.provider === 'paired-runtime') { + if (!runtimeEnvironmentId) { + throw new Error('The paired runtime browser provider is unavailable.') + } + const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') + try { + const created = await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url: defaultUrl, + // Why: desktop pane groups are client-owned — the local reconciler only honors a + // recorded clientTargetGroupId; targetGroupId steers snapshot-driven clients instead. + targetGroupId: groupId, + clientTargetGroupId: groupId + }) + if (created) { + get().recordFeatureInteraction('browser-tab-created') + return + } + } catch (error) { + // Why: browser.headless.v1 remotes succeed above, so a failure here is real; surface it instead of a confusing local-tab fallback (split ownership). + console.warn( + '[browser] remote browser tab creation failed:', + error instanceof Error ? error.message : String(error) + ) + throw error + } + throw new Error('The paired runtime could not create a managed browser tab.') + } + get().createBrowserTab(worktreeId, defaultUrl, { + title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}), + targetGroupId: groupId + }) + get().recordFeatureInteraction('browser-tab-created') + }, + + openBrowserProfileTabInActiveWorkspace: async (url, profileId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return false + } + const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + return false + } + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (browserAvailability.provider === 'paired-runtime') { + if (!runtimeEnvironmentId) { + return false + } + const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') + try { + return await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url, + profileId + }) + } catch (error) { + console.warn( + '[browser] remote profile tab creation failed:', + error instanceof Error ? error.message : String(error) + ) + return false + } + } + get().createBrowserTab(worktreeId, url, { + activate: true, + sessionProfileId: profileId, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + return true + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts new file mode 100644 index 00000000000..53ba2faae2e --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts @@ -0,0 +1,141 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { ClosedBrowserWorkspaceSnapshot } from './browser-slice-contract' +import { findWorkspace } from '../browser-page-records' +import { restoreRecentlyClosedTabPosition } from '../recently-closed-tabs' +import { isLocalBrowserPageOwner } from './browser-host-state' + +export function createBrowserTabFocusActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + reopenClosedBrowserTab: (worktreeId) => { + // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same entry (TOCTOU). + let entryToRestore: ClosedBrowserWorkspaceSnapshot | undefined + + set((s) => { + const recentlyClosed = s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] + entryToRestore = recentlyClosed[0] + if (!entryToRestore) { + return s + } + return { + recentlyClosedBrowserTabsByWorktree: { + ...s.recentlyClosedBrowserTabsByWorktree, + [worktreeId]: recentlyClosed.slice(1) + } + } + }) + + if (!entryToRestore) { + return null + } + + const snap = entryToRestore.workspace + const pages = entryToRestore.pages + const sessionProfileId = snap.sessionProfileId ?? null + const sessionPartition = snap.sessionPartition ?? null + + if (pages.length === 0) { + const restored = get().createBrowserTab(worktreeId, snap.url, { + title: snap.title, + activate: true, + sessionProfileId, + sessionPartition, + ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), + targetGroupId: entryToRestore.position?.groupId + }) + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) + return ( + get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + ) + } + + // Why: append remaining pages in original order so multi-page workspaces preserve their page sequence. + const [firstPage, ...restPages] = pages + const restored = get().createBrowserTab(worktreeId, firstPage.url, { + title: firstPage.title, + activate: true, + sessionProfileId, + sessionPartition, + targetGroupId: entryToRestore.position?.groupId, + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, + ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) + }) + + for (const p of restPages) { + get().createBrowserPage(restored.id, p.url, { + activate: false, + title: p.title, + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, + ...(p.docLocation ? { docLocation: p.docLocation } : {}) + }) + } + + // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. + const activePageId = snap.activePageId + if (activePageId) { + const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] + const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) + const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null + if (targetPage && targetPage.id !== restoredPages[0]?.id) { + get().setActiveBrowserPage(restored.id, targetPage.id) + } + } + + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) + + return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + }, + + setActiveBrowserTab: (tabId) => { + set((s) => { + const browserTab = findWorkspace(s.browserTabsByWorktree, tabId) + if (!browserTab) { + return s + } + return { + activeBrowserTabId: tabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [browserTab.worktreeId]: tabId + }, + activeTabType: 'browser', + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [browserTab.worktreeId]: 'browser' + } + } + }) + + // Why: notify the CDP bridge of the active guest; it keys on page IDs not workspace IDs, so resolve the workspace's active page. + const workspace = findWorkspace(get().browserTabsByWorktree, tabId) + const activePage = workspace?.activePageId + ? (get().browserPagesByWorkspace[workspace.id] ?? []).find( + (page) => page.id === workspace.activePageId + ) + : undefined + if ( + workspace?.activePageId && + isLocalBrowserPageOwner( + get(), + workspace.worktreeId, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser + .notifyActiveTabChanged({ browserPageId: workspace.activePageId }) + .catch(() => {}) + } + + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === tabId) + if (item) { + get().activateTab(item.id) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/create-browser-slice.ts b/src/renderer/src/store/slices/browser/create-browser-slice.ts new file mode 100644 index 00000000000..13519fc5b3a --- /dev/null +++ b/src/renderer/src/store/slices/browser/create-browser-slice.ts @@ -0,0 +1,57 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { BrowserSlice } from './browser-slice-contract' +import { createBrowserHostActions } from './browser-host-actions' +import { createBrowserTabActions } from './browser-tab-actions' +import { createBrowserCloseActions } from './browser-close-actions' +import { createBrowserTabFocusActions } from './browser-tab-focus-actions' +import { createBrowserPageCreateActions } from './browser-page-create-actions' +import { createBrowserPageConversionActions } from './browser-page-conversion-actions' +import { createBrowserPageFocusActions } from './browser-page-focus-actions' +import { createBrowserPageStateActions } from './browser-page-state-actions' +import { createBrowserPageMetadataActions } from './browser-page-metadata-actions' +import { createBrowserHydrationActions } from './browser-hydration-actions' +import { createBrowserProfileListActions } from './browser-profile-list-actions' +import { createBrowserProfileImportActions } from './browser-profile-import-actions' +import { createBrowserCookieImportActions } from './browser-cookie-import-actions' +import { createBrowserHistoryActions } from './browser-history-actions' + +export const createBrowserSlice: StateCreator = (set, get) => ({ + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, + browserAnnotationsByPageId: {}, + remoteBrowserPageHandlesByPageId: {}, + clientHostedBrowserCloseIntentsByEnvironment: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + recentlyClosedBrowserTabsByWorktree: {}, + recentlyClosedBrowserPagesByWorkspace: {}, + pendingAddressBarFocusByTabId: {}, + pendingAddressBarFocusByPageId: {}, + browserSessionProfiles: [], + browserSessionProfilesByHostId: {}, + browserSessionHostIdOverride: null, + browserSessionImportState: null, + browserUrlHistory: [], + workspaceDocHistory: [], + defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: {}, + detectedBrowsers: [], + detectedBrowsersLoaded: false, + detectedBrowsersHost: null, + ...createBrowserHostActions(set, get), + ...createBrowserTabActions(set, get), + ...createBrowserCloseActions(set, get), + ...createBrowserTabFocusActions(set, get), + ...createBrowserPageCreateActions(set, get), + ...createBrowserPageConversionActions(set, get), + ...createBrowserPageFocusActions(set, get), + ...createBrowserPageStateActions(set, get), + ...createBrowserPageMetadataActions(set, get), + ...createBrowserHydrationActions(set, get), + ...createBrowserProfileListActions(set, get), + ...createBrowserProfileImportActions(set, get), + ...createBrowserCookieImportActions(set, get), + ...createBrowserHistoryActions(set, get) +}) diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 605b2c8edd5..d9f3fe44dcf 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -1,590 +1,29 @@ -/* eslint-disable max-lines -- Why: the Linear slice owns status, workspace - selection, issue caches, and optimistic patch propagation as one store - boundary so cache invalidation stays coherent. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { LinearIssue } from '../../../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectSummary -} from '../../../../shared/linear/project-types' -import type { - LinearCollectionResult, - LinearConnectionStatus, - LinearTeam, - LinearViewer, - LinearWorkspace, - LinearWorkspaceError, - LinearWorkspaceSelection -} from '../../../../shared/linear/workspace-types' -import type { CacheEntry } from '../github/cache-model' -import { clampLinearIssueListLimit } from '../../../../shared/linear/issue-read-limits' -import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' -import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' -import { - isLinearIssueAttributeFilterUnsupportedError, - linearConnect, - linearDisconnect, - linearDisconnectWorkspace, - linearListIssues, - linearSearchIssues, - linearSelectWorkspace, - linearStatus, - linearTestConnection -} from '@/runtime/runtime-linear-client' -import { linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' -import { - linearGetCustomView, - linearGetProject, - linearListCustomViewIssues, - linearListCustomViewProjects, - linearListCustomViews, - linearListProjectIssues, - linearListProjects, - linearListTeams -} from '@/runtime/runtime-linear-project-client' -import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' -import { translate } from '@/i18n/i18n' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../shared/task-source-context' -import { - canonicalizeLinearIssueAttributeFilter, - isEmptyLinearIssueAttributeFilter, - linearIssueAttributeFilterSignature, - type LinearIssueAttributeFilter -} from '../../../../shared/linear/issue-attribute-filter' +import { linearListInvalidationToken } from './linear/linear-cache' +import { createLinearConnectionActions } from './linear/linear-slice-connection-actions' +import { createLinearCustomViewDetailActions } from './linear/linear-custom-view-detail-actions' +import { createLinearCustomViewIssueActions } from './linear/linear-custom-view-issue-actions' +import { createLinearCustomViewListActions } from './linear/linear-custom-view-list-actions' +import { createLinearCustomViewProjectActions } from './linear/linear-custom-view-project-actions' +import { createLinearInvalidationActions } from './linear/linear-invalidation-actions' +import { createLinearIssueCacheActions } from './linear/linear-issue-cache-actions' +import { createLinearIssueDetailActions } from './linear/linear-issue-detail-actions' +import { createLinearIssueListActions } from './linear/linear-issue-list-actions' +import { createLinearProjectDetailActions } from './linear/linear-project-detail-actions' +import { createLinearProjectIssueActions } from './linear/linear-project-issue-actions' +import { createLinearProjectListActions } from './linear/linear-project-list-actions' +import { createLinearStatusActions } from './linear/linear-slice-status-actions' +import { createLinearTeamActions } from './linear/linear-team-actions' +import type { LinearSlice } from './linear/linear-slice-contract' -const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL -const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. -const MAX_CACHE_ENTRIES = 500 - -function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is CacheEntry { - return entry !== undefined && Date.now() - entry.fetchedAt < ttl -} - -function evictStaleEntries( - cache: Record>, - maxEntries = MAX_CACHE_ENTRIES -): Record> { - const keys = Object.keys(cache) - if (keys.length <= maxEntries) { - return cache - } - const sorted = keys.sort((a, b) => (cache[a]?.fetchedAt ?? 0) - (cache[b]?.fetchedAt ?? 0)) - const pruned: Record> = {} - for (const key of sorted.slice(sorted.length - maxEntries)) { - pruned[key] = cache[key] - } - return pruned -} - -function looksLikeAuthError(error: unknown): boolean { - const msg = error instanceof Error ? error.message : String(error) - return /authenticat|unauthorized|401/i.test(msg) -} - -type InflightLinearIssueRequest = { - promise: Promise - generation: number - contextKey: string - mutationGeneration: number -} - -function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] { - const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = record.message ?? String(error) - const status = - typeof record.status === 'number' - ? record.status - : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' - ? ((record.response as { status: number }).status as number) - : undefined - if (looksLikeAuthError(error)) { - return 'auth' - } - if (status === 429 || /rate/i.test(record.name ?? '')) { - return 'rate_limited' - } - if ((typeof status === 'number' && status >= 500) || /network/i.test(record.name ?? message)) { - return 'network' - } - return 'unknown' -} - -function workspaceErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -const inflightIssueRequests = new Map() -type InflightLinearListRequest = { - promise: Promise - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} -type InflightLinearPlainListRequest = { - promise: Promise> - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} -type InflightLinearCollectionRequest = { - promise: Promise> - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} -type InflightLinearDetailRequest = { - promise: Promise - force: boolean - contextKey: string - mutationGeneration: number -} - -const inflightSearchRequests = new Map() -const inflightListRequests = new Map() -type InflightLinearTeamRequest = { - promise: Promise - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} - -const inflightTeamRequests = new Map() -const inflightProjectRequests = new Map< - string, - InflightLinearCollectionRequest ->() -const inflightProjectDetailRequests = new Map< - string, - InflightLinearDetailRequest ->() -const inflightProjectIssueRequests = new Map>() -const inflightCustomViewRequests = new Map< - string, - InflightLinearCollectionRequest ->() -const inflightCustomViewDetailRequests = new Map< - string, - InflightLinearDetailRequest ->() -const inflightCustomViewIssueRequests = new Map< - string, - InflightLinearCollectionRequest ->() -const inflightCustomViewProjectRequests = new Map< - string, - InflightLinearCollectionRequest ->() -let inflightStatusRequest: { contextKey: string; promise: Promise } | null = null -let linearStatusReadGeneration = 0 -let linearMutationGeneration = 0 -let linearCacheGeneration = 0 - -function getSelectedWorkspaceId(status: LinearConnectionStatus): LinearWorkspaceSelection | null { - return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null -} - -function linearSearchCacheKey( - workspaceId: LinearWorkspaceSelection | null | undefined, - query: string, - limit: number -): string { - return `${workspaceId ?? 'default'}::search::${query}::${limit}` -} - -function linearListCacheKey( - workspaceId: LinearWorkspaceSelection | null | undefined, - filter: 'assigned' | 'created' | 'all' | 'completed', - limit: number, - attributeFilter?: LinearIssueAttributeFilter | null -): string { - const attributeSignature = linearIssueAttributeFilterSignature(attributeFilter) - return `${workspaceId ?? 'default'}::list::${filter}::${limit}::${attributeSignature}` -} - -// Why: facet mutations need one source-scoped token TaskPage can observe without -// scattering membership guesses through board/detail components. -const LINEAR_LIST_INVALIDATION_VERSION_CAP = 10_000 -let linearListInvalidationToken: { scope: string; version: number } = { - scope: '', - version: 0 -} - -function linearTeamsCacheKey(workspaceId: LinearWorkspaceSelection | null | undefined): string { - return `${workspaceId ?? 'default'}::teams` -} - -function linearWorkspaceSignature(workspace: LinearWorkspace): string { - return [ - workspace.id, - workspace.organizationId, - workspace.organizationName, - workspace.organizationUrlKey ?? '', - workspace.displayName, - workspace.email ?? '', - workspace.credentialRevision ?? 0 - ].join('\u001f') -} - -/** Cache-invalidation key: broader than `linearWorkspaceScopeSignature`, hashes full viewer and workspace metadata. */ -function linearStatusScopeSignature(status: LinearConnectionStatus): string { - return JSON.stringify({ - connected: status.connected, - credentialError: status.credentialError ?? null, - activeWorkspaceId: status.activeWorkspaceId ?? null, - selectedWorkspaceId: getSelectedWorkspaceId(status), - viewer: status.viewer - ? [ - status.viewer.organizationId ?? '', - status.viewer.organizationName, - status.viewer.organizationUrlKey ?? '', - status.viewer.displayName, - status.viewer.email ?? '' - ] - : null, - workspaces: (status.workspaces ?? []).map(linearWorkspaceSignature) - }) -} - -function clearLinearRequestMaps(): void { - inflightIssueRequests.clear() - inflightSearchRequests.clear() - inflightListRequests.clear() - inflightTeamRequests.clear() - inflightProjectRequests.clear() - inflightProjectDetailRequests.clear() - inflightProjectIssueRequests.clear() - inflightCustomViewRequests.clear() - inflightCustomViewDetailRequests.clear() - inflightCustomViewIssueRequests.clear() - inflightCustomViewProjectRequests.clear() -} - -function invalidateLinearCaches(): void { - linearCacheGeneration += 1 - clearLinearRequestMaps() - clearLinearMetadataCache() -} - -function clearLinearIssueCollectionRequestMaps(): void { - inflightSearchRequests.clear() - inflightListRequests.clear() - inflightProjectIssueRequests.clear() - inflightCustomViewIssueRequests.clear() -} - -function shouldRefreshStatusAfterRead( - workspaceId: LinearWorkspaceSelection | null | undefined, - status: LinearConnectionStatus -): boolean { - // Why: 'all' reads can hide per-workspace decrypt failures, and a visible - // credential error may have been cleared by a successful credential read. - return workspaceId === 'all' || status.credentialError !== undefined -} - -function linearCollectionCacheKey( - workspaceId: LinearWorkspaceSelection | null | undefined, - mode: string, - ...parts: (string | number | null | undefined)[] -): string { - return [workspaceId ?? 'default', mode, ...parts.map((part) => part ?? '')].join('::') -} - -function emptyLinearCollection(): LinearCollectionResult { - return { items: [] } -} - -function collectionWithWorkspaceError( - fallback: LinearCollectionResult, - workspaceId: string, - error: unknown -): LinearCollectionResult { - const existingErrors = (fallback.errors ?? []).filter((item) => item.workspaceId !== workspaceId) - return { - ...fallback, - errors: [ - ...existingErrors, - { - workspaceId, - type: workspaceErrorType(error), - message: workspaceErrorMessage(error) || 'Linear request failed.' - } - ] - } -} - -function largestCachedCollectionBelowLimit( - cache: Record>>, - workspaceId: LinearWorkspaceSelection | null | undefined, - mode: string, - scopeId: string, - limit: number -): LinearCollectionResult | null { - const keyPrefix = `${linearCollectionCacheKey(workspaceId, mode, scopeId)}::` - let best: { limit: number; data: LinearCollectionResult } | null = null - for (const [key, entry] of Object.entries(cache)) { - if (!entry?.data || !key.startsWith(keyPrefix)) { - continue - } - const cachedLimit = Number(key.slice(keyPrefix.length)) - if (!Number.isFinite(cachedLimit) || cachedLimit >= limit) { - continue - } - if (!best || cachedLimit > best.limit) { - best = { limit: cachedLimit, data: entry.data } - } - } - return best?.data ?? null -} - -function patchLinearIssueCollectionCache( - cache: Record>>, - issueId: string, - patch: Partial, - canPatchCacheKey: (key: string) => boolean -): { - cache: Record>> - changed: boolean -} { - let changed = false - const nextCache = { ...cache } - for (const [key, entry] of Object.entries(nextCache)) { - if (!canPatchCacheKey(key) || !entry?.data) { - continue - } - const idx = entry.data.items.findIndex((item) => item.id === issueId) - if (idx === -1) { - continue - } - const updatedItems = [...entry.data.items] - updatedItems[idx] = { ...updatedItems[idx], ...patch } - nextCache[key] = { - ...entry, - data: { ...entry.data, items: updatedItems } - } - changed = true - } - return { cache: nextCache, changed } -} - -export type LinearIssueListReadArgs = { - kind: 'list' - filter?: 'assigned' | 'created' | 'all' | 'completed' - limit?: number - attributeFilter?: LinearIssueAttributeFilter | null -} - -export type LinearIssueReadArgs = - | { kind: 'search'; query: string; limit?: number } - | LinearIssueListReadArgs - -type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } -type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } - -function normalizeListAttributeFilter( - attributeFilter?: LinearIssueAttributeFilter | null -): LinearIssueAttributeFilter | undefined { - if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) { - return undefined - } - return canonicalizeLinearIssueAttributeFilter(attributeFilter) -} - -type LinearReadScope = { - settings: AppState['settings'] | TaskSourceContext | null - contextKey: string - cachePrefix: string | null - explicitSource: boolean -} - -function beginLinearMutation(): number { - linearMutationGeneration += 1 - inflightStatusRequest = null - return linearMutationGeneration -} - -function isCurrentLinearMutation(generation: number): boolean { - return generation === linearMutationGeneration -} - -function isCurrentLinearRuntimeContext( - contextKey: string, - settings: AppState['settings'] -): boolean { - return getProviderRuntimeContextKey(settings) === contextKey -} - -function canWriteLinearReadResult( - contextKey: string, - generation: number, - mutationGeneration: number, - settings: AppState['settings'], - explicitSource = false -): boolean { - return ( - generation === linearCacheGeneration && - mutationGeneration === linearMutationGeneration && - (explicitSource || isCurrentLinearRuntimeContext(contextKey, settings)) - ) -} - -function getLinearReadScope( - settings: AppState['settings'], - sourceContext?: TaskSourceContext | null -): LinearReadScope { - if (!sourceContext) { - return { - settings, - contextKey: getProviderRuntimeContextKey(settings), - cachePrefix: null, - explicitSource: false - } - } - const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) - return { - settings: sourceContext, - contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${getTaskSourceCacheScope(sourceContext)}`, - cachePrefix: getTaskSourceCacheScope(sourceContext), - explicitSource: true - } -} - -function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { - return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key -} - -export type LinearSlice = { - linearStatus: LinearConnectionStatus - linearStatusChecked: boolean - linearStatusContextKey: string | null - linearIssueCache: Record> - linearSearchCache: Record> - linearListCache: Record>> - linearTeamCache: Record> - linearProjectCache: Record>> - linearProjectDetailCache: Record> - linearProjectIssueCache: Record>> - linearCustomViewCache: Record>> - linearCustomViewDetailCache: Record> - linearCustomViewIssueCache: Record>> - linearCustomViewProjectCache: Record< - string, - CacheEntry> - > - - checkLinearConnection: (force?: boolean) => Promise - connectLinear: ( - apiKey: string - ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - testLinearConnection: ( - workspaceId?: string | null - ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise - disconnectLinear: () => Promise - disconnectLinearWorkspace: (workspaceId: string) => Promise - fetchLinearIssue: ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => Promise - refreshLinearIssue: ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => Promise - getCachedLinearIssues: ( - args: LinearIssueReadArgs, - options?: Pick - ) => LinearIssue[] | LinearCollectionResult | null - prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => void - searchLinearIssues: ( - query: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise - listLinearIssues: ( - args: LinearIssueListReadArgs, - options?: LinearFetchOptions - ) => Promise> - linearListInvalidationToken: { scope: string; version: number } - invalidateLinearIssueLists: (options?: Pick) => void - getCachedLinearTeams: ( - workspaceId?: LinearWorkspaceSelection | null, - options?: Pick - ) => LinearTeam[] | null - listLinearTeams: ( - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearFetchOptions - ) => Promise - getCachedLinearProjects: ( - query?: string, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: Pick - ) => LinearCollectionResult | null - listLinearProjects: ( - query?: string, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearFetchOptions - ) => Promise> - fetchLinearProject: ( - id: string, - workspaceId: string, - options?: LinearFetchOptions - ) => Promise - listLinearProjectIssues: ( - projectId: string, - workspaceId: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise> - getCachedLinearCustomViews: ( - model: LinearCustomViewModel, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: Pick - ) => LinearCollectionResult | null - listLinearCustomViews: ( - model: LinearCustomViewModel, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearFetchOptions - ) => Promise> - fetchLinearCustomView: ( - viewId: string, - workspaceId: string, - model: LinearCustomViewModel, - options?: LinearFetchOptions - ) => Promise - listLinearCustomViewIssues: ( - viewId: string, - workspaceId: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise> - listLinearCustomViewProjects: ( - viewId: string, - workspaceId: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise> - patchLinearIssue: ( - issueId: string, - patch: Partial, - options?: LinearPatchOptions - ) => void -} +export type { + LinearFetchOptions, + LinearIssueListReadArgs, + LinearIssueReadArgs, + LinearPatchOptions, + LinearSlice +} from './linear/linear-slice-contract' export const createLinearSlice: StateCreator = (set, get) => ({ linearStatus: { connected: false, viewer: null }, @@ -601,1646 +40,19 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearListInvalidationToken: linearListInvalidationToken, - - checkLinearConnection: async (force = false) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { - return inflightStatusRequest.promise - } - if (get().linearStatusContextKey !== contextKey && get().linearStatusChecked) { - set({ linearStatusChecked: false }) - } - - const mutationGeneration = linearMutationGeneration - const statusReadGeneration = (linearStatusReadGeneration += 1) - const request = linearStatus(get().settings) - .then((status) => { - if ( - mutationGeneration !== linearMutationGeneration || - statusReadGeneration !== linearStatusReadGeneration || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - const typedStatus = status as LinearConnectionStatus - const prev = get().linearStatus - const prevScopeSignature = linearStatusScopeSignature(prev) - const nextScopeSignature = linearStatusScopeSignature(typedStatus) - if (prevScopeSignature !== nextScopeSignature) { - invalidateLinearCaches() - set({ - linearStatus: typedStatus, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) - } else if (get().linearStatusContextKey !== contextKey) { - set({ linearStatusContextKey: contextKey }) - } - }) - .catch(() => { - if ( - mutationGeneration !== linearMutationGeneration || - statusReadGeneration !== linearStatusReadGeneration || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - if (get().linearStatus.connected) { - invalidateLinearCaches() - set({ - linearStatus: { connected: false, viewer: null }, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) - } else if (get().linearStatusContextKey !== contextKey) { - set({ linearStatusContextKey: contextKey }) - } - }) - .finally(() => { - if ( - statusReadGeneration === linearStatusReadGeneration && - inflightStatusRequest?.promise === request - ) { - inflightStatusRequest = null - } - }) - inflightStatusRequest = { contextKey, promise: request } - - return request - }, - - testLinearConnection: async (workspaceId) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - try { - const result = (await linearTestConnection(get().settings, workspaceId)) as - | { ok: true; viewer: LinearViewer } - | { ok: false; error: string } - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return result - } - const status = await linearStatus(get().settings) - if ( - isCurrentLinearMutation(requestGeneration) && - isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - const prev = get().linearStatus - if (linearStatusScopeSignature(prev) !== linearStatusScopeSignature(status)) { - invalidateLinearCaches() - set({ - linearStatus: status, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (!get().linearStatusChecked || get().linearStatusContextKey !== contextKey) { - // Preserve the status reference when the probe did not change scope. - set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) - } - } - return result - } catch (error) { - const message = error instanceof Error ? error.message : 'Test failed' - return { ok: false as const, error: message } - } - }, - - connectLinear: async (apiKey: string) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - try { - const result = await linearConnect(get().settings, apiKey) - if ( - result.ok && - isCurrentLinearMutation(requestGeneration) && - isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - invalidateLinearCaches() - set({ - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {} - }) - const status = await linearStatus(get().settings) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return { - ok: false as const, - error: translate( - 'auto.store.slices.linear.37d36984d0', - 'Linear connection was superseded by a newer request.' - ) - } - } - set({ - linearStatus: status, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (result.ok) { - return { - ok: false as const, - error: translate( - 'auto.store.slices.linear.37d36984d0', - 'Linear connection was superseded by a newer request.' - ) - } - } - return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } - } catch (error) { - const message = error instanceof Error ? error.message : 'Connection failed' - return { ok: false as const, error: message } - } - }, - - selectLinearWorkspace: async (workspaceId) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - const status = await linearSelectWorkspace(get().settings, workspaceId) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - invalidateLinearCaches() - set({ - linearStatus: status, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - }, - - disconnectLinear: async () => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - await linearDisconnect(get().settings) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - invalidateLinearCaches() - set({ - linearStatus: { connected: false, viewer: null }, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - }, - - disconnectLinearWorkspace: async (workspaceId) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - await linearDisconnectWorkspace(get().settings, workspaceId) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - const status = await linearStatus(get().settings) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - invalidateLinearCaches() - set({ - linearStatus: status, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - }, - - fetchLinearIssue: async ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) - const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] - if (isFresh(cached)) { - return cached.data - } - - const inflight = inflightIssueRequests.get(issueCacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration - ) { - return inflight.promise - } - - let entry: InflightLinearIssueRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearGetIssue(scope.settings, id, workspaceId) - .then((issue) => { - const data = issue as LinearIssue | null - if ( - inflightIssueRequests.get(issueCacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearIssueCache: evictStaleEntries({ - ...s.linearIssueCache, - [issueCacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] fetchLinearIssue failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - return null - }) - .finally(() => { - if (inflightIssueRequests.get(issueCacheKey) === entry) { - inflightIssueRequests.delete(issueCacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightIssueRequests.set(issueCacheKey, entry) - return promise - }, - - refreshLinearIssue: async ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) - inflightIssueRequests.delete(issueCacheKey) - clearLinearIssueCollectionRequestMaps() - set((s) => { - const nextIssueCache = { ...s.linearIssueCache } - for (const [key, entry] of Object.entries(nextIssueCache)) { - if ( - key === issueCacheKey || - key === id || - entry?.data?.id === id || - entry?.data?.identifier === id - ) { - delete nextIssueCache[key] - } - } - return { - linearIssueCache: nextIssueCache, - linearSearchCache: {}, - linearListCache: {}, - linearProjectIssueCache: {}, - linearCustomViewIssueCache: {} - } - }) - return get().fetchLinearIssue(id, workspaceId, options) - }, - - getCachedLinearIssues: (args, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - if (args.kind === 'search') { - const cacheKey = scopedLinearCacheKey( - scope, - linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) - ) - return get().linearSearchCache[cacheKey]?.data ?? null - } - const limit = clampLinearIssueListLimit(args.limit) - const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) - const cacheKey = scopedLinearCacheKey( - scope, - linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) - ) - return get().linearListCache[cacheKey]?.data ?? null - }, - - prefetchLinearIssues: (args, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - if (args.kind === 'search') { - const limit = args.limit ?? 20 - const cacheKey = scopedLinearCacheKey( - scope, - linearSearchCacheKey(workspaceId, args.query, limit) - ) - const inflight = inflightSearchRequests.get(cacheKey) - if ( - isFresh(get().linearSearchCache[cacheKey]) || - (inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration) - ) { - return - } - void get() - .searchLinearIssues(args.query, limit, options) - .catch(() => {}) - return - } - const limit = clampLinearIssueListLimit(args.limit) - const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) - const listArgs: LinearIssueListReadArgs = { - kind: 'list', - filter: args.filter, - limit, - attributeFilter - } - const cacheKey = scopedLinearCacheKey( - scope, - linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) - ) - const inflight = inflightListRequests.get(cacheKey) - if ( - isFresh(get().linearListCache[cacheKey]) || - (inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration) - ) { - return - } - void get() - .listLinearIssues(listArgs, options) - .catch(() => {}) - }, - - searchLinearIssues: async (query: string, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const cacheKey = scopedLinearCacheKey(scope, linearSearchCacheKey(workspaceId, query, limit)) - const cached = get().linearSearchCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? [] - } - - const inflight = inflightSearchRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearListRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearSearchIssues(scope.settings, query, limit, workspaceId) - .then((issues) => { - const data = issues as LinearIssue[] - if ( - inflightSearchRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearSearchCache: evictStaleEntries({ - ...s.linearSearchCache, - [cacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] searchLinearIssues failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { - void get().checkLinearConnection(true) - } - return [] - } - return get().linearSearchCache[cacheKey]?.data ?? [] - }) - .finally(() => { - if (inflightSearchRequests.get(cacheKey) === entry) { - inflightSearchRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightSearchRequests.set(cacheKey, entry) - return promise - }, - - listLinearIssues: async (args, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const filter = args.filter ?? 'assigned' - const effectiveLimit = clampLinearIssueListLimit(args.limit) - const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) - const cacheKey = scopedLinearCacheKey( - scope, - linearListCacheKey(workspaceId, filter, effectiveLimit, attributeFilter) - ) - const cached = get().linearListCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightListRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearPlainListRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise: Promise> = linearListIssues( - scope.settings, - filter, - effectiveLimit, - workspaceId, - attributeFilter - ) - .then((result) => { - const data = result as LinearCollectionResult - if ( - inflightListRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearListCache: evictStaleEntries({ - ...s.linearListCache, - [cacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] listLinearIssues failed:', error) - // Why: capability mismatch is actionable (update remote runtime). Swallowing - // it as [] would look like "no issues match filters" and hide the fix. - if (isLinearIssueAttributeFilterUnsupportedError(error)) { - throw error - } - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { - void get().checkLinearConnection(true) - } - return emptyLinearCollection() - } - return get().linearListCache[cacheKey]?.data ?? emptyLinearCollection() - }) - .finally(() => { - if (inflightListRequests.get(cacheKey) === entry) { - inflightListRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightListRequests.set(cacheKey, entry) - return promise - }, - - getCachedLinearTeams: (workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const key = linearTeamsCacheKey(workspaceId ?? getSelectedWorkspaceId(get().linearStatus)) - return get().linearTeamCache[scopedLinearCacheKey(scope, key)]?.data ?? null - }, - - listLinearTeams: async (workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = scopedLinearCacheKey(scope, linearTeamsCacheKey(resolvedWorkspaceId)) - const cached = get().linearTeamCache[cacheKey] - if (!options?.force && isFresh(cached, TEAM_CACHE_TTL)) { - return cached.data ?? [] - } - - const inflight = inflightTeamRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearTeamRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListTeams(scope.settings, resolvedWorkspaceId) - .then((teams) => { - const data = teams as LinearTeam[] - if ( - inflightTeamRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearTeamCache: evictStaleEntries({ - ...s.linearTeamCache, - [cacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] listLinearTeams failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { - void get().checkLinearConnection(true) - } - return [] - } - return get().linearTeamCache[cacheKey]?.data ?? [] - }) - .finally(() => { - if (inflightTeamRequests.get(cacheKey) === entry) { - inflightTeamRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightTeamRequests.set(cacheKey, entry) - return promise - }, - - getCachedLinearProjects: (query, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', query?.trim(), limit) - return get().linearProjectCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null - }, - - listLinearProjects: async (query, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const trimmed = query?.trim() || undefined - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) - ) - const cached = get().linearProjectCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightProjectRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListProjects(scope.settings, trimmed, limit, resolvedWorkspaceId, { - force: options?.force - }) - .then((result) => { - if ( - inflightProjectRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearProjectCache: evictStaleEntries({ - ...s.linearProjectCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearProjects failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearProjectCache[cacheKey]?.data ?? emptyLinearCollection() - return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) - }) - .finally(() => { - if (inflightProjectRequests.get(cacheKey) === entry) { - inflightProjectRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightProjectRequests.set(cacheKey, entry) - return promise - }, - - fetchLinearProject: async (id, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'project-detail', id) - ) - const cached = get().linearProjectDetailCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data - } - - const inflight = inflightProjectDetailRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearDetailRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearGetProject(scope.settings, id, workspaceId, { - force: options?.force - }) - .then((project) => { - if ( - inflightProjectDetailRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearProjectDetailCache: evictStaleEntries({ - ...s.linearProjectDetailCache, - [cacheKey]: { data: project, fetchedAt: Date.now() } - }) - })) - } - return project - }) - .catch((error) => { - console.warn('[linear] fetchLinearProject failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - if (options?.force) { - throw error - } - const cachedResult = get().linearProjectDetailCache[cacheKey] - if (cachedResult) { - return cachedResult.data - } - throw error - }) - .finally(() => { - if (inflightProjectDetailRequests.get(cacheKey) === entry) { - inflightProjectDetailRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightProjectDetailRequests.set(cacheKey, entry) - return promise - }, - - listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'project-issues', projectId, effectiveLimit) - ) - const cached = get().linearProjectIssueCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightProjectIssueRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListProjectIssues( - scope.settings, - projectId, - effectiveLimit, - workspaceId, - { - force: options?.force - } - ) - .then((result) => { - if ( - inflightProjectIssueRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearProjectIssueCache: evictStaleEntries({ - ...s.linearProjectIssueCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearProjectIssues failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearProjectIssueCache[cacheKey]?.data ?? - largestCachedCollectionBelowLimit( - get().linearProjectIssueCache, - workspaceId, - 'project-issues', - projectId, - effectiveLimit - ) ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, workspaceId, error) - }) - .finally(() => { - if (inflightProjectIssueRequests.get(cacheKey) === entry) { - inflightProjectIssueRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightProjectIssueRequests.set(cacheKey, entry) - return promise - }, - - getCachedLinearCustomViews: (model, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) - return get().linearCustomViewCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null - }, - - listLinearCustomViews: async (model, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) - ) - const cached = get().linearCustomViewCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightCustomViewRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViews(scope.settings, model, limit, resolvedWorkspaceId, { - force: options?.force - }) - .then((result) => { - if ( - inflightCustomViewRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewCache: evictStaleEntries({ - ...s.linearCustomViewCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearCustomViews failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearCustomViewCache[cacheKey]?.data ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) - }) - .finally(() => { - if (inflightCustomViewRequests.get(cacheKey) === entry) { - inflightCustomViewRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewRequests.set(cacheKey, entry) - return promise - }, - - fetchLinearCustomView: async (viewId, workspaceId, model, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) - ) - const cached = get().linearCustomViewDetailCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data - } - - const inflight = inflightCustomViewDetailRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearDetailRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearGetCustomView(scope.settings, viewId, model, workspaceId, { - force: options?.force - }) - .then((view) => { - if ( - inflightCustomViewDetailRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewDetailCache: evictStaleEntries({ - ...s.linearCustomViewDetailCache, - [cacheKey]: { data: view, fetchedAt: Date.now() } - }) - })) - } - return view - }) - .catch((error) => { - console.warn('[linear] fetchLinearCustomView failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - if (options?.force) { - throw error - } - const cachedResult = get().linearCustomViewDetailCache[cacheKey] - if (cachedResult) { - return cachedResult.data - } - throw error - }) - .finally(() => { - if (inflightCustomViewDetailRequests.get(cacheKey) === entry) { - inflightCustomViewDetailRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewDetailRequests.set(cacheKey, entry) - return promise - }, - - listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, effectiveLimit) - ) - const cached = get().linearCustomViewIssueCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightCustomViewIssueRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViewIssues( - scope.settings, - viewId, - effectiveLimit, - workspaceId, - { - force: options?.force - } - ) - .then((result) => { - if ( - inflightCustomViewIssueRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewIssueCache: evictStaleEntries({ - ...s.linearCustomViewIssueCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearCustomViewIssues failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearCustomViewIssueCache[cacheKey]?.data ?? - largestCachedCollectionBelowLimit( - get().linearCustomViewIssueCache, - workspaceId, - 'custom-view-issues', - viewId, - effectiveLimit - ) ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, workspaceId, error) - }) - .finally(() => { - if (inflightCustomViewIssueRequests.get(cacheKey) === entry) { - inflightCustomViewIssueRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewIssueRequests.set(cacheKey, entry) - return promise - }, - - listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) - ) - const cached = get().linearCustomViewProjectCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightCustomViewProjectRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViewProjects(scope.settings, viewId, limit, workspaceId, { - force: options?.force - }) - .then((result) => { - if ( - inflightCustomViewProjectRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewProjectCache: evictStaleEntries({ - ...s.linearCustomViewProjectCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearCustomViewProjects failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearCustomViewProjectCache[cacheKey]?.data ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, workspaceId, error) - }) - .finally(() => { - if (inflightCustomViewProjectRequests.get(cacheKey) === entry) { - inflightCustomViewProjectRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewProjectRequests.set(cacheKey, entry) - return promise - }, - - invalidateLinearIssueLists: (options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const tokenScope = scope.cachePrefix ?? 'local' - const nextVersion = - linearListInvalidationToken.scope === tokenScope - ? (linearListInvalidationToken.version + 1) % LINEAR_LIST_INVALIDATION_VERSION_CAP || 1 - : 1 - linearListInvalidationToken = { scope: tokenScope, version: nextVersion } - - // Why: drop only attribute-filtered plain list entries in this source scope - // so the next TaskPage read is forced current without wiping search/unrelated - // collections. - set((s) => { - const nextListCache = { ...s.linearListCache } - let changed = false - for (const key of Object.keys(nextListCache)) { - const parts = key.split('::') - // Cache keys end with the attribute signature; unfiltered entries end empty. - const attributeSignature = parts.at(-1) ?? '' - if (!attributeSignature) { - continue - } - if (scope.cachePrefix) { - if (!key.startsWith(`${scope.cachePrefix}::`)) { - continue - } - } else if (parts[1] !== 'list') { - // Why: unscoped invalidation must not touch other runtimes' scoped list keys. - continue - } - delete nextListCache[key] - inflightListRequests.delete(key) - changed = true - } - if (!changed) { - return { linearListInvalidationToken: linearListInvalidationToken } - } - return { - linearListCache: nextListCache, - linearListInvalidationToken: linearListInvalidationToken - } - }) - }, - - patchLinearIssue: (issueId, patch, options) => { - const sourceScope = - options?.sourceContext?.provider === 'linear' - ? getTaskSourceCacheScope(options.sourceContext) - : null - const canPatchCacheKey = (key: string): boolean => - sourceScope === null || key.startsWith(`${sourceScope}::`) - set((s) => { - let changed = false - - const nextIssueCache = { ...s.linearIssueCache } - for (const [key, issueEntry] of Object.entries(nextIssueCache)) { - if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { - continue - } - // Why: set fetchedAt to 0 so the next fetchLinearIssue call - // actually hits IPC instead of returning the stale optimistic data. - nextIssueCache[key] = { - ...issueEntry, - data: { ...issueEntry.data, ...patch }, - fetchedAt: 0 - } - changed = true - } - - const nextSearchCache = { ...s.linearSearchCache } - for (const key of Object.keys(nextSearchCache)) { - const entry = nextSearchCache[key] - if (!canPatchCacheKey(key) || !entry?.data) { - continue - } - const idx = entry.data.findIndex((item) => item.id === issueId) - if (idx === -1) { - continue - } - const updatedItems = [...entry.data] - updatedItems[idx] = { ...updatedItems[idx], ...patch } - nextSearchCache[key] = { ...entry, data: updatedItems } - changed = true - } - - const nextListCache = patchLinearIssueCollectionCache( - s.linearListCache, - issueId, - patch, - canPatchCacheKey - ) - if (nextListCache.changed) { - changed = true - } - - const nextProjectIssueCache = patchLinearIssueCollectionCache( - s.linearProjectIssueCache, - issueId, - patch, - canPatchCacheKey - ) - if (nextProjectIssueCache.changed) { - changed = true - } - - const nextCustomViewIssueCache = patchLinearIssueCollectionCache( - s.linearCustomViewIssueCache, - issueId, - patch, - canPatchCacheKey - ) - if (nextCustomViewIssueCache.changed) { - changed = true - } - - return changed - ? { - linearIssueCache: nextIssueCache, - linearSearchCache: nextSearchCache, - linearListCache: nextListCache.changed ? nextListCache.cache : s.linearListCache, - linearProjectIssueCache: nextProjectIssueCache.changed - ? nextProjectIssueCache.cache - : s.linearProjectIssueCache, - linearCustomViewIssueCache: nextCustomViewIssueCache.changed - ? nextCustomViewIssueCache.cache - : s.linearCustomViewIssueCache - } - : {} - }) - } + linearListInvalidationToken, + ...createLinearStatusActions(set, get), + ...createLinearConnectionActions(set, get), + ...createLinearIssueDetailActions(set, get), + ...createLinearIssueCacheActions(set, get), + ...createLinearIssueListActions(set, get), + ...createLinearTeamActions(set, get), + ...createLinearProjectListActions(set, get), + ...createLinearProjectDetailActions(set, get), + ...createLinearProjectIssueActions(set, get), + ...createLinearCustomViewListActions(set, get), + ...createLinearCustomViewDetailActions(set, get), + ...createLinearCustomViewIssueActions(set, get), + ...createLinearCustomViewProjectActions(set, get), + ...createLinearInvalidationActions(set, get) }) diff --git a/src/renderer/src/store/slices/linear/linear-cache.ts b/src/renderer/src/store/slices/linear/linear-cache.ts new file mode 100644 index 00000000000..f77f96f70f2 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-cache.ts @@ -0,0 +1,234 @@ +import type { CacheEntry } from '../../github/cache-model' +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearCollectionResult, + LinearConnectionStatus, + LinearWorkspace, + LinearWorkspaceError, + LinearWorkspaceSelection +} from '../../../../../shared/linear/workspace-types' +import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' +import { linearIssueAttributeFilterSignature } from '../../../../../shared/linear/issue-attribute-filter' + +export const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL +export const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. +export const MAX_CACHE_ENTRIES = 500 + +export function isFresh( + entry: CacheEntry | undefined, + ttl = CACHE_TTL +): entry is CacheEntry { + return entry !== undefined && Date.now() - entry.fetchedAt < ttl +} + +export function evictStaleEntries( + cache: Record>, + maxEntries = MAX_CACHE_ENTRIES +): Record> { + const keys = Object.keys(cache) + if (keys.length <= maxEntries) { + return cache + } + const sorted = keys.sort((a, b) => (cache[a]?.fetchedAt ?? 0) - (cache[b]?.fetchedAt ?? 0)) + const pruned: Record> = {} + for (const key of sorted.slice(sorted.length - maxEntries)) { + pruned[key] = cache[key] + } + return pruned +} + +export function looksLikeAuthError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error) + return /authenticat|unauthorized|401/i.test(msg) +} + +export function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] { + const record = error as { name?: string; message?: string; status?: number; response?: unknown } + const message = record.message ?? String(error) + const status = + typeof record.status === 'number' + ? record.status + : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' + ? ((record.response as { status: number }).status as number) + : undefined + if (looksLikeAuthError(error)) { + return 'auth' + } + if (status === 429 || /rate/i.test(record.name ?? '')) { + return 'rate_limited' + } + if ((typeof status === 'number' && status >= 500) || /network/i.test(record.name ?? message)) { + return 'network' + } + return 'unknown' +} + +export function workspaceErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function getSelectedWorkspaceId( + status: LinearConnectionStatus +): LinearWorkspaceSelection | null { + return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null +} + +export function linearSearchCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined, + query: string, + limit: number +): string { + return `${workspaceId ?? 'default'}::search::${query}::${limit}` +} + +export function linearListCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined, + filter: 'assigned' | 'created' | 'all' | 'completed', + limit: number, + attributeFilter?: LinearIssueAttributeFilter | null +): string { + const attributeSignature = linearIssueAttributeFilterSignature(attributeFilter) + return `${workspaceId ?? 'default'}::list::${filter}::${limit}::${attributeSignature}` +} + +export const LINEAR_LIST_INVALIDATION_VERSION_CAP = 10_000 +export let linearListInvalidationToken: { scope: string; version: number } = { + scope: '', + version: 0 +} + +export function setLinearListInvalidationToken(next: { scope: string; version: number }): void { + linearListInvalidationToken = next +} + +export function linearTeamsCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined +): string { + return `${workspaceId ?? 'default'}::teams` +} + +function linearWorkspaceSignature(workspace: LinearWorkspace): string { + return [ + workspace.id, + workspace.organizationId, + workspace.organizationName, + workspace.organizationUrlKey ?? '', + workspace.displayName, + workspace.email ?? '', + workspace.credentialRevision ?? 0 + ].join('\u001f') +} + +/** Cache-invalidation key: broader than `linearWorkspaceScopeSignature`, hashes full viewer and workspace metadata. */ +export function linearStatusScopeSignature(status: LinearConnectionStatus): string { + return JSON.stringify({ + connected: status.connected, + credentialError: status.credentialError ?? null, + activeWorkspaceId: status.activeWorkspaceId ?? null, + selectedWorkspaceId: getSelectedWorkspaceId(status), + viewer: status.viewer + ? [ + status.viewer.organizationId ?? '', + status.viewer.organizationName, + status.viewer.organizationUrlKey ?? '', + status.viewer.displayName, + status.viewer.email ?? '' + ] + : null, + workspaces: (status.workspaces ?? []).map(linearWorkspaceSignature) + }) +} + +export function shouldRefreshStatusAfterRead( + workspaceId: LinearWorkspaceSelection | null | undefined, + status: LinearConnectionStatus +): boolean { + // Why: 'all' reads can hide per-workspace decrypt failures, and a visible + // credential error may have been cleared by a successful credential read. + return workspaceId === 'all' || status.credentialError !== undefined +} + +export function linearCollectionCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined, + mode: string, + ...parts: (string | number | null | undefined)[] +): string { + return [workspaceId ?? 'default', mode, ...parts.map((part) => part ?? '')].join('::') +} + +export function emptyLinearCollection(): LinearCollectionResult { + return { items: [] } +} + +export function collectionWithWorkspaceError( + fallback: LinearCollectionResult, + workspaceId: string, + error: unknown +): LinearCollectionResult { + const existingErrors = (fallback.errors ?? []).filter((item) => item.workspaceId !== workspaceId) + return { + ...fallback, + errors: [ + ...existingErrors, + { + workspaceId, + type: workspaceErrorType(error), + message: workspaceErrorMessage(error) || 'Linear request failed.' + } + ] + } +} + +export function largestCachedCollectionBelowLimit( + cache: Record>>, + workspaceId: LinearWorkspaceSelection | null | undefined, + mode: string, + scopeId: string, + limit: number +): LinearCollectionResult | null { + const keyPrefix = `${linearCollectionCacheKey(workspaceId, mode, scopeId)}::` + let best: { limit: number; data: LinearCollectionResult } | null = null + for (const [key, entry] of Object.entries(cache)) { + if (!entry?.data || !key.startsWith(keyPrefix)) { + continue + } + const cachedLimit = Number(key.slice(keyPrefix.length)) + if (!Number.isFinite(cachedLimit) || cachedLimit >= limit) { + continue + } + if (!best || cachedLimit > best.limit) { + best = { limit: cachedLimit, data: entry.data } + } + } + return best?.data ?? null +} + +export function patchLinearIssueCollectionCache( + cache: Record>>, + issueId: string, + patch: Partial, + canPatchCacheKey: (key: string) => boolean +): { + cache: Record>> + changed: boolean +} { + let changed = false + const nextCache = { ...cache } + for (const [key, entry] of Object.entries(nextCache)) { + if (!canPatchCacheKey(key) || !entry?.data) { + continue + } + const idx = entry.data.items.findIndex((item) => item.id === issueId) + if (idx === -1) { + continue + } + const updatedItems = [...entry.data.items] + updatedItems[idx] = { ...updatedItems[idx], ...patch } + nextCache[key] = { + ...entry, + data: { ...entry.data, items: updatedItems } + } + changed = true + } + return { cache: nextCache, changed } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts new file mode 100644 index 00000000000..28cf7a07483 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts @@ -0,0 +1,141 @@ +import type { + LinearCustomViewModel, + LinearCustomViewSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearGetCustomView } from '@/runtime/runtime-linear-project-client' +import { + evictStaleEntries, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewDetailRequests, + type InflightLinearDetailRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewDetailActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + fetchLinearCustomView: async ( + viewId, + workspaceId, + model: LinearCustomViewModel, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) + ) + const cached = get().linearCustomViewDetailCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data + } + + const inflight = inflightCustomViewDetailRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearDetailRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearGetCustomView(scope.settings, viewId, model, workspaceId, { + force: options?.force + }) + .then((view) => { + if ( + inflightCustomViewDetailRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewDetailCache: evictStaleEntries({ + ...s.linearCustomViewDetailCache, + [cacheKey]: { data: view, fetchedAt: Date.now() } + }) + })) + } + return view + }) + .catch((error) => { + console.warn('[linear] fetchLinearCustomView failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + if (options?.force) { + throw error + } + const cachedResult = get().linearCustomViewDetailCache[cacheKey] + if (cachedResult) { + return cachedResult.data + } + throw error + }) + .finally(() => { + if (inflightCustomViewDetailRequests.get(cacheKey) === entry) { + inflightCustomViewDetailRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewDetailRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts new file mode 100644 index 00000000000..65280c66e30 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts @@ -0,0 +1,153 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListCustomViewIssues } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + largestCachedCollectionBelowLimit, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewIssueRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewIssueActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + listLinearCustomViewIssues: async ( + viewId, + workspaceId, + limit = 20, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const effectiveLimit = clampLinearIssueListLimit(limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, effectiveLimit) + ) + const cached = get().linearCustomViewIssueCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightCustomViewIssueRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListCustomViewIssues( + scope.settings, + viewId, + effectiveLimit, + workspaceId, + { + force: options?.force + } + ) + .then((result) => { + if ( + inflightCustomViewIssueRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewIssueCache: evictStaleEntries({ + ...s.linearCustomViewIssueCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearCustomViewIssues failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearCustomViewIssueCache[cacheKey]?.data ?? + largestCachedCollectionBelowLimit( + get().linearCustomViewIssueCache, + workspaceId, + 'custom-view-issues', + viewId, + effectiveLimit + ) ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, workspaceId, error) + }) + .finally(() => { + if (inflightCustomViewIssueRequests.get(cacheKey) === entry) { + inflightCustomViewIssueRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewIssueRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts new file mode 100644 index 00000000000..5a54247cc7a --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts @@ -0,0 +1,149 @@ +import type { + LinearCustomViewModel, + LinearCustomViewSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListCustomViews } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + getSelectedWorkspaceId, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewListActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearCustomViews: (model, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + return get().linearCustomViewCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null + }, + + listLinearCustomViews: async ( + model: LinearCustomViewModel, + limit = 20, + workspaceId, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + ) + const cached = get().linearCustomViewCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightCustomViewRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListCustomViews(scope.settings, model, limit, resolvedWorkspaceId, { + force: options?.force + }) + .then((result) => { + if ( + inflightCustomViewRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewCache: evictStaleEntries({ + ...s.linearCustomViewCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearCustomViews failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearCustomViewCache[cacheKey]?.data ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) + }) + .finally(() => { + if (inflightCustomViewRequests.get(cacheKey) === entry) { + inflightCustomViewRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts new file mode 100644 index 00000000000..c00001341eb --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts @@ -0,0 +1,137 @@ +import type { LinearProjectSummary } from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListCustomViewProjects } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewProjectRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewProjectActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + listLinearCustomViewProjects: async ( + viewId, + workspaceId, + limit = 20, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) + ) + const cached = get().linearCustomViewProjectCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightCustomViewProjectRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListCustomViewProjects(scope.settings, viewId, limit, workspaceId, { + force: options?.force + }) + .then((result) => { + if ( + inflightCustomViewProjectRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewProjectCache: evictStaleEntries({ + ...s.linearCustomViewProjectCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearCustomViewProjects failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearCustomViewProjectCache[cacheKey]?.data ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, workspaceId, error) + }) + .finally(() => { + if (inflightCustomViewProjectRequests.get(cacheKey) === entry) { + inflightCustomViewProjectRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewProjectRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-invalidation-actions.ts b/src/renderer/src/store/slices/linear/linear-invalidation-actions.ts new file mode 100644 index 00000000000..b515ae1d83e --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-invalidation-actions.ts @@ -0,0 +1,155 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { + LINEAR_LIST_INVALIDATION_VERSION_CAP, + linearListInvalidationToken, + patchLinearIssueCollectionCache, + setLinearListInvalidationToken +} from './linear-cache' +import { inflightListRequests } from './linear-slice-request-state' +import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context' +import { getLinearReadScope } from './linear-slice-scope' + +export function createLinearInvalidationActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + invalidateLinearIssueLists: (options?: Pick) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const tokenScope = scope.cachePrefix ?? 'local' + const nextVersion = + linearListInvalidationToken.scope === tokenScope + ? (linearListInvalidationToken.version + 1) % LINEAR_LIST_INVALIDATION_VERSION_CAP || 1 + : 1 + const nextToken = { scope: tokenScope, version: nextVersion } + setLinearListInvalidationToken(nextToken) + + // Why: drop only attribute-filtered plain list entries in this source scope + // so the next TaskPage read is forced current without wiping search/unrelated + // collections. + set((s) => { + const nextListCache = { ...s.linearListCache } + let changed = false + for (const key of Object.keys(nextListCache)) { + const parts = key.split('::') + // Cache keys end with the attribute signature; unfiltered entries end empty. + const attributeSignature = parts.at(-1) ?? '' + if (!attributeSignature) { + continue + } + if (scope.cachePrefix) { + if (!key.startsWith(`${scope.cachePrefix}::`)) { + continue + } + } else if (parts[1] !== 'list') { + // Why: unscoped invalidation must not touch other runtimes' scoped list keys. + continue + } + delete nextListCache[key] + inflightListRequests.delete(key) + changed = true + } + if (!changed) { + return { linearListInvalidationToken: nextToken } + } + return { + linearListCache: nextListCache, + linearListInvalidationToken: nextToken + } + }) + }, + + patchLinearIssue: (issueId: string, patch: Partial, options) => { + const sourceScope = + options?.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) + set((s) => { + let changed = false + + const nextIssueCache = { ...s.linearIssueCache } + for (const [key, issueEntry] of Object.entries(nextIssueCache)) { + if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { + continue + } + // Why: set fetchedAt to 0 so the next fetchLinearIssue call + // actually hits IPC instead of returning the stale optimistic data. + nextIssueCache[key] = { + ...issueEntry, + data: { ...issueEntry.data, ...patch }, + fetchedAt: 0 + } + changed = true + } + + const nextSearchCache = { ...s.linearSearchCache } + for (const key of Object.keys(nextSearchCache)) { + const entry = nextSearchCache[key] + if (!canPatchCacheKey(key) || !entry?.data) { + continue + } + const idx = entry.data.findIndex((item) => item.id === issueId) + if (idx === -1) { + continue + } + const updatedItems = [...entry.data] + updatedItems[idx] = { ...updatedItems[idx], ...patch } + nextSearchCache[key] = { ...entry, data: updatedItems } + changed = true + } + + const nextListCache = patchLinearIssueCollectionCache( + s.linearListCache, + issueId, + patch, + canPatchCacheKey + ) + if (nextListCache.changed) { + changed = true + } + + const nextProjectIssueCache = patchLinearIssueCollectionCache( + s.linearProjectIssueCache, + issueId, + patch, + canPatchCacheKey + ) + if (nextProjectIssueCache.changed) { + changed = true + } + + const nextCustomViewIssueCache = patchLinearIssueCollectionCache( + s.linearCustomViewIssueCache, + issueId, + patch, + canPatchCacheKey + ) + if (nextCustomViewIssueCache.changed) { + changed = true + } + + return changed + ? { + linearIssueCache: nextIssueCache, + linearSearchCache: nextSearchCache, + linearListCache: nextListCache.changed ? nextListCache.cache : s.linearListCache, + linearProjectIssueCache: nextProjectIssueCache.changed + ? nextProjectIssueCache.cache + : s.linearProjectIssueCache, + linearCustomViewIssueCache: nextCustomViewIssueCache.changed + ? nextCustomViewIssueCache.cache + : s.linearCustomViewIssueCache + } + : {} + }) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts b/src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts new file mode 100644 index 00000000000..32c3116aaac --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts @@ -0,0 +1,101 @@ +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import type { + LinearIssueListReadArgs, + LinearIssueReadArgs, + LinearSlice, + LinearSliceGet, + LinearSliceSet, + LinearFetchOptions +} from './linear-slice-contract' +import { + getSelectedWorkspaceId, + isFresh, + linearListCacheKey, + linearSearchCacheKey +} from './linear-cache' +import { + getLinearMutationGeneration, + inflightListRequests, + inflightSearchRequests +} from './linear-slice-request-state' +import { + getLinearReadScope, + normalizeListAttributeFilter, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearIssueCacheActions( + _set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearIssues: (args: LinearIssueReadArgs, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + if (args.kind === 'search') { + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) + ) + return get().linearSearchCache[cacheKey]?.data ?? null + } + const limit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) + ) + return get().linearListCache[cacheKey]?.data ?? null + }, + + prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + if (args.kind === 'search') { + const limit = args.limit ?? 20 + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, limit) + ) + const inflight = inflightSearchRequests.get(cacheKey) + if ( + isFresh(get().linearSearchCache[cacheKey]) || + (inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration()) + ) { + return + } + void get() + .searchLinearIssues(args.query, limit, options) + .catch(() => {}) + return + } + const limit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const listArgs: LinearIssueListReadArgs = { + kind: 'list', + filter: args.filter, + limit, + attributeFilter + } + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) + ) + const inflight = inflightListRequests.get(cacheKey) + if ( + isFresh(get().linearListCache[cacheKey]) || + (inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration()) + ) { + return + } + void get() + .listLinearIssues(listArgs, options) + .catch(() => {}) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts b/src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts new file mode 100644 index 00000000000..94b4f3fde07 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts @@ -0,0 +1,157 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearSlice, + LinearSliceGet, + LinearSliceSet, + LinearFetchOptions +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' +import { + clearLinearIssueCollectionRequestMaps, + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightIssueRequests +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' +import { + evictStaleEntries, + isFresh, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import type { InflightLinearIssueRequest } from './linear-slice-request-state' + +export function createLinearIssueDetailActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + fetchLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) + const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] + if (isFresh(cached)) { + return cached.data + } + + const inflight = inflightIssueRequests.get(issueCacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() + ) { + return inflight.promise + } + + let entry: InflightLinearIssueRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearGetIssue(scope.settings, id, workspaceId) + .then((issue) => { + const data = issue as LinearIssue | null + if ( + inflightIssueRequests.get(issueCacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearIssueCache: evictStaleEntries({ + ...s.linearIssueCache, + [issueCacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] fetchLinearIssue failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + return null + }) + .finally(() => { + if (inflightIssueRequests.get(issueCacheKey) === entry) { + inflightIssueRequests.delete(issueCacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightIssueRequests.set(issueCacheKey, entry) + return promise + }, + + refreshLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) + inflightIssueRequests.delete(issueCacheKey) + clearLinearIssueCollectionRequestMaps() + set((s) => { + const nextIssueCache = { ...s.linearIssueCache } + for (const [key, entry] of Object.entries(nextIssueCache)) { + if ( + key === issueCacheKey || + key === id || + entry?.data?.id === id || + entry?.data?.identifier === id + ) { + delete nextIssueCache[key] + } + } + return { + linearIssueCache: nextIssueCache, + linearSearchCache: {}, + linearListCache: {}, + linearProjectIssueCache: {}, + linearCustomViewIssueCache: {} + } + }) + return get().fetchLinearIssue(id, workspaceId, options) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-issue-list-actions.ts b/src/renderer/src/store/slices/linear/linear-issue-list-actions.ts new file mode 100644 index 00000000000..20fdd8b70c4 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-issue-list-actions.ts @@ -0,0 +1,250 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types' +import type { + LinearIssueListReadArgs, + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { + isLinearIssueAttributeFilterUnsupportedError, + linearListIssues, + linearSearchIssues +} from '@/runtime/runtime-linear-client' +import { + evictStaleEntries, + emptyLinearCollection, + getSelectedWorkspaceId, + isFresh, + linearListCacheKey, + linearSearchCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightListRequests, + inflightSearchRequests, + type InflightLinearListRequest, + type InflightLinearPlainListRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + normalizeListAttributeFilter, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearIssueListActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + searchLinearIssues: async (query: string, limit = 20, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + const cacheKey = scopedLinearCacheKey(scope, linearSearchCacheKey(workspaceId, query, limit)) + const cached = get().linearSearchCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? [] + } + + const inflight = inflightSearchRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearListRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearSearchIssues(scope.settings, query, limit, workspaceId) + .then((issues) => { + const data = issues as LinearIssue[] + if ( + inflightSearchRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearSearchCache: evictStaleEntries({ + ...s.linearSearchCache, + [cacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] searchLinearIssues failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { + void get().checkLinearConnection(true) + } + return [] + } + return get().linearSearchCache[cacheKey]?.data ?? [] + }) + .finally(() => { + if (inflightSearchRequests.get(cacheKey) === entry) { + inflightSearchRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightSearchRequests.set(cacheKey, entry) + return promise + }, + + listLinearIssues: async (args: LinearIssueListReadArgs, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + const filter = args.filter ?? 'assigned' + const effectiveLimit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, filter, effectiveLimit, attributeFilter) + ) + const cached = get().linearListCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightListRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearPlainListRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise: Promise> = linearListIssues( + scope.settings, + filter, + effectiveLimit, + workspaceId, + attributeFilter + ) + .then((result) => { + const data = result as LinearCollectionResult + if ( + inflightListRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearListCache: evictStaleEntries({ + ...s.linearListCache, + [cacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] listLinearIssues failed:', error) + // Why: capability mismatch is actionable (update remote runtime). Swallowing + // it as [] would look like "no issues match filters" and hide the fix. + if (isLinearIssueAttributeFilterUnsupportedError(error)) { + throw error + } + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { + void get().checkLinearConnection(true) + } + return emptyLinearCollection() + } + return get().linearListCache[cacheKey]?.data ?? emptyLinearCollection() + }) + .finally(() => { + if (inflightListRequests.get(cacheKey) === entry) { + inflightListRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightListRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-project-detail-actions.ts b/src/renderer/src/store/slices/linear/linear-project-detail-actions.ts new file mode 100644 index 00000000000..e8e97b8c4c1 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-project-detail-actions.ts @@ -0,0 +1,133 @@ +import type { LinearProjectDetail } from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearGetProject } from '@/runtime/runtime-linear-project-client' +import { + evictStaleEntries, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightProjectDetailRequests, + type InflightLinearDetailRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearProjectDetailActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + fetchLinearProject: async (id, workspaceId, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-detail', id) + ) + const cached = get().linearProjectDetailCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data + } + + const inflight = inflightProjectDetailRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearDetailRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearGetProject(scope.settings, id, workspaceId, { + force: options?.force + }) + .then((project) => { + if ( + inflightProjectDetailRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearProjectDetailCache: evictStaleEntries({ + ...s.linearProjectDetailCache, + [cacheKey]: { data: project, fetchedAt: Date.now() } + }) + })) + } + return project + }) + .catch((error) => { + console.warn('[linear] fetchLinearProject failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + if (options?.force) { + throw error + } + const cachedResult = get().linearProjectDetailCache[cacheKey] + if (cachedResult) { + return cachedResult.data + } + throw error + }) + .finally(() => { + if (inflightProjectDetailRequests.get(cacheKey) === entry) { + inflightProjectDetailRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightProjectDetailRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-project-issue-actions.ts b/src/renderer/src/store/slices/linear/linear-project-issue-actions.ts new file mode 100644 index 00000000000..1fbdb093ba0 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-project-issue-actions.ts @@ -0,0 +1,153 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListProjectIssues } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + largestCachedCollectionBelowLimit, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightProjectIssueRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearProjectIssueActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + listLinearProjectIssues: async ( + projectId, + workspaceId, + limit = 20, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const effectiveLimit = clampLinearIssueListLimit(limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-issues', projectId, effectiveLimit) + ) + const cached = get().linearProjectIssueCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightProjectIssueRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListProjectIssues( + scope.settings, + projectId, + effectiveLimit, + workspaceId, + { + force: options?.force + } + ) + .then((result) => { + if ( + inflightProjectIssueRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearProjectIssueCache: evictStaleEntries({ + ...s.linearProjectIssueCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearProjectIssues failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearProjectIssueCache[cacheKey]?.data ?? + largestCachedCollectionBelowLimit( + get().linearProjectIssueCache, + workspaceId, + 'project-issues', + projectId, + effectiveLimit + ) ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, workspaceId, error) + }) + .finally(() => { + if (inflightProjectIssueRequests.get(cacheKey) === entry) { + inflightProjectIssueRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightProjectIssueRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-project-list-actions.ts b/src/renderer/src/store/slices/linear/linear-project-list-actions.ts new file mode 100644 index 00000000000..ac7a6fbb07e --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-project-list-actions.ts @@ -0,0 +1,147 @@ +import type { LinearProjectSummary } from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + getSelectedWorkspaceId, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightProjectRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearProjectListActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearProjects: (query, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = linearCollectionCacheKey( + resolvedWorkspaceId, + 'projects', + query?.trim(), + limit + ) + return get().linearProjectCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null + }, + + listLinearProjects: async (query, limit = 20, workspaceId, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const trimmed = query?.trim() || undefined + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) + ) + const cached = get().linearProjectCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightProjectRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListProjects(scope.settings, trimmed, limit, resolvedWorkspaceId, { + force: options?.force + }) + .then((result) => { + if ( + inflightProjectRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearProjectCache: evictStaleEntries({ + ...s.linearProjectCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearProjects failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearProjectCache[cacheKey]?.data ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) + }) + .finally(() => { + if (inflightProjectRequests.get(cacheKey) === entry) { + inflightProjectRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightProjectRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts b/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts new file mode 100644 index 00000000000..c799e190c75 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts @@ -0,0 +1,234 @@ +import type { LinearViewer } from '../../../../../shared/linear/workspace-types' +import type { LinearSlice, LinearSliceGet, LinearSliceSet } from './linear-slice-contract' +import { + linearConnect, + linearDisconnect, + linearDisconnectWorkspace, + linearSelectWorkspace, + linearStatus, + linearTestConnection +} from '@/runtime/runtime-linear-client' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { translate } from '@/i18n/i18n' +import { + beginLinearMutation, + invalidateLinearCaches, + isCurrentLinearMutation +} from './linear-slice-request-state' +import { isCurrentLinearRuntimeContext } from './linear-slice-scope' +import { linearStatusScopeSignature } from './linear-cache' + +export function createLinearConnectionActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick< + LinearSlice, + | 'testLinearConnection' + | 'connectLinear' + | 'selectLinearWorkspace' + | 'disconnectLinear' + | 'disconnectLinearWorkspace' +> { + return { + testLinearConnection: async (workspaceId) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + try { + const result = (await linearTestConnection(get().settings, workspaceId)) as + | { ok: true; viewer: LinearViewer } + | { ok: false; error: string } + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return result + } + const status = await linearStatus(get().settings) + if ( + isCurrentLinearMutation(requestGeneration) && + isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + const prev = get().linearStatus + if (linearStatusScopeSignature(prev) !== linearStatusScopeSignature(status)) { + invalidateLinearCaches() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (!get().linearStatusChecked || get().linearStatusContextKey !== contextKey) { + // Preserve the status reference when the probe did not change scope. + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } + } + return result + } catch (error) { + const message = error instanceof Error ? error.message : 'Test failed' + return { ok: false as const, error: message } + } + }, + + connectLinear: async (apiKey: string) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + try { + const result = await linearConnect(get().settings, apiKey) + if ( + result.ok && + isCurrentLinearMutation(requestGeneration) && + isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + invalidateLinearCaches() + set({ + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {} + }) + const status = await linearStatus(get().settings) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.linear.37d36984d0', + 'Linear connection was superseded by a newer request.' + ) + } + } + set({ + linearStatus: status, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (result.ok) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.linear.37d36984d0', + 'Linear connection was superseded by a newer request.' + ) + } + } + return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } + } catch (error) { + const message = error instanceof Error ? error.message : 'Connection failed' + return { ok: false as const, error: message } + } + }, + + selectLinearWorkspace: async (workspaceId) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + const status = await linearSelectWorkspace(get().settings, workspaceId) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + invalidateLinearCaches() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + }, + + disconnectLinear: async () => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + await linearDisconnect(get().settings) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + invalidateLinearCaches() + set({ + linearStatus: { connected: false, viewer: null }, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + }, + + disconnectLinearWorkspace: async (workspaceId) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + await linearDisconnectWorkspace(get().settings, workspaceId) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + const status = await linearStatus(get().settings) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + invalidateLinearCaches() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-contract.ts b/src/renderer/src/store/slices/linear/linear-slice-contract.ts new file mode 100644 index 00000000000..3c87fdf3090 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-contract.ts @@ -0,0 +1,160 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConnectionStatus, + LinearTeam, + LinearViewer, + LinearWorkspaceSelection +} from '../../../../../shared/linear/workspace-types' +import type { CacheEntry } from '../../github/cache-model' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' + +export type LinearIssueListReadArgs = { + kind: 'list' + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + attributeFilter?: LinearIssueAttributeFilter | null +} + +export type LinearIssueReadArgs = + | { kind: 'search'; query: string; limit?: number } + | LinearIssueListReadArgs + +export type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } +export type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } + +export type LinearSlice = { + linearStatus: LinearConnectionStatus + linearStatusChecked: boolean + linearStatusContextKey: string | null + linearIssueCache: Record> + linearSearchCache: Record> + linearListCache: Record>> + linearTeamCache: Record> + linearProjectCache: Record>> + linearProjectDetailCache: Record> + linearProjectIssueCache: Record>> + linearCustomViewCache: Record>> + linearCustomViewDetailCache: Record> + linearCustomViewIssueCache: Record>> + linearCustomViewProjectCache: Record< + string, + CacheEntry> + > + + checkLinearConnection: (force?: boolean) => Promise + connectLinear: ( + apiKey: string + ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + testLinearConnection: ( + workspaceId?: string | null + ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise + disconnectLinear: () => Promise + disconnectLinearWorkspace: (workspaceId: string) => Promise + fetchLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise + refreshLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise + getCachedLinearIssues: ( + args: LinearIssueReadArgs, + options?: Pick + ) => LinearIssue[] | LinearCollectionResult | null + prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => void + searchLinearIssues: ( + query: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise + listLinearIssues: ( + args: LinearIssueListReadArgs, + options?: LinearFetchOptions + ) => Promise> + linearListInvalidationToken: { scope: string; version: number } + invalidateLinearIssueLists: (options?: Pick) => void + getCachedLinearTeams: ( + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick + ) => LinearTeam[] | null + listLinearTeams: ( + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearFetchOptions + ) => Promise + getCachedLinearProjects: ( + query?: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick + ) => LinearCollectionResult | null + listLinearProjects: ( + query?: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearFetchOptions + ) => Promise> + fetchLinearProject: ( + id: string, + workspaceId: string, + options?: LinearFetchOptions + ) => Promise + listLinearProjectIssues: ( + projectId: string, + workspaceId: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise> + getCachedLinearCustomViews: ( + model: LinearCustomViewModel, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick + ) => LinearCollectionResult | null + listLinearCustomViews: ( + model: LinearCustomViewModel, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearFetchOptions + ) => Promise> + fetchLinearCustomView: ( + viewId: string, + workspaceId: string, + model: LinearCustomViewModel, + options?: LinearFetchOptions + ) => Promise + listLinearCustomViewIssues: ( + viewId: string, + workspaceId: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise> + listLinearCustomViewProjects: ( + viewId: string, + workspaceId: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise> + patchLinearIssue: ( + issueId: string, + patch: Partial, + options?: LinearPatchOptions + ) => void +} + +export type LinearSliceStateCreator = StateCreator +export type LinearSliceSet = Parameters[0] +export type LinearSliceGet = Parameters[1] diff --git a/src/renderer/src/store/slices/linear/linear-slice-request-state.ts b/src/renderer/src/store/slices/linear/linear-slice-request-state.ts new file mode 100644 index 00000000000..cd232ba768b --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-request-state.ts @@ -0,0 +1,161 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearTeam +} from '../../../../../shared/linear/workspace-types' +import { clearLinearMetadataCache } from '../../../hooks/useIssueMetadata' + +export type InflightLinearIssueRequest = { + promise: Promise + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearListRequest = { + promise: Promise + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearPlainListRequest = { + promise: Promise> + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearCollectionRequest = { + promise: Promise> + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearDetailRequest = { + promise: Promise + force: boolean + contextKey: string + mutationGeneration: number +} + +export type InflightLinearTeamRequest = { + promise: Promise + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export const linearRequestState = { + issueRequests: new Map(), + searchRequests: new Map(), + listRequests: new Map(), + teamRequests: new Map(), + projectRequests: new Map>(), + projectDetailRequests: new Map>(), + projectIssueRequests: new Map>(), + customViewRequests: new Map>(), + customViewDetailRequests: new Map< + string, + InflightLinearDetailRequest + >(), + customViewIssueRequests: new Map>(), + customViewProjectRequests: new Map< + string, + InflightLinearCollectionRequest + >(), + inflightStatusRequest: null as { contextKey: string; promise: Promise } | null, + statusReadGeneration: 0, + mutationGeneration: 0, + cacheGeneration: 0 +} + +// Named aliases keep action modules focused on behavior while sharing one request registry. +export const inflightIssueRequests = linearRequestState.issueRequests +export const inflightSearchRequests = linearRequestState.searchRequests +export const inflightListRequests = linearRequestState.listRequests +export const inflightTeamRequests = linearRequestState.teamRequests +export const inflightProjectRequests = linearRequestState.projectRequests +export const inflightProjectDetailRequests = linearRequestState.projectDetailRequests +export const inflightProjectIssueRequests = linearRequestState.projectIssueRequests +export const inflightCustomViewRequests = linearRequestState.customViewRequests +export const inflightCustomViewDetailRequests = linearRequestState.customViewDetailRequests +export const inflightCustomViewIssueRequests = linearRequestState.customViewIssueRequests +export const inflightCustomViewProjectRequests = linearRequestState.customViewProjectRequests + +export function clearLinearRequestMaps(): void { + linearRequestState.issueRequests.clear() + linearRequestState.searchRequests.clear() + linearRequestState.listRequests.clear() + linearRequestState.teamRequests.clear() + linearRequestState.projectRequests.clear() + linearRequestState.projectDetailRequests.clear() + linearRequestState.projectIssueRequests.clear() + linearRequestState.customViewRequests.clear() + linearRequestState.customViewDetailRequests.clear() + linearRequestState.customViewIssueRequests.clear() + linearRequestState.customViewProjectRequests.clear() +} + +export function invalidateLinearCaches(): void { + linearRequestState.cacheGeneration += 1 + clearLinearRequestMaps() + clearLinearMetadataCache() +} + +export function clearLinearIssueCollectionRequestMaps(): void { + linearRequestState.searchRequests.clear() + linearRequestState.listRequests.clear() + linearRequestState.projectIssueRequests.clear() + linearRequestState.customViewIssueRequests.clear() +} + +export function beginLinearMutation(): number { + linearRequestState.mutationGeneration += 1 + linearRequestState.inflightStatusRequest = null + return linearRequestState.mutationGeneration +} + +export function isCurrentLinearMutation(generation: number): boolean { + return generation === linearRequestState.mutationGeneration +} + +export function nextLinearStatusReadGeneration(): number { + linearRequestState.statusReadGeneration += 1 + return linearRequestState.statusReadGeneration +} + +export function isCurrentLinearStatusRead(generation: number): boolean { + return generation === linearRequestState.statusReadGeneration +} + +export function getLinearCacheGeneration(): number { + return linearRequestState.cacheGeneration +} + +export function getLinearMutationGeneration(): number { + return linearRequestState.mutationGeneration +} + +export function getInflightStatusRequest(): { + contextKey: string + promise: Promise +} | null { + return linearRequestState.inflightStatusRequest +} + +export function setInflightStatusRequest( + request: { contextKey: string; promise: Promise } | null +): void { + linearRequestState.inflightStatusRequest = request +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-scope.ts b/src/renderer/src/store/slices/linear/linear-slice-scope.ts new file mode 100644 index 00000000000..42c1aadac03 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-scope.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../../types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings +} from '../../../../../shared/task-source-context' +import { + canonicalizeLinearIssueAttributeFilter, + isEmptyLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../../../../shared/linear/issue-attribute-filter' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + linearRequestState +} from './linear-slice-request-state' + +export function normalizeListAttributeFilter( + attributeFilter?: LinearIssueAttributeFilter | null +): LinearIssueAttributeFilter | undefined { + if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) { + return undefined + } + return canonicalizeLinearIssueAttributeFilter(attributeFilter) +} + +export type LinearReadScope = { + settings: AppState['settings'] | TaskSourceContext | null + contextKey: string + cachePrefix: string | null + explicitSource: boolean +} + +export function isCurrentLinearRuntimeContext( + contextKey: string, + settings: AppState['settings'] +): boolean { + return getProviderRuntimeContextKey(settings) === contextKey +} + +export function canWriteLinearReadResult( + contextKey: string, + generation: number, + mutationGeneration: number, + settings: AppState['settings'], + explicitSource = false +): boolean { + return ( + generation === getLinearCacheGeneration() && + mutationGeneration === getLinearMutationGeneration() && + (explicitSource || isCurrentLinearRuntimeContext(contextKey, settings)) + ) +} + +export function getLinearReadScope( + settings: AppState['settings'], + sourceContext?: TaskSourceContext | null +): LinearReadScope { + if (!sourceContext) { + return { + settings, + contextKey: getProviderRuntimeContextKey(settings), + cachePrefix: null, + explicitSource: false + } + } + const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) + const cachePrefix = getTaskSourceCacheScope(sourceContext) + return { + settings: sourceContext, + contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${cachePrefix}`, + cachePrefix, + explicitSource: true + } +} + +export function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { + return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key +} + +/** Read the current request generation without exposing mutable implementation details. */ +export function linearReadGenerationSnapshot(): { + cacheGeneration: number + mutationGeneration: number +} { + return { + cacheGeneration: getLinearCacheGeneration(), + mutationGeneration: getLinearMutationGeneration() + } +} + +/** Keep the status request handle private to the request-state module. */ +export function getInflightStatusRequest(): { contextKey: string; promise: Promise } | null { + return linearRequestState.inflightStatusRequest +} + +export function setInflightStatusRequest( + request: { contextKey: string; promise: Promise } | null +): void { + linearRequestState.inflightStatusRequest = request +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts b/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts new file mode 100644 index 00000000000..5fa5be61945 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts @@ -0,0 +1,115 @@ +import type { LinearConnectionStatus } from '../../../../../shared/linear/workspace-types' +import type { LinearSlice, LinearSliceGet, LinearSliceSet } from './linear-slice-contract' +import { linearStatus } from '@/runtime/runtime-linear-client' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { + getInflightStatusRequest, + getLinearMutationGeneration, + invalidateLinearCaches, + isCurrentLinearStatusRead, + nextLinearStatusReadGeneration, + setInflightStatusRequest +} from './linear-slice-request-state' +import { linearStatusScopeSignature } from './linear-cache' +import { isCurrentLinearRuntimeContext } from './linear-slice-scope' + +export function createLinearStatusActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + checkLinearConnection: async (force = false) => { + const contextKey = getProviderRuntimeContextKey(get().settings) + const inflightStatusRequest = getInflightStatusRequest() + if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { + return inflightStatusRequest.promise + } + if (get().linearStatusContextKey !== contextKey && get().linearStatusChecked) { + set({ linearStatusChecked: false }) + } + + const mutationGeneration = getLinearMutationGeneration() + const statusReadGeneration = nextLinearStatusReadGeneration() + const request = linearStatus(get().settings) + .then((status) => { + if ( + mutationGeneration !== getLinearMutationGeneration() || + !isCurrentLinearStatusRead(statusReadGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + const typedStatus = status as LinearConnectionStatus + const prev = get().linearStatus + const prevScopeSignature = linearStatusScopeSignature(prev) + const nextScopeSignature = linearStatusScopeSignature(typedStatus) + if (prevScopeSignature !== nextScopeSignature) { + invalidateLinearCaches() + set({ + linearStatus: typedStatus, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (!get().linearStatusChecked) { + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } else if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusContextKey: contextKey }) + } + }) + .catch(() => { + if ( + mutationGeneration !== getLinearMutationGeneration() || + !isCurrentLinearStatusRead(statusReadGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + if (get().linearStatus.connected) { + invalidateLinearCaches() + set({ + linearStatus: { connected: false, viewer: null }, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (!get().linearStatusChecked) { + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } else if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusContextKey: contextKey }) + } + }) + .finally(() => { + if ( + isCurrentLinearStatusRead(statusReadGeneration) && + getInflightStatusRequest()?.promise === request + ) { + setInflightStatusRequest(null) + } + }) + setInflightStatusRequest({ contextKey, promise: request }) + + return request + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-team-actions.ts b/src/renderer/src/store/slices/linear/linear-team-actions.ts new file mode 100644 index 00000000000..b551927cbcc --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-team-actions.ts @@ -0,0 +1,135 @@ +import type { LinearTeam } from '../../../../../shared/linear/workspace-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListTeams } from '@/runtime/runtime-linear-project-client' +import { + evictStaleEntries, + getSelectedWorkspaceId, + isFresh, + linearTeamsCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead, + TEAM_CACHE_TTL +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightTeamRequests, + type InflightLinearTeamRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearTeamActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearTeams: (workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const key = linearTeamsCacheKey(workspaceId ?? getSelectedWorkspaceId(get().linearStatus)) + return get().linearTeamCache[scopedLinearCacheKey(scope, key)]?.data ?? null + }, + + listLinearTeams: async (workspaceId, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = scopedLinearCacheKey(scope, linearTeamsCacheKey(resolvedWorkspaceId)) + const cached = get().linearTeamCache[cacheKey] + if (!options?.force && isFresh(cached, TEAM_CACHE_TTL)) { + return cached.data ?? [] + } + + const inflight = inflightTeamRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearTeamRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListTeams(scope.settings, resolvedWorkspaceId) + .then((teams) => { + const data = teams as LinearTeam[] + if ( + inflightTeamRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearTeamCache: evictStaleEntries({ + ...s.linearTeamCache, + [cacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] listLinearTeams failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { + void get().checkLinearConnection(true) + } + return [] + } + return get().linearTeamCache[cacheKey]?.data ?? [] + }) + .finally(() => { + if (inflightTeamRequests.get(cacheKey) === entry) { + inflightTeamRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightTeamRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index ad1f8db8e46..d44de7db8f2 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1,2117 +1,12 @@ -/* eslint-disable max-lines -- Why: split-tab group state updates layout, focus, and tab membership atomically in one slice to avoid split-brain. */ -import type { StateCreator } from 'zustand' -import type { AppState } from '../types' -import { toVisibleTabType } from '../../../../shared/tab-types' -import type { - Tab, - TabContentType, - TabGroup, - TabGroupLayoutNode, - WorkspaceVisibleTabType -} from '../../../../shared/tab-types' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { emitNativeChatToggled } from '@/lib/native-chat-telemetry' -import { - dedupeTabOrder, - ensureGroup, - findGroupAndWorktree, - findGroupForTab, - findTabAndWorktree, - findTabByEntityInGroup, - patchTab, - pickNextActiveTab, - pushRecentTabId, - sanitizeRecentTabIds, - updateGroup -} from './tab-group-state' -import { isPaneColumnSplitDropNoOp } from './pane-column-split-drop-no-op' -import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydration' -import { - buildOrphanTerminalCleanupPatch, - getOrphanTerminalIds, - terminalTabHasReconnectablePty -} from './terminal-orphan-helpers' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { getActiveExecutionHostIdForWorktree } from '@/lib/unified-tab-host-ownership' -import { - addAdditionalValidWorkspaceKeys, - type WorkspaceSessionHydrationOptions -} from '@/lib/workspace-session-hydration-keys' -import { - buildValidWorktreeIdsForSessionHydration, - collectPersistedWorktreeIdsForSessionHydration -} from './degraded-repo-worktree-validity' - -export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' - -function replaceWorkspaceRecordKeys( - current: Record, - hydrated: Record, - workspaceKeys: ReadonlySet -): Record { - return { - ...Object.fromEntries(Object.entries(current).filter(([key]) => !workspaceKeys.has(key))), - ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => workspaceKeys.has(key))) - } -} - -export type TabsSlice = { - unifiedTabsByWorktree: Record - // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. - renamingTabId: string | null - groupsByWorktree: Record - activeGroupIdByWorktree: Record - layoutByWorktree: Record - createUnifiedTab: ( - worktreeId: string, - contentType: TabContentType, - init?: Partial< - Pick< - Tab, - | 'id' - | 'entityId' - | 'executionHostId' - | 'label' - | 'generatedLabel' - | 'quickCommandLabel' - | 'customLabel' - | 'color' - | 'isPreview' - | 'isPinned' - > & { - targetGroupId: string - activate: boolean - recordInteraction: boolean - } - > - ) => Tab - createUnifiedTabInSplit: ( - worktreeId: string, - contentType: TabContentType, - target: { - sourceGroupId: string - splitDirection: TabSplitDirection - }, - init?: Partial< - Pick< - Tab, - | 'id' - | 'entityId' - | 'executionHostId' - | 'label' - | 'generatedLabel' - | 'quickCommandLabel' - | 'customLabel' - | 'color' - | 'isPreview' - | 'isPinned' - > & { - activate: boolean - recordInteraction: boolean - } - > - ) => Tab | null - getTab: (tabId: string) => Tab | null - getActiveTab: (worktreeId: string) => Tab | null - findTabForEntityInGroup: ( - worktreeId: string, - groupId: string, - entityId: string, - contentType?: TabContentType - ) => Tab | null - activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void - closeUnifiedTab: ( - tabId: string, - opts?: { - /** Keep the worktree selected even if this empties it — for closes the user did not ask for. */ - preserveWorktreeSelection?: boolean - recordInteraction?: boolean - terminalRetirementHandled?: boolean - } - ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null - reorderUnifiedTabs: ( - groupId: string, - tabIds: string[], - opts?: { recordInteraction?: boolean } - ) => void - setTabLabel: (tabId: string, label: string) => void - /** Set a tab's view mode (terminal vs native chat). Patches only that tab. */ - setTabViewMode: (tabId: string, mode: 'terminal' | 'chat') => void - /** Flip a tab between terminal and native-chat renderings; the live TerminalPane stays mounted. */ - toggleTabViewMode: (tabId: string) => void - setTabCustomLabel: ( - tabId: string, - label: string | null, - opts?: { recordInteraction?: boolean } - ) => void - setUnifiedTabColor: (tabId: string, color: string | null) => void - setRenamingTabId: (tabId: string | null) => void - pinTab: (tabId: string) => void - unpinTab: (tabId: string) => void - closeOtherTabs: (tabId: string) => string[] - closeTabsToRight: (tabId: string) => string[] - closeTabsToLeft: (tabId: string) => string[] - ensureWorktreeRootGroup: (worktreeId: string) => string - focusGroup: (worktreeId: string, groupId: string) => void - closeEmptyGroup: (worktreeId: string, groupId: string) => boolean - createEmptySplitGroup: ( - worktreeId: string, - sourceGroupId: string, - direction: TabSplitDirection, - opts?: { activate?: boolean } - ) => string | null - moveUnifiedTabToGroup: ( - tabId: string, - targetGroupId: string, - opts?: { index?: number; activate?: boolean; recordInteraction?: boolean } - ) => boolean - dropUnifiedTab: ( - tabId: string, - target: { - groupId: string - index?: number - splitDirection?: TabSplitDirection - } - ) => boolean - copyUnifiedTabToGroup: ( - tabId: string, - targetGroupId: string, - init?: Partial< - Pick< - Tab, - | 'id' - | 'entityId' - | 'label' - | 'generatedLabel' - | 'quickCommandLabel' - | 'customLabel' - | 'color' - | 'isPinned' - > - > - ) => Tab | null - mergeGroupIntoSibling: (worktreeId: string, groupId: string) => string | null - setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void - reconcileWorktreeTabModel: (worktreeId: string) => { - renderableTabCount: number - activeRenderableTabId: string | null - } - hydrateTabsSession: ( - session: WorkspaceSessionState, - options?: WorkspaceSessionHydrationOptions - ) => void -} - -// Why: keep the TerminalTab pin in sync with the unified pin so reconcile's existing.isPinned fallback stays authoritative locally. -function patchTerminalTabPinned( - tabsByWorktree: Record, - worktreeId: string, - tabId: string, - isPinned: boolean -): Partial> { - const tabs = tabsByWorktree[worktreeId] - if (!tabs?.some((tab) => tab.id === tabId)) { - return {} - } - return { - tabsByWorktree: { - ...tabsByWorktree, - [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) - } - } -} - -// Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. -// Dynamic import keeps this store slice off the runtime layer. -function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean): void { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - // Why: only terminal tab pins are persisted host-side today (browser/editor in #5729); skip the RPC for other types. - if ( - !found || - found.tab.contentType !== 'terminal' || - !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) - ) { - return - } - const worktreeId = found.worktreeId - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId, tabId, isPinned }) - ) -} - -// Why: viewMode is host-tracked like color/pin, so mirror local sets or they're lost on reconnect and to paired clients. -// Only the action path mirrors (never reconcile applying a host value), so the echoed snapshot can't re-trigger an outbound RPC. -function mirrorTabViewModeToHost( - state: AppState, - tabId: string, - viewMode: 'terminal' | 'chat' -): void { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - // Why: only terminal tab viewMode is persisted host-side; skip the RPC for other types instead of a no-op round trip. - if ( - !found || - found.tab.contentType !== 'terminal' || - !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) - ) { - return - } - const worktreeId = found.worktreeId - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId, tabId, viewMode }) - ) -} - -function buildSplitNode( - existingGroupId: string, - newGroupId: string, - direction: 'horizontal' | 'vertical', - position: 'first' | 'second' -): TabGroupLayoutNode { - const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } - const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } - return { - type: 'split', - direction, - first: position === 'first' ? newLeaf : existingLeaf, - second: position === 'second' ? newLeaf : existingLeaf, - ratio: 0.5 - } -} - -function replaceLeaf( - root: TabGroupLayoutNode, - targetGroupId: string, - replacement: TabGroupLayoutNode -): TabGroupLayoutNode { - if (root.type === 'leaf') { - return root.groupId === targetGroupId ? replacement : root - } - return { - ...root, - first: replaceLeaf(root.first, targetGroupId, replacement), - second: replaceLeaf(root.second, targetGroupId, replacement) - } -} - -function updateSplitRatio( - root: TabGroupLayoutNode, - path: string[], - ratio: number -): TabGroupLayoutNode { - if (path.length === 0) { - return root.type === 'split' ? { ...root, ratio } : root - } - if (root.type !== 'split') { - return root - } - const [segment, ...rest] = path - if (segment === 'first') { - return { ...root, first: updateSplitRatio(root.first, rest, ratio) } - } - if (segment === 'second') { - return { ...root, second: updateSplitRatio(root.second, rest, ratio) } - } - return root -} - -function findFirstLeaf(root: TabGroupLayoutNode): string { - return root.type === 'leaf' ? root.groupId : findFirstLeaf(root.first) -} - -function partitionPinnedTabOrder(tabOrder: string[], tabs: Tab[], movingTabId: string): string[] { - const tabById = new Map(tabs.map((tab) => [tab.id, tab])) - const withoutMoving = dedupeTabOrder(tabOrder).filter((id) => id !== movingTabId) - const pinnedIds = withoutMoving.filter((id) => tabById.get(id)?.isPinned) - const unpinnedIds = withoutMoving.filter((id) => !tabById.get(id)?.isPinned) - return [...pinnedIds, movingTabId, ...unpinnedIds] -} - -function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] { - const orderMap = new Map(tabOrder.map((id, index) => [id, index])) - return tabs.map((tab) => { - const sortOrder = orderMap.get(tab.id) - return sortOrder === undefined ? tab : { ...tab, sortOrder } - }) -} - -function isReplaceablePreviewContentType(contentType: Tab['contentType']): boolean { - return ( - contentType === 'editor' || - contentType === 'diff' || - contentType === 'conflict-review' || - contentType === 'check-details' - ) -} - -function canReplacePreviewContentType( - incomingContentType: Tab['contentType'], - existingContentType: Tab['contentType'] -): boolean { - if (isReplaceablePreviewContentType(incomingContentType)) { - return isReplaceablePreviewContentType(existingContentType) - } - return existingContentType === incomingContentType -} - -export function findSiblingGroupId(root: TabGroupLayoutNode, targetGroupId: string): string | null { - if (root.type === 'leaf') { - return null - } - if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { - return root.second.type === 'leaf' ? root.second.groupId : findFirstLeaf(root.second) - } - if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { - return root.first.type === 'leaf' ? root.first.groupId : findFirstLeaf(root.first) - } - return ( - findSiblingGroupId(root.first, targetGroupId) ?? findSiblingGroupId(root.second, targetGroupId) - ) -} - -function removeLeaf(root: TabGroupLayoutNode, targetGroupId: string): TabGroupLayoutNode | null { - if (root.type === 'leaf') { - return root.groupId === targetGroupId ? null : root - } - if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { - return root.second - } - if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { - return root.first - } - const first = removeLeaf(root.first, targetGroupId) - const second = removeLeaf(root.second, targetGroupId) - if (first === null) { - return second - } - if (second === null) { - return first - } - return { ...root, first, second } -} - -function collapseGroupLayout( - layoutByWorktree: Record, - activeGroupIdByWorktree: Record, - worktreeId: string, - groupId: string, - fallbackGroupId?: string | null -): { - layoutByWorktree: Record - activeGroupIdByWorktree: Record -} { - const currentLayout = layoutByWorktree[worktreeId] - if (!currentLayout) { - return { layoutByWorktree, activeGroupIdByWorktree } - } - const siblingId = findSiblingGroupId(currentLayout, groupId) - const collapsed = removeLeaf(currentLayout, groupId) - const nextLayoutByWorktree = { ...layoutByWorktree } - if (collapsed) { - nextLayoutByWorktree[worktreeId] = collapsed - } else { - delete nextLayoutByWorktree[worktreeId] - } - return { - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: { - ...activeGroupIdByWorktree, - [worktreeId]: siblingId ?? fallbackGroupId ?? activeGroupIdByWorktree[worktreeId] - } - } -} - -function deriveActiveSurfaceForWorktree( - state: Pick< - AppState, - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' - >, - worktreeId: string, - preferredGroupId?: string | null -): { - activeBrowserTabId: string | null - activeFileId: string | null - activeTabId: string | null - activeTabType: WorkspaceVisibleTabType -} { - const groups = state.groupsByWorktree[worktreeId] ?? [] - const activeGroupId = preferredGroupId ?? state.activeGroupIdByWorktree[worktreeId] ?? null - const activeGroup = - (activeGroupId ? groups.find((group) => group.id === activeGroupId) : null) ?? groups[0] ?? null - const activeUnifiedTab = - activeGroup?.activeTabId != null - ? ((state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id - ) ?? null) - : null - const restoredFileId = state.activeFileIdByWorktree[worktreeId] ?? null - const restoredBrowserTabId = state.activeBrowserTabIdByWorktree[worktreeId] ?? null - const restoredTerminalTabId = state.activeTabIdByWorktree[worktreeId] ?? null - const browserTabs = state.browserTabsByWorktree[worktreeId] ?? [] - const terminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const fileStillOpen = restoredFileId - ? state.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === worktreeId) - : false - const browserTabStillOpen = restoredBrowserTabId - ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) - : false - const terminalTabStillExists = restoredTerminalTabId - ? terminalTabs.some((tab) => tab.id === restoredTerminalTabId) - : false - const hasGroupOwnedSurface = groups.length > 0 || Boolean(state.layoutByWorktree[worktreeId]) - - let activeFileId: string | null - let activeBrowserTabId: string | null - let activeTabType: WorkspaceVisibleTabType - - if (activeUnifiedTab) { - activeFileId = - activeUnifiedTab.contentType === 'editor' || - activeUnifiedTab.contentType === 'diff' || - activeUnifiedTab.contentType === 'conflict-review' || - activeUnifiedTab.contentType === 'check-details' - ? activeUnifiedTab.entityId - : fileStillOpen - ? restoredFileId - : null - activeBrowserTabId = - activeUnifiedTab.contentType === 'browser' - ? activeUnifiedTab.entityId - : browserTabStillOpen - ? restoredBrowserTabId - : (browserTabs[0]?.id ?? null) - activeTabType = toVisibleTabType(activeUnifiedTab.contentType) - } else if (hasGroupOwnedSurface) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - // Why: focusing an empty split should target its default terminal area, not the previously active browser/editor in another group. - activeTabType = 'terminal' - } else if (browserTabStillOpen) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = restoredBrowserTabId - activeTabType = 'browser' - } else if (fileStillOpen) { - activeFileId = restoredFileId - activeBrowserTabId = browserTabs[0]?.id ?? null - activeTabType = 'editor' - } else { - const fallbackFile = state.openFiles.find((file) => file.worktreeId === worktreeId) ?? null - const fallbackBrowserTab = browserTabs[0] ?? null - activeFileId = fallbackFile?.id ?? null - activeBrowserTabId = fallbackBrowserTab?.id ?? null - activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' - } - - return { - activeBrowserTabId, - activeFileId, - activeTabId: - activeUnifiedTab?.contentType === 'terminal' - ? activeUnifiedTab.entityId - : terminalTabStillExists - ? restoredTerminalTabId - : (terminalTabs[0]?.id ?? null), - activeTabType - } -} - -function buildActiveSurfacePatch( - state: Pick< - AppState, - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'activeTabTypeByWorktree' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' - >, - worktreeId: string, - preferredGroupId?: string | null -): Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' -> { - const derived = deriveActiveSurfaceForWorktree(state, worktreeId, preferredGroupId) - return { - activeBrowserTabId: derived.activeBrowserTabId, - activeBrowserTabIdByWorktree: { - ...state.activeBrowserTabIdByWorktree, - [worktreeId]: derived.activeBrowserTabId - }, - activeFileId: derived.activeFileId, - activeFileIdByWorktree: { - ...state.activeFileIdByWorktree, - [worktreeId]: derived.activeFileId - }, - activeTabId: derived.activeTabId, - activeTabIdByWorktree: { - ...state.activeTabIdByWorktree, - [worktreeId]: derived.activeTabId - }, - activeTabType: derived.activeTabType, - activeTabTypeByWorktree: { - ...state.activeTabTypeByWorktree, - [worktreeId]: derived.activeTabType - } - } -} - -function activeSurfacePatchMatchesState( - state: Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' - >, - worktreeId: string, - patch: Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' - > -): boolean { - return ( - state.activeBrowserTabId === patch.activeBrowserTabId && - state.activeBrowserTabIdByWorktree[worktreeId] === - patch.activeBrowserTabIdByWorktree[worktreeId] && - state.activeFileId === patch.activeFileId && - state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] && - state.activeTabId === patch.activeTabId && - state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] && - state.activeTabType === patch.activeTabType && - state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId] - ) -} - -export type WorktreeTabModelReconciliation = { - patch: Partial - renderableTabCount: number - activeRenderableTabId: string | null -} - -export function projectWorktreeTabModelReconciliation( - state: AppState, - worktreeId: string -): WorktreeTabModelReconciliation { - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const groups = state.groupsByWorktree[worktreeId] ?? [] - const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const unifiedTerminalEntityIds = new Set( - unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) - ) - const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { - if (unifiedTerminalEntityIds.has(tab.id)) { - return false - } - // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. - // A session-scoped unverified-loss marker is also liveness evidence: the - // host may have disappeared before it could publish a PTY, so keep the row - // visible until a replacement binds or the user closes it. - return ( - terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) || - state.unverifiedPtyLossTabIds[tab.id] === true - ) - }) - const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) - const ensuredGroupState = - legacyRuntimeTerminalTabs.length > 0 - ? ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - state.activeGroupIdByWorktree[worktreeId] - ) - : null - const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null - const restoredLegacyTabs = - reconciliationGroup == null - ? [] - : legacyRuntimeTerminalTabs - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .map((tab) => ({ - id: tab.id, - entityId: tab.id, - groupId: reconciliationGroup.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt - })) - const reconciledUnifiedTabs = - restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs - const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] - const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) - const legacyFallbackActiveTabId = - rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) - ? rememberedLegacyActiveTabId - : (restoredLegacyTabs[0]?.id ?? null) - const reconciledGroups = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { - ...reconciliationGroup, - // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. - activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, - tabOrder: dedupeTabOrder([ - ...reconciliationGroup.tabOrder, - ...restoredLegacyTabs.map((tab) => tab.id) - ]) - }) - : groups - const liveTerminalIds = new Set( - runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) - const liveBrowserIds = new Set( - (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) - ) - - const isRenderableTab = (tab: Tab): boolean => { - if (tab.contentType === 'terminal') { - return liveTerminalIds.has(tab.entityId) - } - if (tab.contentType === 'browser') { - return liveBrowserIds.has(tab.entityId) - } - if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { - return true - } - return liveEditorIds.has(tab.entityId) - } - - const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) - const validTabIds = new Set(validTabs.map((tab) => tab.id)) - const nextGroupsWithEmpty = reconciledGroups.map((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - const activeTabId = - group.activeTabId && validTabIds.has(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - const tabOrderUnchanged = - tabOrder.length === group.tabOrder.length && - tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) - const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) - const recentUnchanged = - recentTabIds.length === (group.recentTabIds ?? []).length && - recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) - return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged - ? group - : { ...group, tabOrder, activeTabId, recentTabIds } - }) - const nextGroups = - validTabs.length > 0 - ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) - : nextGroupsWithEmpty - const currentActiveGroupId = - state.activeGroupIdByWorktree[worktreeId] ?? - ensuredGroupState?.activeGroupIdByWorktree[worktreeId] - const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) - const nextActiveGroupId = activeGroupStillExists - ? currentActiveGroupId - : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? - nextGroups[0]?.id ?? - currentActiveGroupId) - const groupsChanged = - nextGroups.length !== groups.length || - nextGroups.some((group, index) => group !== groups[index]) - const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 - const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId - const baseNextLayout = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) - : state.layoutByWorktree[worktreeId] - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const prunedNextLayout = - baseNextLayout && validGroupIds.size > 0 - ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) - : baseNextLayout - const nextLayout = - prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) - const currentLayout = state.layoutByWorktree[worktreeId] - const layoutChanged = nextLayout !== currentLayout - let patch: Partial = {} - - if ( - tabsChanged || - groupsChanged || - activeGroupChanged || - layoutChanged || - orphanTerminalIds.size > 0 - ) { - const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => - tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] - ) - let nextUnreadTerminalTabs = state.unreadTerminalTabs - if (droppedTerminalEntityIds.length > 0) { - const copy = { ...state.unreadTerminalTabs } - let changed = false - for (const entityId of droppedTerminalEntityIds) { - if (copy[entityId]) { - delete copy[entityId] - changed = true - } - } - if (changed) { - nextUnreadTerminalTabs = copy - } - } - patch = { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextLayout && layoutChanged - ? { - layoutByWorktree: { - ...state.layoutByWorktree, - // Why: restored runtime terminals need a concrete leaf before activation. - [worktreeId]: nextLayout - } - } - : {}), - ...(orphanTerminalIds.size > 0 - ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) - : {}) - } - } - - return { - patch, - renderableTabCount: validTabs.length, - activeRenderableTabId: - nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? - nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? - null - } -} - -export const createTabsSlice: StateCreator = (set, get) => ({ - unifiedTabsByWorktree: {}, - renamingTabId: null, - groupsByWorktree: {}, - activeGroupIdByWorktree: {}, - layoutByWorktree: {}, - - createUnifiedTab: (worktreeId, contentType, init) => { - const id = init?.id ?? createBrowserUuid() - let created!: Tab - set((state) => { - const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - init?.targetGroupId ?? state.activeGroupIdByWorktree[worktreeId] - ) - const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - - let nextTabs = existingTabs - let nextOrder = dedupeTabOrder(group.tabOrder) - if (init?.isPreview) { - const existingPreview = existingTabs.find( - (tab) => - tab.groupId === group.id && - tab.isPreview && - canReplacePreviewContentType(contentType, tab.contentType) - ) - if (existingPreview) { - nextTabs = existingTabs.filter((tab) => tab.id !== existingPreview.id) - nextOrder = nextOrder.filter((tabId) => tabId !== existingPreview.id) - } - } - - const shouldActivate = init?.activate ?? true - const createdAt = Date.now() - const executionHostId = - init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) - created = { - id, - entityId: init?.entityId ?? id, - groupId: group.id, - worktreeId, - ...(executionHostId ? { executionHostId } : {}), - contentType, - label: - init?.label ?? (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), - ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), - ...(init?.quickCommandLabel !== undefined - ? { quickCommandLabel: init.quickCommandLabel } - : {}), - customLabel: init?.customLabel ?? null, - color: init?.color ?? null, - sortOrder: nextOrder.length, - createdAt, - // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. - ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), - isPreview: init?.isPreview, - isPinned: init?.isPinned - } - - nextOrder = dedupeTabOrder([...nextOrder, created.id]) - const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id) - const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder) - // Why: automation-created browser tabs must paint without stealing the visible group selection from the user's current tab. - const nextRecent = shouldActivate - ? pushRecentTabId(sanitizedRecent, created.id) - : sanitizedRecent - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: [...nextTabs, created] - }, - groupsByWorktree: { - ...groupsByWorktree, - [worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], { - ...group, - activeTabId: nextActiveTabId, - tabOrder: nextOrder, - recentTabIds: nextRecent - }) - }, - activeGroupIdByWorktree, - layoutByWorktree: { - ...state.layoutByWorktree, - [worktreeId]: state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } - } - } - }) - if (init?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - return created - }, - - createUnifiedTabInSplit: (worktreeId, contentType, target, init) => { - const id = init?.id ?? createBrowserUuid() - const newGroupId = createBrowserUuid() - let created: Tab | null = null - let moved = false - set((state) => { - const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, target.sourceGroupId) - if (!sourceGroup) { - return {} - } - const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const currentGroups = state.groupsByWorktree[worktreeId] ?? [] - const shouldActivate = init?.activate ?? true - const currentLayout = - state.layoutByWorktree[worktreeId] ?? - ({ type: 'leaf', groupId: target.sourceGroupId } as const) - const createdAt = Date.now() - const executionHostId = - init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) - const createdTab: Tab = { - id, - entityId: init?.entityId ?? id, - groupId: newGroupId, - worktreeId, - ...(executionHostId ? { executionHostId } : {}), - contentType, - label: - init?.label ?? (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), - ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), - ...(init?.quickCommandLabel !== undefined - ? { quickCommandLabel: init.quickCommandLabel } - : {}), - customLabel: init?.customLabel ?? null, - color: init?.color ?? null, - sortOrder: 0, - createdAt, - // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. - ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), - isPreview: init?.isPreview, - isPinned: init?.isPinned - } - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: id, - tabOrder: [id], - recentTabIds: shouldActivate ? [id] : [] - } - created = createdTab - const replacement = buildSplitNode( - target.sourceGroupId, - newGroupId, - target.splitDirection === 'left' || target.splitDirection === 'right' - ? 'horizontal' - : 'vertical', - target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' - ) - const nextUnifiedTabsByWorktree = { - ...state.unifiedTabsByWorktree, - [worktreeId]: [...existingTabs, createdTab] - } - const nextGroupsByWorktree = { - ...state.groupsByWorktree, - [worktreeId]: [...currentGroups, newGroup] - } - const nextLayoutByWorktree = { - ...state.layoutByWorktree, - [worktreeId]: replaceLeaf(currentLayout, target.sourceGroupId, replacement) - } - const nextActiveGroupIdByWorktree = shouldActivate - ? { - ...state.activeGroupIdByWorktree, - [worktreeId]: newGroupId - } - : state.activeGroupIdByWorktree - moved = true - return { - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - ...(shouldActivate && state.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...state, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - newGroupId - ) - : {}) - } - }) - if (created && init?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - if (moved && init?.recordInteraction !== false) { - get().recordFeatureInteraction?.('tab-splits') - } - return created - }, - - getTab: (tabId) => findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab ?? null, - - getActiveTab: (worktreeId) => { - const state = get() - const groupId = state.activeGroupIdByWorktree[worktreeId] - const group = (state.groupsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === groupId - ) - if (!group?.activeTabId) { - return null - } - return ( - (state.unifiedTabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === group.activeTabId) ?? - null - ) - }, - - findTabForEntityInGroup: (worktreeId, groupId, entityId, contentType) => - findTabByEntityInGroup(get().unifiedTabsByWorktree, worktreeId, groupId, entityId, contentType), - - activateTab: (tabId, opts) => { - set((state) => { - const scopedWorktreeId = opts?.worktreeId - let found: ReturnType - if (scopedWorktreeId !== undefined) { - const scopedTabs = Object.hasOwn(state.unifiedTabsByWorktree, scopedWorktreeId) - ? state.unifiedTabsByWorktree[scopedWorktreeId] - : [] - const scopedTab = scopedTabs.find((tab) => tab.id === tabId) - found = scopedTab ? { tab: scopedTab, worktreeId: scopedWorktreeId } : null - } else { - found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - } - if (!found) { - return {} - } - const { tab, worktreeId } = found - // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. - // Why (activeWorktree guard below): only when the tab is in the active worktree, else the unseen signal is lost (mirrors focusGroup). - const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null - const nextUnreadTerminalTabs = - state.activeWorktreeId === worktreeId && - terminalEntityId && - state.unreadTerminalTabs[terminalEntityId] - ? (() => { - const copy = { ...state.unreadTerminalTabs } - delete copy[terminalEntityId] - return copy - })() - : state.unreadTerminalTabs - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => - item.id === tabId - ? { - ...item, - isPreview: opts?.preservePreview ? item.isPreview : false, - lastFocusedAt: Date.now() - } - : item - ) - }, - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => - group.id === tab.groupId - ? { - ...group, - activeTabId: tabId, - // Why: track every activation in the group's MRU so closeUnifiedTab returns to the previous tab; sanitize to prune removed ids. - recentTabIds: pushRecentTabId( - sanitizeRecentTabIds(group.recentTabIds, group.tabOrder), - tabId - ) - } - : group - ) - }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: tab.groupId - }, - // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}) - } - }) - }, - - closeUnifiedTab: (tabId, opts) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return null - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return null - } - - if (tab.contentType === 'terminal' && !opts?.terminalRetirementHandled) { - const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) - const wasLastTab = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)).length === 0 - // Why: unified-only hydrated tabs still own provider sessions without a legacy row, so retire every terminal close by entity id. - get().closeTab(tab.entityId, { recordInteraction: opts?.recordInteraction }) - return { closedTabId: tabId, wasLastTab, worktreeId } - } - - const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) - const remainingOrder = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)) - const wasLastTab = remainingOrder.length === 0 - // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. - const nextActiveTabId = - group.activeTabId === tabId - ? wasLastTab - ? null - : pickNextActiveTab(dedupedGroupOrder, group.recentTabIds, tabId) - : group.activeTabId - const nextRecentTabIds = sanitizeRecentTabIds( - (group.recentTabIds ?? []).filter((id) => id !== tabId), - remainingOrder - ) - const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null - - set((current) => { - const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( - (item) => item.id !== tabId - ) - // Why: close-to-right/others bypass terminals.closeTab, so clear the entityId-keyed unread flag here or a stale dot leaks. - let nextUnreadTerminalTabs = current.unreadTerminalTabs - if (terminalEntityId && current.unreadTerminalTabs[terminalEntityId]) { - nextUnreadTerminalTabs = { ...current.unreadTerminalTabs } - delete nextUnreadTerminalTabs[terminalEntityId] - } - let nextGroups = (current.groupsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === group.id - ? { - ...candidate, - activeTabId: nextActiveTabId, - tabOrder: remainingOrder, - recentTabIds: nextRecentTabIds - } - : candidate - ) - let nextLayoutByWorktree = current.layoutByWorktree - let nextActiveGroupIdByWorktree = current.activeGroupIdByWorktree - if (wasLastTab && current.layoutByWorktree[worktreeId] && nextGroups.length > 1) { - nextGroups = nextGroups.filter((candidate) => candidate.id !== group.id) - const collapsedState = collapseGroupLayout( - current.layoutByWorktree, - current.activeGroupIdByWorktree, - worktreeId, - group.id, - nextGroups[0]?.id ?? null - ) - nextLayoutByWorktree = collapsedState.layoutByWorktree - nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree - } - // Why: the landing fallback answers "the user emptied this worktree". An unwound create - // never added a tab, so it must leave the selection exactly as the click found it. - const shouldDeactivateWorktree = - !opts?.preserveWorktreeSelection && - current.activeWorktreeId === worktreeId && - nextTabs.length === 0 && - (current.tabsByWorktree[worktreeId] ?? []).length === 0 && - (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && - !current.openFiles.some((file) => file.worktreeId === worktreeId) - return { - unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, - groupsByWorktree: { - ...current.groupsByWorktree, - [worktreeId]: nextGroups - }, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. - ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - // Why: closing the last tab can leave the worktree selected but render-empty, so write the landing-state fallback directly. - ...(shouldDeactivateWorktree - ? { - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - activeTabId: null, - activeBrowserTabId: null, - activeFileId: null, - activeTabType: 'terminal' as const, - activeTabIdByWorktree: { - ...current.activeTabIdByWorktree, - [worktreeId]: null - }, - activeBrowserTabIdByWorktree: { - ...current.activeBrowserTabIdByWorktree, - [worktreeId]: null - }, - activeFileIdByWorktree: { - ...current.activeFileIdByWorktree, - [worktreeId]: null - }, - activeTabTypeByWorktree: { - ...current.activeTabTypeByWorktree, - [worktreeId]: 'terminal' - } - } - : {}), - ...(!shouldDeactivateWorktree && current.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...current, - unifiedTabsByWorktree: { - ...current.unifiedTabsByWorktree, - [worktreeId]: nextTabs - }, - groupsByWorktree: { - ...current.groupsByWorktree, - [worktreeId]: nextGroups - }, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - nextActiveGroupIdByWorktree[worktreeId] ?? null - ) - : {}) - } - }) - - if (opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - return { closedTabId: tabId, wasLastTab, worktreeId } - }, - - reorderUnifiedTabs: (groupId, tabIds, opts) => { - let reordered = false - set((state) => { - for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) { - const group = groups.find((candidate) => candidate.id === groupId) - if (!group) { - continue - } - // Why: dedupe at the store boundary so each tab keeps one canonical position and later group ops don't branch on duplicate ids. - const nextTabOrder = dedupeTabOrder(tabIds) - reordered = true - const orderMap = new Map(nextTabOrder.map((id, index) => [id, index])) - return { - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: updateGroup(groups, { ...group, tabOrder: nextTabOrder }) - }, - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => { - const sortOrder = orderMap.get(tab.id) - return sortOrder === undefined ? tab : { ...tab, sortOrder } - }) - } - } - } - return {} - }) - if (reordered && opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - setTabLabel: (tabId, label) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) - }, - - setTabViewMode: (tabId, mode) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) - mirrorTabViewModeToHost(get(), tabId, mode) - }, - - toggleTabViewMode: (tabId) => { - let toggled: { - from: 'terminal' | 'chat' - to: 'terminal' | 'chat' - agent: TuiAgent | null - } | null = null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. - const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' - const nextMode = fromMode === 'chat' ? 'terminal' : 'chat' - // Why: launchAgent lives on the legacy terminal tab (keyed by entityId); resolve it here so toggle telemetry can attribute by agent. - const agent = - (state.tabsByWorktree[found.worktreeId] ?? []).find( - (terminal) => terminal.id === found.tab.entityId - )?.launchAgent ?? null - toggled = { from: fromMode, to: nextMode, agent } - return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} - }) - // Why: emit after the state write so the event reflects the committed mode. - const committed = toggled as { - from: 'terminal' | 'chat' - to: 'terminal' | 'chat' - agent: TuiAgent | null - } | null - if (committed) { - emitNativeChatToggled(committed) - mirrorTabViewModeToHost(get(), tabId, committed.to) - } - }, - - setRenamingTabId: (tabId) => { - set({ renamingTabId: tabId }) - }, - - setTabCustomLabel: (tabId, label, opts) => { - const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) - if (exists && opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - setUnifiedTabColor: (tabId, color) => { - const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) - if (exists) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - pinTab: (tabId) => { - const exists = get().getTab(tabId) !== null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - const { tab, worktreeId } = found - const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, isPinned: true, isPreview: false } : candidate - ) - const groups = state.groupsByWorktree[worktreeId] ?? [] - const group = groups.find((candidate) => candidate.id === tab.groupId) - if (!group) { - return { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } - } - } - const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) - }, - // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: updateGroup(groups, { ...group, tabOrder }) - } - } - }) - mirrorTabPinnedToHost(get(), tabId, true) - if (exists) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - unpinTab: (tabId) => { - const exists = get().getTab(tabId) !== null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - const { tab, worktreeId } = found - const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, isPinned: false } : candidate - ) - const groups = state.groupsByWorktree[worktreeId] ?? [] - const group = groups.find((candidate) => candidate.id === tab.groupId) - if (!group) { - return { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } - } - } - const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) - }, - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: updateGroup(groups, { ...group, tabOrder }) - } - } - }) - mirrorTabPinnedToHost(get(), tabId, false) - if (exists) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - closeOtherTabs: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - const closedIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) - .filter((item) => item.groupId === group.id && item.id !== tabId && !item.isPinned) - .map((item) => item.id) - for (const id of closedIds) { - get().closeUnifiedTab(id) - } - return closedIds - }, - - closeTabsToRight: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - const index = group.tabOrder.indexOf(tabId) - if (index === -1) { - return [] - } - const closableIds = group.tabOrder - .slice(index + 1) - .filter( - (id) => - !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) - ?.isPinned - ) - for (const id of closableIds) { - get().closeUnifiedTab(id) - } - return closableIds - }, - - closeTabsToLeft: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - const index = group.tabOrder.indexOf(tabId) - if (index === -1) { - return [] - } - const closableIds = group.tabOrder - .slice(0, index) - .filter( - (id) => - !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) - ?.isPinned - ) - for (const id of closableIds) { - get().closeUnifiedTab(id) - } - return closableIds - }, - - ensureWorktreeRootGroup: (worktreeId) => { - const existingGroups = get().groupsByWorktree[worktreeId] ?? [] - if (existingGroups.length > 0) { - return get().activeGroupIdByWorktree[worktreeId] ?? existingGroups[0].id - } - - const groupId = createBrowserUuid() - set((state) => ({ - // Why: a zero-tab worktree still needs a canonical root group so new tabs and splits land in a deterministic place. - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] - }, - layoutByWorktree: { - ...state.layoutByWorktree, - [worktreeId]: { type: 'leaf', groupId } - }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: groupId - } - })) - return groupId - }, - - focusGroup: (worktreeId, groupId) => - set((state) => { - const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId - const nextActiveGroupIdByWorktree = groupAlreadyFocused - ? state.activeGroupIdByWorktree - : { - ...state.activeGroupIdByWorktree, - [worktreeId]: groupId - } - // Why: focusing a group surfaces its active terminal tab, so dismiss the tab-level bell. - // Why (activeWorktree guard below): only when the group is in the active worktree, else the unseen tab's bell is swallowed. - if (state.activeWorktreeId !== worktreeId) { - if (groupAlreadyFocused) { - return state - } - return { - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - } - } - const groups = state.groupsByWorktree[worktreeId] ?? [] - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const visibleTerminalEntityIds = new Set( - groups - .map((group) => - group.activeTabId ? unifiedTabs.find((tab) => tab.id === group.activeTabId) : null - ) - .filter((tab): tab is (typeof unifiedTabs)[number] => tab?.contentType === 'terminal') - .map((tab) => tab.entityId) - ) - const nextUnreadTerminalTabs = - visibleTerminalEntityIds.size > 0 - ? (() => { - let changed = false - const copy = { ...state.unreadTerminalTabs } - for (const terminalEntityId of visibleTerminalEntityIds) { - if (!copy[terminalEntityId]) { - continue - } - delete copy[terminalEntityId] - changed = true - } - return changed ? copy : state.unreadTerminalTabs - })() - : state.unreadTerminalTabs - const activeSurfacePatch = buildActiveSurfacePatch( - { - ...state, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - groupId - ) - if ( - groupAlreadyFocused && - nextUnreadTerminalTabs === state.unreadTerminalTabs && - activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch) - ) { - return state - } - return { - ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }), - // Why: only write unreadTerminalTabs when it changed — preserving the reference keeps selectors/subscribers from firing spuriously. - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...activeSurfacePatch - } - }), - - closeEmptyGroup: (worktreeId, groupId) => { - const state = get() - const group = (state.groupsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === groupId - ) - if (!group || group.tabOrder.length > 0) { - return false - } - set((current) => { - const remainingGroups = (current.groupsByWorktree[worktreeId] ?? []).filter( - (candidate) => candidate.id !== groupId - ) - const collapsedState = collapseGroupLayout( - current.layoutByWorktree, - current.activeGroupIdByWorktree, - worktreeId, - groupId, - remainingGroups[0]?.id ?? null - ) - // Why: drop the dead group's recent-quick-command entry so the map can't grow unbounded as groups open/close. - const { [groupId]: _droppedRecent, ...remainingRecent } = current.recentQuickCommandIdByGroup - return { - groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups }, - layoutByWorktree: collapsedState.layoutByWorktree, - activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree, - recentQuickCommandIdByGroup: remainingRecent, - ...(current.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...current, - groupsByWorktree: { - ...current.groupsByWorktree, - [worktreeId]: remainingGroups - }, - layoutByWorktree: collapsedState.layoutByWorktree, - activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree - }, - worktreeId, - collapsedState.activeGroupIdByWorktree[worktreeId] ?? null - ) - : {}) - } - }) - return true - }, - - createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => { - const newGroupId = createBrowserUuid() - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: null, - tabOrder: [] - } - const shouldActivate = opts?.activate !== false - set((state) => { - const existing = state.groupsByWorktree[worktreeId] ?? [] - const currentLayout = - state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: sourceGroupId } as const) - const replacement = buildSplitNode( - sourceGroupId, - newGroupId, - direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical', - direction === 'left' || direction === 'up' ? 'first' : 'second' - ) - return { - groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, - layoutByWorktree: { - ...state.layoutByWorktree, - [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) - }, - ...(shouldActivate - ? { - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: newGroupId - } - } - : {}) - } - }) - get().recordFeatureInteraction?.('terminal-panes') - return newGroupId - }, - - moveUnifiedTabToGroup: (tabId, targetGroupId, opts) => { - let moved = false - set((state) => { - const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) - if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} - } - const { tab, worktreeId } = foundTab - if (tab.groupId === targetGroupId) { - return {} - } - const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - const targetGroup = foundTarget.group - if (!sourceGroup) { - return {} - } - moved = true - - const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) - const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) - // Why: defensive dedupe so target order can't grow a duplicate id (stale state); see dropUnifiedTab for the same guard. - const targetOrder = dedupeTabOrder(targetGroup.tabOrder.filter((id) => id !== tabId)) - const targetIndex = Math.max( - 0, - Math.min(opts?.index ?? targetOrder.length, targetOrder.length) - ) - targetOrder.splice(targetIndex, 0, tabId) - const nextActiveGroupIdByWorktree = { - ...state.activeGroupIdByWorktree, - [worktreeId]: opts?.activate ? targetGroupId : state.activeGroupIdByWorktree[worktreeId] - } - const sourceRecentTabIds = sanitizeRecentTabIds( - (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), - sourceOrder - ) - const nextGroups = (state.groupsByWorktree[worktreeId] ?? []).map((group) => { - if (group.id === sourceGroup.id) { - return { - ...group, - activeTabId: - group.activeTabId === tabId - ? // Why: keep MRU-aware selection so the user lands on their previously-focused tab, not a visual neighbor. - pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) - : group.activeTabId, - tabOrder: sourceOrder, - recentTabIds: sourceRecentTabIds - } - } - if (group.id === targetGroupId) { - const sanitizedTargetRecent = sanitizeRecentTabIds(group.recentTabIds, targetOrder) - return { - ...group, - activeTabId: opts?.activate ? tabId : group.activeTabId, - tabOrder: targetOrder, - recentTabIds: opts?.activate - ? pushRecentTabId(sanitizedTargetRecent, tabId) - : sanitizedTargetRecent - } - } - return group - }) - let nextLayoutByWorktree = state.layoutByWorktree - let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree - let filteredGroups = nextGroups - if (sourceOrder.length === 0) { - filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) - const collapsedState = collapseGroupLayout( - nextLayoutByWorktree, - nextActiveGroupIdByWorktreeResolved, - worktreeId, - sourceGroup.id, - targetGroupId - ) - nextLayoutByWorktree = collapsedState.layoutByWorktree - nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree - } - const nextGroupsByWorktree = { - ...state.groupsByWorktree, - [worktreeId]: filteredGroups - } - const nextUnifiedTabsByWorktree = { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate - ) - } - return { - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved, - ...(state.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...state, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved - }, - worktreeId, - nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null - ) - : {}) - } - }) - if (moved && opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('tab-splits') - } - return moved - }, - - dropUnifiedTab: (tabId, target) => { - let moved = false - set((state) => { - const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) - if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} - } - - const { tab, worktreeId } = foundTab - const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - const targetGroup = foundTarget.group - if (!sourceGroup) { - return {} - } - - const isSplitDrop = Boolean(target.splitDirection) - if (!isSplitDrop && tab.groupId === target.groupId) { - return {} - } - const layout = state.layoutByWorktree[worktreeId] - if ( - isSplitDrop && - isPaneColumnSplitDropNoOp({ - sourceGroupId: sourceGroup.id, - targetGroupId: target.groupId, - splitDirection: target.splitDirection!, - sourceTabCount: sourceGroup.tabOrder.length, - layout - }) - ) { - // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. - return {} - } - - moved = true - - let nextGroups = state.groupsByWorktree[worktreeId] ?? [] - let nextLayoutByWorktree = state.layoutByWorktree - let nextActiveGroupIdByWorktree = state.activeGroupIdByWorktree - let resolvedTargetGroupId = target.groupId - - if (target.splitDirection) { - const newGroupId = createBrowserUuid() - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: null, // Placeholder; properly set in the nextGroups.map() below - tabOrder: [] - } - const currentLayout = - nextLayoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: target.groupId } as const) - const replacement = buildSplitNode( - target.groupId, - newGroupId, - target.splitDirection === 'left' || target.splitDirection === 'right' - ? 'horizontal' - : 'vertical', - target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' - ) - - resolvedTargetGroupId = newGroupId - nextGroups = [...nextGroups, newGroup] - nextLayoutByWorktree = { - ...nextLayoutByWorktree, - [worktreeId]: replaceLeaf(currentLayout, target.groupId, replacement) - } - nextActiveGroupIdByWorktree = { - ...nextActiveGroupIdByWorktree, - [worktreeId]: newGroupId - } - } - - const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) - const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) - const destinationGroup = - nextGroups.find((group) => group.id === resolvedTargetGroupId) ?? targetGroup - // Why: target order may already hold this tab id (racey write / same-group split); dedupe first or React hits a duplicate key. - const targetOrder = dedupeTabOrder(destinationGroup.tabOrder.filter((id) => id !== tabId)) - const targetIndex = Math.max( - 0, - Math.min(target.index ?? targetOrder.length, targetOrder.length) - ) - targetOrder.splice(targetIndex, 0, tabId) - - const sourceRecentTabIds = sanitizeRecentTabIds( - (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), - sourceOrder - ) - nextGroups = nextGroups.map((group) => { - if (group.id === sourceGroup.id) { - return { - ...group, - activeTabId: - group.activeTabId === tabId - ? // Why: same MRU-aware fallback as moveUnifiedTabToGroup — the drag keeps the user on their previously-active tab. - pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) - : group.activeTabId, - tabOrder: sourceOrder, - recentTabIds: sourceRecentTabIds - } - } - if (group.id === resolvedTargetGroupId) { - return { - ...group, - activeTabId: tabId, - tabOrder: targetOrder, - recentTabIds: pushRecentTabId( - sanitizeRecentTabIds(group.recentTabIds, targetOrder), - tabId - ) - } - } - return group - }) - - if (sourceOrder.length === 0) { - nextGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) - const collapsedState = collapseGroupLayout( - nextLayoutByWorktree, - nextActiveGroupIdByWorktree, - worktreeId, - sourceGroup.id, - resolvedTargetGroupId - ) - nextLayoutByWorktree = collapsedState.layoutByWorktree - nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree - } else { - nextActiveGroupIdByWorktree = { - ...nextActiveGroupIdByWorktree, - [worktreeId]: resolvedTargetGroupId - } - } - - const nextUnifiedTabsByWorktree = { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, groupId: resolvedTargetGroupId } : candidate - ) - } - const nextGroupsByWorktree = { - ...state.groupsByWorktree, - [worktreeId]: nextGroups - } - - return { - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - ...(state.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...state, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - resolvedTargetGroupId - ) - : {}) - } - }) - if (moved) { - get().recordFeatureInteraction?.('terminal-tabs') - get().recordFeatureInteraction?.('tab-splits') - } - return moved - }, - - copyUnifiedTabToGroup: (tabId, targetGroupId, init) => { - const foundTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) - const foundTarget = findGroupAndWorktree(get().groupsByWorktree, targetGroupId) - if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return null - } - const { tab, worktreeId } = foundTab - return get().createUnifiedTab(worktreeId, tab.contentType, { - entityId: init?.entityId ?? tab.entityId, - executionHostId: tab.executionHostId, - label: init?.label ?? tab.label, - generatedLabel: init?.generatedLabel ?? tab.generatedLabel, - quickCommandLabel: init?.quickCommandLabel ?? tab.quickCommandLabel, - customLabel: init?.customLabel ?? tab.customLabel, - color: init?.color ?? tab.color, - isPinned: init?.isPinned ?? tab.isPinned, - id: init?.id, - targetGroupId - }) - }, - - mergeGroupIntoSibling: (worktreeId, groupId) => { - const state = get() - const groups = state.groupsByWorktree[worktreeId] ?? [] - const sourceGroup = groups.find((candidate) => candidate.id === groupId) - const layout = state.layoutByWorktree[worktreeId] - if (!sourceGroup || !layout || groups.length <= 1) { - return null - } - const targetGroupId = findSiblingGroupId(layout, groupId) - if (!targetGroupId) { - return null - } - - const orderedSourceTabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( - (tab) => tab.groupId === groupId - ) - for (const tabId of sourceGroup.tabOrder) { - const item = orderedSourceTabs.find((tab) => tab.id === tabId) - if (!item) { - continue - } - get().moveUnifiedTabToGroup(item.id, targetGroupId, { recordInteraction: false }) - } - get().closeEmptyGroup(worktreeId, groupId) - get().recordFeatureInteraction?.('terminal-panes') - return targetGroupId - }, - - setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => { - set((state) => { - const currentLayout = state.layoutByWorktree[worktreeId] - if (!currentLayout) { - return state - } - // Why: an unchanged ratio must not mint fresh root state — every store - // subscriber wakes on the new reference (STA-3328). - let targetNode: TabGroupLayoutNode | undefined = currentLayout - for (const segment of nodePath.length > 0 ? nodePath.split('.') : []) { - targetNode = - targetNode && targetNode.type === 'split' && (segment === 'first' || segment === 'second') - ? targetNode[segment] - : undefined - } - if (!targetNode || targetNode.type !== 'split' || (targetNode.ratio ?? 0.5) === ratio) { - return state - } - return { - layoutByWorktree: { - ...state.layoutByWorktree, - // Why: split ratios belong to the tab-group model (not transient UI), so persist them for restores and multi-step group ops. - [worktreeId]: updateSplitRatio( - currentLayout, - nodePath.length > 0 ? nodePath.split('.') : [], - ratio - ) - } - } - }) - }, - - reconcileWorktreeTabModel: (worktreeId) => { - const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) - if (Object.keys(reconciliation.patch).length > 0) { - set(reconciliation.patch) - } - return { - renderableTabCount: reconciliation.renderableTabCount, - activeRenderableTabId: reconciliation.activeRenderableTabId - } - }, - - hydrateTabsSession: (session, options) => { - const state = get() - const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session) - const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) - validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of state.folderWorkspaces) { - validWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - const hydrated = buildHydratedTabState(session, validWorktreeIds) - if (!options?.replaceWorkspaceKeys) { - set(hydrated) - return - } - const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) - set((current) => ({ - unifiedTabsByWorktree: replaceWorkspaceRecordKeys( - current.unifiedTabsByWorktree, - hydrated.unifiedTabsByWorktree, - replaceWorkspaceKeys - ), - groupsByWorktree: replaceWorkspaceRecordKeys( - current.groupsByWorktree, - hydrated.groupsByWorktree, - replaceWorkspaceKeys - ), - activeGroupIdByWorktree: replaceWorkspaceRecordKeys( - current.activeGroupIdByWorktree, - hydrated.activeGroupIdByWorktree, - replaceWorkspaceKeys - ), - layoutByWorktree: replaceWorkspaceRecordKeys( - current.layoutByWorktree, - hydrated.layoutByWorktree, - replaceWorkspaceKeys - ) - })) - } -}) +export type { + TabSplitDirection, + TabsSlice, + TabsSliceGet, + TabsSliceSet +} from './tabs/tabs-slice-contract' +export { createTabsSlice } from './tabs/create-tabs-slice' +export { findSiblingGroupId } from './tabs/tabs-layout' +export { + type WorktreeTabModelReconciliation, + projectWorktreeTabModelReconciliation +} from './tabs/tabs-reconciliation' diff --git a/src/renderer/src/store/slices/tabs/create-tabs-slice.ts b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts new file mode 100644 index 00000000000..799cd257339 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts @@ -0,0 +1,31 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { TabsSlice } from './tabs-slice-contract' +import { createTabsCreateActions } from './tabs-create-actions' +import { createTabsFocusActions } from './tabs-focus-actions' +import { createTabsCloseActions } from './tabs-close-actions' +import { createTabsBulkCloseActions } from './tabs-bulk-close-actions' +import { createTabsLabelActions } from './tabs-label-actions' +import { createTabsGroupActions } from './tabs-group-actions' +import { createTabsMoveActions } from './tabs-move-actions' +import { createTabsDropActions } from './tabs-drop-actions' +import { createTabsSecondaryActions } from './tabs-secondary-actions' +import { createTabsSessionActions } from './tabs-session-actions' + +export const createTabsSlice: StateCreator = (set, get) => ({ + unifiedTabsByWorktree: {}, + renamingTabId: null, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + ...createTabsCreateActions(set, get), + ...createTabsFocusActions(set, get), + ...createTabsCloseActions(set, get), + ...createTabsBulkCloseActions(set, get), + ...createTabsLabelActions(set, get), + ...createTabsGroupActions(set, get), + ...createTabsMoveActions(set, get), + ...createTabsDropActions(set, get), + ...createTabsSecondaryActions(set, get), + ...createTabsSessionActions(set, get) +}) diff --git a/src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts new file mode 100644 index 00000000000..65393c43315 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts @@ -0,0 +1,87 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { findGroupForTab, findTabAndWorktree } from '../tab-group-state' + +export function createTabsBulkCloseActions( + _set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + closeOtherTabs: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const closedIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((item) => item.groupId === group.id && item.id !== tabId && !item.isPinned) + .map((item) => item.id) + for (const id of closedIds) { + get().closeUnifiedTab(id) + } + return closedIds + }, + + closeTabsToRight: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { + return [] + } + const closableIds = group.tabOrder + .slice(index + 1) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === id + )?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + }, + + closeTabsToLeft: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { + return [] + } + const closableIds = group.tabOrder + .slice(0, index) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === id + )?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts new file mode 100644 index 00000000000..c89b0953f5d --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -0,0 +1,165 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { collapseGroupLayout } from './tabs-layout' +import { + dedupeTabOrder, + findGroupForTab, + findTabAndWorktree, + pickNextActiveTab, + sanitizeRecentTabIds +} from '../tab-group-state' +import { buildActiveSurfacePatch } from './tabs-surface' + +export function createTabsCloseActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + closeUnifiedTab: (tabId, opts) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return null + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return null + } + + if (tab.contentType === 'terminal' && !opts?.terminalRetirementHandled) { + const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) + const wasLastTab = + dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)).length === 0 + // Why: unified-only hydrated tabs still own provider sessions without a legacy row, so retire every terminal close by entity id. + get().closeTab(tab.entityId, { recordInteraction: opts?.recordInteraction }) + return { closedTabId: tabId, wasLastTab, worktreeId } + } + + const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) + const remainingOrder = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)) + const wasLastTab = remainingOrder.length === 0 + // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. + const nextActiveTabId = + group.activeTabId === tabId + ? wasLastTab + ? null + : pickNextActiveTab(dedupedGroupOrder, group.recentTabIds, tabId) + : group.activeTabId + const nextRecentTabIds = sanitizeRecentTabIds( + (group.recentTabIds ?? []).filter((id) => id !== tabId), + remainingOrder + ) + const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null + + set((current) => { + const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (item) => item.id !== tabId + ) + // Why: close-to-right/others bypass terminals.closeTab, so clear the entityId-keyed unread flag here or a stale dot leaks. + let nextUnreadTerminalTabs = current.unreadTerminalTabs + if (terminalEntityId && current.unreadTerminalTabs[terminalEntityId]) { + nextUnreadTerminalTabs = { ...current.unreadTerminalTabs } + delete nextUnreadTerminalTabs[terminalEntityId] + } + let nextGroups = (current.groupsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === group.id + ? { + ...candidate, + activeTabId: nextActiveTabId, + tabOrder: remainingOrder, + recentTabIds: nextRecentTabIds + } + : candidate + ) + let nextLayoutByWorktree = current.layoutByWorktree + let nextActiveGroupIdByWorktree = current.activeGroupIdByWorktree + if (wasLastTab && current.layoutByWorktree[worktreeId] && nextGroups.length > 1) { + nextGroups = nextGroups.filter((candidate) => candidate.id !== group.id) + const collapsedState = collapseGroupLayout( + current.layoutByWorktree, + current.activeGroupIdByWorktree, + worktreeId, + group.id, + nextGroups[0]?.id ?? null + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree + } + // Why: the landing fallback answers "the user emptied this worktree". An unwound create + // never added a tab, so it must leave the selection exactly as the click found it. + const shouldDeactivateWorktree = + !opts?.preserveWorktreeSelection && + current.activeWorktreeId === worktreeId && + nextTabs.length === 0 && + (current.tabsByWorktree[worktreeId] ?? []).length === 0 && + (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && + !current.openFiles.some((file) => file.worktreeId === worktreeId) + return { + unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, + groupsByWorktree: { + ...current.groupsByWorktree, + [worktreeId]: nextGroups + }, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. + ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + // Why: closing the last tab can leave the worktree selected but render-empty, so write the landing-state fallback directly. + ...(shouldDeactivateWorktree + ? { + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null, + activeBrowserTabId: null, + activeFileId: null, + activeTabType: 'terminal' as const, + activeTabIdByWorktree: { + ...current.activeTabIdByWorktree, + [worktreeId]: null + }, + activeBrowserTabIdByWorktree: { + ...current.activeBrowserTabIdByWorktree, + [worktreeId]: null + }, + activeFileIdByWorktree: { + ...current.activeFileIdByWorktree, + [worktreeId]: null + }, + activeTabTypeByWorktree: { + ...current.activeTabTypeByWorktree, + [worktreeId]: 'terminal' + } + } + : {}), + ...(!shouldDeactivateWorktree && current.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...current, + unifiedTabsByWorktree: { + ...current.unifiedTabsByWorktree, + [worktreeId]: nextTabs + }, + groupsByWorktree: { + ...current.groupsByWorktree, + [worktreeId]: nextGroups + }, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + nextActiveGroupIdByWorktree[worktreeId] ?? null + ) + : {}) + } + }) + + if (opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return { closedTabId: tabId, wasLastTab, worktreeId } + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts new file mode 100644 index 00000000000..b4f1d611b2f --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -0,0 +1,221 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import { getActiveExecutionHostIdForWorktree } from '@/lib/unified-tab-host-ownership' +import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { buildActiveSurfacePatch } from './tabs-surface' +import { buildSplitNode, replaceLeaf } from './tabs-layout' +import { + dedupeTabOrder, + ensureGroup, + findGroupForTab, + pushRecentTabId, + sanitizeRecentTabIds, + updateGroup +} from '../tab-group-state' +import { canReplacePreviewContentType } from './tabs-tab-order' + +export function createTabsCreateActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + createUnifiedTab: (worktreeId, contentType, init) => { + const id = init?.id ?? createBrowserUuid() + let created!: Tab + set((state) => { + const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + init?.targetGroupId ?? state.activeGroupIdByWorktree[worktreeId] + ) + const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + + let nextTabs = existingTabs + let nextOrder = dedupeTabOrder(group.tabOrder) + if (init?.isPreview) { + const existingPreview = existingTabs.find( + (tab) => + tab.groupId === group.id && + tab.isPreview && + canReplacePreviewContentType(contentType, tab.contentType) + ) + if (existingPreview) { + nextTabs = existingTabs.filter((tab) => tab.id !== existingPreview.id) + nextOrder = nextOrder.filter((tabId) => tabId !== existingPreview.id) + } + } + + const shouldActivate = init?.activate ?? true + const createdAt = Date.now() + const executionHostId = + init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) + created = { + id, + entityId: init?.entityId ?? id, + groupId: group.id, + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + contentType, + label: + init?.label ?? + (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), + ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), + ...(init?.quickCommandLabel !== undefined + ? { quickCommandLabel: init.quickCommandLabel } + : {}), + customLabel: init?.customLabel ?? null, + color: init?.color ?? null, + sortOrder: nextOrder.length, + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), + isPreview: init?.isPreview, + isPinned: init?.isPinned + } + + nextOrder = dedupeTabOrder([...nextOrder, created.id]) + const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id) + const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder) + // Why: automation-created browser tabs must paint without stealing the visible group selection from the user's current tab. + const nextRecent = shouldActivate + ? pushRecentTabId(sanitizedRecent, created.id) + : sanitizedRecent + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...nextTabs, created] + }, + groupsByWorktree: { + ...groupsByWorktree, + [worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], { + ...group, + activeTabId: nextActiveTabId, + tabOrder: nextOrder, + recentTabIds: nextRecent + }) + }, + activeGroupIdByWorktree, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } + } + } + }) + if (init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return created + }, + + createUnifiedTabInSplit: (worktreeId, contentType, target, init) => { + const id = init?.id ?? createBrowserUuid() + const newGroupId = createBrowserUuid() + let created: Tab | null = null + let moved = false + set((state) => { + const sourceGroup = findGroupForTab( + state.groupsByWorktree, + worktreeId, + target.sourceGroupId + ) + if (!sourceGroup) { + return {} + } + const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const currentGroups = state.groupsByWorktree[worktreeId] ?? [] + const shouldActivate = init?.activate ?? true + const currentLayout = + state.layoutByWorktree[worktreeId] ?? + ({ type: 'leaf', groupId: target.sourceGroupId } as const) + const createdAt = Date.now() + const executionHostId = + init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) + const createdTab: Tab = { + id, + entityId: init?.entityId ?? id, + groupId: newGroupId, + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + contentType, + label: + init?.label ?? + (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), + ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), + ...(init?.quickCommandLabel !== undefined + ? { quickCommandLabel: init.quickCommandLabel } + : {}), + customLabel: init?.customLabel ?? null, + color: init?.color ?? null, + sortOrder: 0, + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), + isPreview: init?.isPreview, + isPinned: init?.isPinned + } + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: id, + tabOrder: [id], + recentTabIds: shouldActivate ? [id] : [] + } + created = createdTab + const replacement = buildSplitNode( + target.sourceGroupId, + newGroupId, + target.splitDirection === 'left' || target.splitDirection === 'right' + ? 'horizontal' + : 'vertical', + target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' + ) + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...existingTabs, createdTab] + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: [...currentGroups, newGroup] + } + const nextLayoutByWorktree = { + ...state.layoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, target.sourceGroupId, replacement) + } + const nextActiveGroupIdByWorktree = shouldActivate + ? { + ...state.activeGroupIdByWorktree, + [worktreeId]: newGroupId + } + : state.activeGroupIdByWorktree + moved = true + return { + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + ...(shouldActivate && state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + newGroupId + ) + : {}) + } + }) + if (created && init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + if (moved && init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('tab-splits') + } + return created + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts new file mode 100644 index 00000000000..2cadf76365e --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -0,0 +1,194 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { TabGroup } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { isPaneColumnSplitDropNoOp } from '../pane-column-split-drop-no-op' +import { collapseGroupLayout, buildSplitNode, replaceLeaf } from './tabs-layout' +import { buildActiveSurfacePatch } from './tabs-surface' +import { + dedupeTabOrder, + findGroupAndWorktree, + findGroupForTab, + findTabAndWorktree, + pickNextActiveTab, + pushRecentTabId, + sanitizeRecentTabIds +} from '../tab-group-state' + +export function createTabsDropActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + dropUnifiedTab: (tabId, target) => { + let moved = false + set((state) => { + const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return {} + } + + const { tab, worktreeId } = foundTab + const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + const targetGroup = foundTarget.group + if (!sourceGroup) { + return {} + } + + const isSplitDrop = Boolean(target.splitDirection) + if (!isSplitDrop && tab.groupId === target.groupId) { + return {} + } + const layout = state.layoutByWorktree[worktreeId] + if ( + isSplitDrop && + isPaneColumnSplitDropNoOp({ + sourceGroupId: sourceGroup.id, + targetGroupId: target.groupId, + splitDirection: target.splitDirection!, + sourceTabCount: sourceGroup.tabOrder.length, + layout + }) + ) { + // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. + return {} + } + + moved = true + + let nextGroups = state.groupsByWorktree[worktreeId] ?? [] + let nextLayoutByWorktree = state.layoutByWorktree + let nextActiveGroupIdByWorktree = state.activeGroupIdByWorktree + let resolvedTargetGroupId = target.groupId + + if (target.splitDirection) { + const newGroupId = createBrowserUuid() + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: null, // Placeholder; properly set in the nextGroups.map() below + tabOrder: [] + } + const currentLayout = + nextLayoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: target.groupId } as const) + const replacement = buildSplitNode( + target.groupId, + newGroupId, + target.splitDirection === 'left' || target.splitDirection === 'right' + ? 'horizontal' + : 'vertical', + target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' + ) + + resolvedTargetGroupId = newGroupId + nextGroups = [...nextGroups, newGroup] + nextLayoutByWorktree = { + ...nextLayoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, target.groupId, replacement) + } + nextActiveGroupIdByWorktree = { + ...nextActiveGroupIdByWorktree, + [worktreeId]: newGroupId + } + } + + const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) + const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) + const destinationGroup = + nextGroups.find((group) => group.id === resolvedTargetGroupId) ?? targetGroup + // Why: target order may already hold this tab id (racey write / same-group split); dedupe first or React hits a duplicate key. + const targetOrder = dedupeTabOrder(destinationGroup.tabOrder.filter((id) => id !== tabId)) + const targetIndex = Math.max( + 0, + Math.min(target.index ?? targetOrder.length, targetOrder.length) + ) + targetOrder.splice(targetIndex, 0, tabId) + + const sourceRecentTabIds = sanitizeRecentTabIds( + (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), + sourceOrder + ) + nextGroups = nextGroups.map((group) => { + if (group.id === sourceGroup.id) { + return { + ...group, + activeTabId: + group.activeTabId === tabId + ? // Why: same MRU-aware fallback as moveUnifiedTabToGroup — the drag keeps the user on their previously-active tab. + pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) + : group.activeTabId, + tabOrder: sourceOrder, + recentTabIds: sourceRecentTabIds + } + } + if (group.id === resolvedTargetGroupId) { + return { + ...group, + activeTabId: tabId, + tabOrder: targetOrder, + recentTabIds: pushRecentTabId( + sanitizeRecentTabIds(group.recentTabIds, targetOrder), + tabId + ) + } + } + return group + }) + + if (sourceOrder.length === 0) { + nextGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) + const collapsedState = collapseGroupLayout( + nextLayoutByWorktree, + nextActiveGroupIdByWorktree, + worktreeId, + sourceGroup.id, + resolvedTargetGroupId + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree + } else { + nextActiveGroupIdByWorktree = { + ...nextActiveGroupIdByWorktree, + [worktreeId]: resolvedTargetGroupId + } + } + + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, groupId: resolvedTargetGroupId } : candidate + ) + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: nextGroups + } + + return { + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + ...(state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + resolvedTargetGroupId + ) + : {}) + } + }) + if (moved) { + get().recordFeatureInteraction?.('terminal-tabs') + get().recordFeatureInteraction?.('tab-splits') + } + return moved + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts new file mode 100644 index 00000000000..39a0dd38858 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -0,0 +1,113 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { + findTabAndWorktree, + findTabByEntityInGroup, + pushRecentTabId, + sanitizeRecentTabIds +} from '../tab-group-state' + +export function createTabsFocusActions( + _set: TabsSliceSet, + get: TabsSliceGet +): Pick { + const set = _set + return { + getTab: (tabId) => findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab ?? null, + + getActiveTab: (worktreeId) => { + const state = get() + const groupId = state.activeGroupIdByWorktree[worktreeId] + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + if (!group?.activeTabId) { + return null + } + return ( + (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === group.activeTabId + ) ?? null + ) + }, + + findTabForEntityInGroup: (worktreeId, groupId, entityId, contentType) => + findTabByEntityInGroup( + get().unifiedTabsByWorktree, + worktreeId, + groupId, + entityId, + contentType + ), + + activateTab: (tabId, opts) => { + set((state) => { + const scopedWorktreeId = opts?.worktreeId + let found: ReturnType + if (scopedWorktreeId !== undefined) { + const scopedTabs = Object.hasOwn(state.unifiedTabsByWorktree, scopedWorktreeId) + ? state.unifiedTabsByWorktree[scopedWorktreeId] + : [] + const scopedTab = scopedTabs.find((tab) => tab.id === tabId) + found = scopedTab ? { tab: scopedTab, worktreeId: scopedWorktreeId } : null + } else { + found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + } + if (!found) { + return {} + } + const { tab, worktreeId } = found + // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. + // Why (activeWorktree guard below): only when the tab is in the active worktree, else the unseen signal is lost (mirrors focusGroup). + const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null + const nextUnreadTerminalTabs = + state.activeWorktreeId === worktreeId && + terminalEntityId && + state.unreadTerminalTabs[terminalEntityId] + ? (() => { + const copy = { ...state.unreadTerminalTabs } + delete copy[terminalEntityId] + return copy + })() + : state.unreadTerminalTabs + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => + item.id === tabId + ? { + ...item, + isPreview: opts?.preservePreview ? item.isPreview : false, + lastFocusedAt: Date.now() + } + : item + ) + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => + group.id === tab.groupId + ? { + ...group, + activeTabId: tabId, + // Why: track every activation in the group's MRU so closeUnifiedTab returns to the previous tab; sanitize to prune removed ids. + recentTabIds: pushRecentTabId( + sanitizeRecentTabIds(group.recentTabIds, group.tabOrder), + tabId + ) + } + : group + ) + }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: tab.groupId + }, + // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-group-actions.ts b/src/renderer/src/store/slices/tabs/tabs-group-actions.ts new file mode 100644 index 00000000000..f071ff4071d --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-group-actions.ts @@ -0,0 +1,195 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { TabGroup } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { activeSurfacePatchMatchesState, buildActiveSurfacePatch } from './tabs-surface' +import { buildSplitNode, collapseGroupLayout, replaceLeaf } from './tabs-layout' + +export function createTabsGroupActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick< + TabsSlice, + 'ensureWorktreeRootGroup' | 'focusGroup' | 'closeEmptyGroup' | 'createEmptySplitGroup' +> { + return { + ensureWorktreeRootGroup: (worktreeId) => { + const existingGroups = get().groupsByWorktree[worktreeId] ?? [] + if (existingGroups.length > 0) { + return get().activeGroupIdByWorktree[worktreeId] ?? existingGroups[0].id + } + + const groupId = createBrowserUuid() + set((state) => ({ + // Why: a zero-tab worktree still needs a canonical root group so new tabs and splits land in a deterministic place. + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: { type: 'leaf', groupId } + }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId + } + })) + return groupId + }, + + focusGroup: (worktreeId, groupId) => + set((state) => { + const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId + const nextActiveGroupIdByWorktree = groupAlreadyFocused + ? state.activeGroupIdByWorktree + : { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId + } + // Why: focusing a group surfaces its active terminal tab, so dismiss the tab-level bell. + // Why (activeWorktree guard below): only when the group is in the active worktree, else the unseen tab's bell is swallowed. + if (state.activeWorktreeId !== worktreeId) { + if (groupAlreadyFocused) { + return state + } + return { + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + } + } + const groups = state.groupsByWorktree[worktreeId] ?? [] + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const visibleTerminalEntityIds = new Set( + groups + .map((group) => + group.activeTabId ? unifiedTabs.find((tab) => tab.id === group.activeTabId) : null + ) + .filter((tab): tab is (typeof unifiedTabs)[number] => tab?.contentType === 'terminal') + .map((tab) => tab.entityId) + ) + const nextUnreadTerminalTabs = + visibleTerminalEntityIds.size > 0 + ? (() => { + let changed = false + const copy = { ...state.unreadTerminalTabs } + for (const terminalEntityId of visibleTerminalEntityIds) { + if (!copy[terminalEntityId]) { + continue + } + delete copy[terminalEntityId] + changed = true + } + return changed ? copy : state.unreadTerminalTabs + })() + : state.unreadTerminalTabs + const activeSurfacePatch = buildActiveSurfacePatch( + { + ...state, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + groupId + ) + if ( + groupAlreadyFocused && + nextUnreadTerminalTabs === state.unreadTerminalTabs && + activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch) + ) { + return state + } + return { + ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }), + // Why: only write unreadTerminalTabs when it changed — preserving the reference keeps selectors/subscribers from firing spuriously. + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...activeSurfacePatch + } + }), + + closeEmptyGroup: (worktreeId, groupId) => { + const state = get() + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + if (!group || group.tabOrder.length > 0) { + return false + } + set((current) => { + const remainingGroups = (current.groupsByWorktree[worktreeId] ?? []).filter( + (candidate) => candidate.id !== groupId + ) + const collapsedState = collapseGroupLayout( + current.layoutByWorktree, + current.activeGroupIdByWorktree, + worktreeId, + groupId, + remainingGroups[0]?.id ?? null + ) + // Why: drop the dead group's recent-quick-command entry so the map can't grow unbounded as groups open/close. + const { [groupId]: _droppedRecent, ...remainingRecent } = + current.recentQuickCommandIdByGroup + return { + groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups }, + layoutByWorktree: collapsedState.layoutByWorktree, + activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree, + recentQuickCommandIdByGroup: remainingRecent, + ...(current.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...current, + groupsByWorktree: { + ...current.groupsByWorktree, + [worktreeId]: remainingGroups + }, + layoutByWorktree: collapsedState.layoutByWorktree, + activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree + }, + worktreeId, + collapsedState.activeGroupIdByWorktree[worktreeId] ?? null + ) + : {}) + } + }) + return true + }, + + createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => { + const newGroupId = createBrowserUuid() + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: null, + tabOrder: [] + } + const shouldActivate = opts?.activate !== false + set((state) => { + const existing = state.groupsByWorktree[worktreeId] ?? [] + const currentLayout = + state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: sourceGroupId } as const) + const replacement = buildSplitNode( + sourceGroupId, + newGroupId, + direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical', + direction === 'left' || direction === 'up' ? 'first' : 'second' + ) + return { + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) + }, + ...(shouldActivate + ? { + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: newGroupId + } + } + : {}) + } + }) + get().recordFeatureInteraction?.('terminal-panes') + return newGroupId + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts new file mode 100644 index 00000000000..e87a34f81c2 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts @@ -0,0 +1,62 @@ +import type { AppState } from '../../types' +import type { TerminalTab } from '../../../../../shared/terminal-tab-types' +import { findTabAndWorktree } from '../tab-group-state' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function patchTerminalTabPinned( + tabsByWorktree: Record, + worktreeId: string, + tabId: string, + isPinned: boolean +): Partial> { + const tabs = tabsByWorktree[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + return {} + } + return { + tabsByWorktree: { + ...tabsByWorktree, + [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) + } + } +} + +// Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. +// Dynamic import keeps this store slice off the runtime layer. +export function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean): void { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + // Why: only terminal tab pins are persisted host-side today (browser/editor in #5729); skip the RPC for other types. + if ( + !found || + found.tab.contentType !== 'terminal' || + !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) + ) { + return + } + const worktreeId = found.worktreeId + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId, tabId, isPinned }) + ) +} + +// Why: viewMode is host-tracked like color/pin, so mirror local sets or they're lost on reconnect and to paired clients. +// Only the action path mirrors (never reconcile applying a host value), so the echoed snapshot can't re-trigger an outbound RPC. +export function mirrorTabViewModeToHost( + state: AppState, + tabId: string, + viewMode: 'terminal' | 'chat' +): void { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + // Why: only terminal tab viewMode is persisted host-side; skip the RPC for other types instead of a no-op round trip. + if ( + !found || + found.tab.contentType !== 'terminal' || + !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) + ) { + return + } + const worktreeId = found.worktreeId + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId, tabId, viewMode }) + ) +} diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts new file mode 100644 index 00000000000..012e1e8c0c0 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -0,0 +1,199 @@ +import { emitNativeChatToggled } from '@/lib/native-chat-telemetry' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { findTabAndWorktree, patchTab, updateGroup, dedupeTabOrder } from '../tab-group-state' +import { applyTabOrderSortValues, partitionPinnedTabOrder } from './tabs-tab-order' +import { + mirrorTabPinnedToHost, + mirrorTabViewModeToHost, + patchTerminalTabPinned +} from './tabs-host-mirroring' + +export function createTabsLabelActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick< + TabsSlice, + | 'reorderUnifiedTabs' + | 'setTabLabel' + | 'setTabViewMode' + | 'toggleTabViewMode' + | 'setRenamingTabId' + | 'setTabCustomLabel' + | 'setUnifiedTabColor' + | 'pinTab' + | 'unpinTab' +> { + return { + reorderUnifiedTabs: (groupId, tabIds, opts) => { + let reordered = false + set((state) => { + for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) { + const group = groups.find((candidate) => candidate.id === groupId) + if (!group) { + continue + } + // Why: dedupe at the store boundary so each tab keeps one canonical position and later group ops don't branch on duplicate ids. + const nextTabOrder = dedupeTabOrder(tabIds) + reordered = true + const orderMap = new Map(nextTabOrder.map((id, index) => [id, index])) + return { + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder: nextTabOrder }) + }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => { + const sortOrder = orderMap.get(tab.id) + return sortOrder === undefined ? tab : { ...tab, sortOrder } + }) + } + } + } + return {} + }) + if (reordered && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + setTabLabel: (tabId, label) => { + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + }, + + setTabViewMode: (tabId, mode) => { + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) + mirrorTabViewModeToHost(get(), tabId, mode) + }, + + toggleTabViewMode: (tabId) => { + let toggled: { + from: 'terminal' | 'chat' + to: 'terminal' | 'chat' + agent: TuiAgent | null + } | null = null + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. + const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' + const nextMode = fromMode === 'chat' ? 'terminal' : 'chat' + // Why: launchAgent lives on the legacy terminal tab (keyed by entityId); resolve it here so toggle telemetry can attribute by agent. + const agent = + (state.tabsByWorktree[found.worktreeId] ?? []).find( + (terminal) => terminal.id === found.tab.entityId + )?.launchAgent ?? null + toggled = { from: fromMode, to: nextMode, agent } + return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} + }) + // Why: emit after the state write so the event reflects the committed mode. + const committed = toggled as { + from: 'terminal' | 'chat' + to: 'terminal' | 'chat' + agent: TuiAgent | null + } | null + if (committed) { + emitNativeChatToggled(committed) + mirrorTabViewModeToHost(get(), tabId, committed.to) + } + }, + + setRenamingTabId: (tabId) => { + set({ renamingTabId: tabId }) + }, + + setTabCustomLabel: (tabId, label, opts) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + if (exists && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + setUnifiedTabColor: (tabId, color) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + pinTab: (tabId) => { + const exists = get().getTab(tabId) !== null + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + const { tab, worktreeId } = found + const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, isPinned: true, isPreview: false } : candidate + ) + const groups = state.groupsByWorktree[worktreeId] ?? [] + const group = groups.find((candidate) => candidate.id === tab.groupId) + if (!group) { + return { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } + } + } + const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) + }, + // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. + ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder }) + } + } + }) + mirrorTabPinnedToHost(get(), tabId, true) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + unpinTab: (tabId) => { + const exists = get().getTab(tabId) !== null + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + const { tab, worktreeId } = found + const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, isPinned: false } : candidate + ) + const groups = state.groupsByWorktree[worktreeId] ?? [] + const group = groups.find((candidate) => candidate.id === tab.groupId) + if (!group) { + return { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } + } + } + const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) + }, + ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder }) + } + } + }) + mirrorTabPinnedToHost(get(), tabId, false) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-layout.ts b/src/renderer/src/store/slices/tabs/tabs-layout.ts new file mode 100644 index 00000000000..293dcce03a5 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-layout.ts @@ -0,0 +1,125 @@ +import type { TabGroupLayoutNode } from '../../../../../shared/tab-types' + +export function buildSplitNode( + existingGroupId: string, + newGroupId: string, + direction: 'horizontal' | 'vertical', + position: 'first' | 'second' +): TabGroupLayoutNode { + const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } + const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } + return { + type: 'split', + direction, + first: position === 'first' ? newLeaf : existingLeaf, + second: position === 'second' ? newLeaf : existingLeaf, + ratio: 0.5 + } +} + +export function replaceLeaf( + root: TabGroupLayoutNode, + targetGroupId: string, + replacement: TabGroupLayoutNode +): TabGroupLayoutNode { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? replacement : root + } + return { + ...root, + first: replaceLeaf(root.first, targetGroupId, replacement), + second: replaceLeaf(root.second, targetGroupId, replacement) + } +} + +export function updateSplitRatio( + root: TabGroupLayoutNode, + path: string[], + ratio: number +): TabGroupLayoutNode { + if (path.length === 0) { + return root.type === 'split' ? { ...root, ratio } : root + } + if (root.type !== 'split') { + return root + } + const [segment, ...rest] = path + if (segment === 'first') { + return { ...root, first: updateSplitRatio(root.first, rest, ratio) } + } + if (segment === 'second') { + return { ...root, second: updateSplitRatio(root.second, rest, ratio) } + } + return root +} + +function findFirstLeaf(root: TabGroupLayoutNode): string { + return root.type === 'leaf' ? root.groupId : findFirstLeaf(root.first) +} + +export function findSiblingGroupId(root: TabGroupLayoutNode, targetGroupId: string): string | null { + if (root.type === 'leaf') { + return null + } + if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { + return root.second.type === 'leaf' ? root.second.groupId : findFirstLeaf(root.second) + } + if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { + return root.first.type === 'leaf' ? root.first.groupId : findFirstLeaf(root.first) + } + return ( + findSiblingGroupId(root.first, targetGroupId) ?? findSiblingGroupId(root.second, targetGroupId) + ) +} + +function removeLeaf(root: TabGroupLayoutNode, targetGroupId: string): TabGroupLayoutNode | null { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? null : root + } + if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { + return root.second + } + if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { + return root.first + } + const first = removeLeaf(root.first, targetGroupId) + const second = removeLeaf(root.second, targetGroupId) + if (first === null) { + return second + } + if (second === null) { + return first + } + return { ...root, first, second } +} + +export function collapseGroupLayout( + layoutByWorktree: Record, + activeGroupIdByWorktree: Record, + worktreeId: string, + groupId: string, + fallbackGroupId?: string | null +): { + layoutByWorktree: Record + activeGroupIdByWorktree: Record +} { + const currentLayout = layoutByWorktree[worktreeId] + if (!currentLayout) { + return { layoutByWorktree, activeGroupIdByWorktree } + } + const siblingId = findSiblingGroupId(currentLayout, groupId) + const collapsed = removeLeaf(currentLayout, groupId) + const nextLayoutByWorktree = { ...layoutByWorktree } + if (collapsed) { + nextLayoutByWorktree[worktreeId] = collapsed + } else { + delete nextLayoutByWorktree[worktreeId] + } + return { + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: { + ...activeGroupIdByWorktree, + [worktreeId]: siblingId ?? fallbackGroupId ?? activeGroupIdByWorktree[worktreeId] + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts new file mode 100644 index 00000000000..4c651f613ac --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -0,0 +1,132 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { collapseGroupLayout } from './tabs-layout' +import { buildActiveSurfacePatch } from './tabs-surface' +import { + dedupeTabOrder, + findGroupAndWorktree, + findGroupForTab, + findTabAndWorktree, + pickNextActiveTab, + pushRecentTabId, + sanitizeRecentTabIds +} from '../tab-group-state' + +export function createTabsMoveActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + moveUnifiedTabToGroup: (tabId, targetGroupId, opts) => { + let moved = false + set((state) => { + const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return {} + } + const { tab, worktreeId } = foundTab + if (tab.groupId === targetGroupId) { + return {} + } + const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + const targetGroup = foundTarget.group + if (!sourceGroup) { + return {} + } + moved = true + + const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) + const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) + // Why: defensive dedupe so target order can't grow a duplicate id (stale state); see dropUnifiedTab for the same guard. + const targetOrder = dedupeTabOrder(targetGroup.tabOrder.filter((id) => id !== tabId)) + const targetIndex = Math.max( + 0, + Math.min(opts?.index ?? targetOrder.length, targetOrder.length) + ) + targetOrder.splice(targetIndex, 0, tabId) + const nextActiveGroupIdByWorktree = { + ...state.activeGroupIdByWorktree, + [worktreeId]: opts?.activate ? targetGroupId : state.activeGroupIdByWorktree[worktreeId] + } + const sourceRecentTabIds = sanitizeRecentTabIds( + (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), + sourceOrder + ) + const nextGroups = (state.groupsByWorktree[worktreeId] ?? []).map((group) => { + if (group.id === sourceGroup.id) { + return { + ...group, + activeTabId: + group.activeTabId === tabId + ? // Why: keep MRU-aware selection so the user lands on their previously-focused tab, not a visual neighbor. + pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) + : group.activeTabId, + tabOrder: sourceOrder, + recentTabIds: sourceRecentTabIds + } + } + if (group.id === targetGroupId) { + const sanitizedTargetRecent = sanitizeRecentTabIds(group.recentTabIds, targetOrder) + return { + ...group, + activeTabId: opts?.activate ? tabId : group.activeTabId, + tabOrder: targetOrder, + recentTabIds: opts?.activate + ? pushRecentTabId(sanitizedTargetRecent, tabId) + : sanitizedTargetRecent + } + } + return group + }) + let nextLayoutByWorktree = state.layoutByWorktree + let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree + let filteredGroups = nextGroups + if (sourceOrder.length === 0) { + filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) + const collapsedState = collapseGroupLayout( + nextLayoutByWorktree, + nextActiveGroupIdByWorktreeResolved, + worktreeId, + sourceGroup.id, + targetGroupId + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: filteredGroups + } + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate + ) + } + return { + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved, + ...(state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved + }, + worktreeId, + nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null + ) + : {}) + } + }) + if (moved && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('tab-splits') + } + return moved + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts new file mode 100644 index 00000000000..1be6199b55c --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts @@ -0,0 +1,225 @@ +import type { AppState } from '../../types' +import type { Tab } from '../../../../../shared/tab-types' +import { dedupeTabOrder, ensureGroup, sanitizeRecentTabIds, updateGroup } from '../tab-group-state' +import { pruneTabGroupLayoutForGroups } from '../tabs-hydration' +import { + buildOrphanTerminalCleanupPatch, + getOrphanTerminalIds, + terminalTabHasReconnectablePty +} from '../terminal-orphan-helpers' + +export type WorktreeTabModelReconciliation = { + patch: Partial + renderableTabCount: number + activeRenderableTabId: string | null +} + +export function projectWorktreeTabModelReconciliation( + state: AppState, + worktreeId: string +): WorktreeTabModelReconciliation { + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const groups = state.groupsByWorktree[worktreeId] ?? [] + const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const unifiedTerminalEntityIds = new Set( + unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) + ) + const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { + if (unifiedTerminalEntityIds.has(tab.id)) { + return false + } + // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. + // A session-scoped unverified-loss marker is also liveness evidence: the + // host may have disappeared before it could publish a PTY, so keep the row + // visible until a replacement binds or the user closes it. + return ( + terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) || + state.unverifiedPtyLossTabIds[tab.id] === true + ) + }) + const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) + const ensuredGroupState = + legacyRuntimeTerminalTabs.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null + const restoredLegacyTabs = + reconciliationGroup == null + ? [] + : legacyRuntimeTerminalTabs + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .map((tab) => ({ + id: tab.id, + entityId: tab.id, + groupId: reconciliationGroup.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + const reconciledUnifiedTabs = + restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs + const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] + const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) + const legacyFallbackActiveTabId = + rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) + ? rememberedLegacyActiveTabId + : (restoredLegacyTabs[0]?.id ?? null) + const reconciledGroups = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { + ...reconciliationGroup, + // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. + activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, + tabOrder: dedupeTabOrder([ + ...reconciliationGroup.tabOrder, + ...restoredLegacyTabs.map((tab) => tab.id) + ]) + }) + : groups + const liveTerminalIds = new Set( + runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const liveEditorIds = new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) + const liveBrowserIds = new Set( + (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) + ) + + const isRenderableTab = (tab: Tab): boolean => { + if (tab.contentType === 'terminal') { + return liveTerminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return liveBrowserIds.has(tab.entityId) + } + if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { + return true + } + return liveEditorIds.has(tab.entityId) + } + + const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) + const validTabIds = new Set(validTabs.map((tab) => tab.id)) + const nextGroupsWithEmpty = reconciledGroups.map((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + const activeTabId = + group.activeTabId && validTabIds.has(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const tabOrderUnchanged = + tabOrder.length === group.tabOrder.length && + tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) + const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) + const recentUnchanged = + recentTabIds.length === (group.recentTabIds ?? []).length && + recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) + return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged + ? group + : { ...group, tabOrder, activeTabId, recentTabIds } + }) + const nextGroups = + validTabs.length > 0 + ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) + : nextGroupsWithEmpty + const currentActiveGroupId = + state.activeGroupIdByWorktree[worktreeId] ?? + ensuredGroupState?.activeGroupIdByWorktree[worktreeId] + const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) + const nextActiveGroupId = activeGroupStillExists + ? currentActiveGroupId + : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? + nextGroups[0]?.id ?? + currentActiveGroupId) + const groupsChanged = + nextGroups.length !== groups.length || + nextGroups.some((group, index) => group !== groups[index]) + const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 + const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId + const baseNextLayout = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) + : state.layoutByWorktree[worktreeId] + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const prunedNextLayout = + baseNextLayout && validGroupIds.size > 0 + ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) + : baseNextLayout + const nextLayout = + prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) + const currentLayout = state.layoutByWorktree[worktreeId] + const layoutChanged = nextLayout !== currentLayout + let patch: Partial = {} + + if ( + tabsChanged || + groupsChanged || + activeGroupChanged || + layoutChanged || + orphanTerminalIds.size > 0 + ) { + const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => + tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] + ) + let nextUnreadTerminalTabs = state.unreadTerminalTabs + if (droppedTerminalEntityIds.length > 0) { + const copy = { ...state.unreadTerminalTabs } + let changed = false + for (const entityId of droppedTerminalEntityIds) { + if (copy[entityId]) { + delete copy[entityId] + changed = true + } + } + if (changed) { + nextUnreadTerminalTabs = copy + } + } + patch = { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: nextActiveGroupId + }, + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextLayout && layoutChanged + ? { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: restored runtime terminals need a concrete leaf before activation. + [worktreeId]: nextLayout + } + } + : {}), + ...(orphanTerminalIds.size > 0 + ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) + : {}) + } + } + + return { + patch, + renderableTabCount: validTabs.length, + activeRenderableTabId: + nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? + nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? + null + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts b/src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts new file mode 100644 index 00000000000..4d98c1e2fbd --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts @@ -0,0 +1,94 @@ +import type { TabGroupLayoutNode } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { findGroupAndWorktree, findTabAndWorktree } from '../tab-group-state' +import { findSiblingGroupId, updateSplitRatio } from './tabs-layout' + +export function createTabsSecondaryActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + copyUnifiedTabToGroup: (tabId, targetGroupId, init) => { + const foundTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(get().groupsByWorktree, targetGroupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return null + } + const { tab, worktreeId } = foundTab + return get().createUnifiedTab(worktreeId, tab.contentType, { + entityId: init?.entityId ?? tab.entityId, + executionHostId: tab.executionHostId, + label: init?.label ?? tab.label, + generatedLabel: init?.generatedLabel ?? tab.generatedLabel, + quickCommandLabel: init?.quickCommandLabel ?? tab.quickCommandLabel, + customLabel: init?.customLabel ?? tab.customLabel, + color: init?.color ?? tab.color, + isPinned: init?.isPinned ?? tab.isPinned, + id: init?.id, + targetGroupId + }) + }, + + mergeGroupIntoSibling: (worktreeId, groupId) => { + const state = get() + const groups = state.groupsByWorktree[worktreeId] ?? [] + const sourceGroup = groups.find((candidate) => candidate.id === groupId) + const layout = state.layoutByWorktree[worktreeId] + if (!sourceGroup || !layout || groups.length <= 1) { + return null + } + const targetGroupId = findSiblingGroupId(layout, groupId) + if (!targetGroupId) { + return null + } + + const orderedSourceTabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.groupId === groupId + ) + for (const tabId of sourceGroup.tabOrder) { + const item = orderedSourceTabs.find((tab) => tab.id === tabId) + if (!item) { + continue + } + get().moveUnifiedTabToGroup(item.id, targetGroupId, { recordInteraction: false }) + } + get().closeEmptyGroup(worktreeId, groupId) + get().recordFeatureInteraction?.('terminal-panes') + return targetGroupId + }, + + setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => { + set((state) => { + const currentLayout = state.layoutByWorktree[worktreeId] + if (!currentLayout) { + return state + } + // Why: an unchanged ratio must not mint fresh root state — every store + // subscriber wakes on the new reference (STA-3328). + let targetNode: TabGroupLayoutNode | undefined = currentLayout + for (const segment of nodePath.length > 0 ? nodePath.split('.') : []) { + targetNode = + targetNode && + targetNode.type === 'split' && + (segment === 'first' || segment === 'second') + ? targetNode[segment] + : undefined + } + if (!targetNode || targetNode.type !== 'split' || (targetNode.ratio ?? 0.5) === ratio) { + return state + } + return { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: split ratios belong to the tab-group model (not transient UI), so persist them for restores and multi-step group ops. + [worktreeId]: updateSplitRatio( + currentLayout, + nodePath.length > 0 ? nodePath.split('.') : [], + ratio + ) + } + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts new file mode 100644 index 00000000000..1c2d22c0ce7 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -0,0 +1,78 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from '../degraded-repo-worktree-validity' +import { buildHydratedTabState } from '../tabs-hydration' +import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' + +function replaceWorkspaceRecordKeys( + current: Record, + hydrated: Record, + workspaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !workspaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => workspaceKeys.has(key))) + } +} + +export function createTabsSessionActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + reconcileWorktreeTabModel: (worktreeId) => { + const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) + if (Object.keys(reconciliation.patch).length > 0) { + set(reconciliation.patch) + } + return { + renderableTabCount: reconciliation.renderableTabCount, + activeRenderableTabId: reconciliation.activeRenderableTabId + } + }, + + hydrateTabsSession: (session, options) => { + const state = get() + const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) + validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of state.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) + const hydrated = buildHydratedTabState(session, validWorktreeIds) + if (!options?.replaceWorkspaceKeys) { + set(hydrated) + return + } + const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) + set((current) => ({ + unifiedTabsByWorktree: replaceWorkspaceRecordKeys( + current.unifiedTabsByWorktree, + hydrated.unifiedTabsByWorktree, + replaceWorkspaceKeys + ), + groupsByWorktree: replaceWorkspaceRecordKeys( + current.groupsByWorktree, + hydrated.groupsByWorktree, + replaceWorkspaceKeys + ), + activeGroupIdByWorktree: replaceWorkspaceRecordKeys( + current.activeGroupIdByWorktree, + hydrated.activeGroupIdByWorktree, + replaceWorkspaceKeys + ), + layoutByWorktree: replaceWorkspaceRecordKeys( + current.layoutByWorktree, + hydrated.layoutByWorktree, + replaceWorkspaceKeys + ) + })) + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts new file mode 100644 index 00000000000..456d5892255 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -0,0 +1,163 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { + Tab, + TabContentType, + TabGroup, + TabGroupLayoutNode +} from '../../../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../../../shared/workspace-session-state-types' +import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' + +export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' + +export type TabsSlice = { + unifiedTabsByWorktree: Record + // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. + renamingTabId: string | null + groupsByWorktree: Record + activeGroupIdByWorktree: Record + layoutByWorktree: Record + createUnifiedTab: ( + worktreeId: string, + contentType: TabContentType, + init?: Partial< + Pick< + Tab, + | 'id' + | 'entityId' + | 'executionHostId' + | 'label' + | 'generatedLabel' + | 'quickCommandLabel' + | 'customLabel' + | 'color' + | 'isPreview' + | 'isPinned' + > & { + targetGroupId: string + activate: boolean + recordInteraction: boolean + } + > + ) => Tab + createUnifiedTabInSplit: ( + worktreeId: string, + contentType: TabContentType, + target: { + sourceGroupId: string + splitDirection: TabSplitDirection + }, + init?: Partial< + Pick< + Tab, + | 'id' + | 'entityId' + | 'executionHostId' + | 'label' + | 'generatedLabel' + | 'quickCommandLabel' + | 'customLabel' + | 'color' + | 'isPreview' + | 'isPinned' + > & { + activate: boolean + recordInteraction: boolean + } + > + ) => Tab | null + getTab: (tabId: string) => Tab | null + getActiveTab: (worktreeId: string) => Tab | null + findTabForEntityInGroup: ( + worktreeId: string, + groupId: string, + entityId: string, + contentType?: TabContentType + ) => Tab | null + activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void + closeUnifiedTab: ( + tabId: string, + opts?: { + /** Keep the worktree selected even if this empties it — for closes the user did not ask for. */ + preserveWorktreeSelection?: boolean + recordInteraction?: boolean + terminalRetirementHandled?: boolean + } + ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null + reorderUnifiedTabs: ( + groupId: string, + tabIds: string[], + opts?: { recordInteraction?: boolean } + ) => void + setTabLabel: (tabId: string, label: string) => void + /** Set a tab's view mode (terminal vs native chat). Patches only that tab. */ + setTabViewMode: (tabId: string, mode: 'terminal' | 'chat') => void + /** Flip a tab between terminal and native-chat renderings; the live TerminalPane stays mounted. */ + toggleTabViewMode: (tabId: string) => void + setTabCustomLabel: ( + tabId: string, + label: string | null, + opts?: { recordInteraction?: boolean } + ) => void + setUnifiedTabColor: (tabId: string, color: string | null) => void + setRenamingTabId: (tabId: string | null) => void + pinTab: (tabId: string) => void + unpinTab: (tabId: string) => void + closeOtherTabs: (tabId: string) => string[] + closeTabsToRight: (tabId: string) => string[] + closeTabsToLeft: (tabId: string) => string[] + ensureWorktreeRootGroup: (worktreeId: string) => string + focusGroup: (worktreeId: string, groupId: string) => void + closeEmptyGroup: (worktreeId: string, groupId: string) => boolean + createEmptySplitGroup: ( + worktreeId: string, + sourceGroupId: string, + direction: TabSplitDirection, + opts?: { activate?: boolean } + ) => string | null + moveUnifiedTabToGroup: ( + tabId: string, + targetGroupId: string, + opts?: { index?: number; activate?: boolean; recordInteraction?: boolean } + ) => boolean + dropUnifiedTab: ( + tabId: string, + target: { + groupId: string + index?: number + splitDirection?: TabSplitDirection + } + ) => boolean + copyUnifiedTabToGroup: ( + tabId: string, + targetGroupId: string, + init?: Partial< + Pick< + Tab, + | 'id' + | 'entityId' + | 'label' + | 'generatedLabel' + | 'quickCommandLabel' + | 'customLabel' + | 'color' + | 'isPinned' + > + > + ) => Tab | null + mergeGroupIntoSibling: (worktreeId: string, groupId: string) => string | null + setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void + reconcileWorktreeTabModel: (worktreeId: string) => { + renderableTabCount: number + activeRenderableTabId: string | null + } + hydrateTabsSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void +} + +type TabsStateCreator = StateCreator +export type TabsSliceSet = Parameters[0] +export type TabsSliceGet = Parameters[1] diff --git a/src/renderer/src/store/slices/tabs/tabs-surface.ts b/src/renderer/src/store/slices/tabs/tabs-surface.ts new file mode 100644 index 00000000000..b870ca7bb32 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-surface.ts @@ -0,0 +1,197 @@ +import type { AppState } from '../../types' +import { toVisibleTabType } from '../../../../../shared/tab-types' +import type { WorkspaceVisibleTabType } from '../../../../../shared/tab-types' + +export function deriveActiveSurfaceForWorktree( + state: Pick< + AppState, + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + >, + worktreeId: string, + preferredGroupId?: string | null +): { + activeBrowserTabId: string | null + activeFileId: string | null + activeTabId: string | null + activeTabType: WorkspaceVisibleTabType +} { + const groups = state.groupsByWorktree[worktreeId] ?? [] + const activeGroupId = preferredGroupId ?? state.activeGroupIdByWorktree[worktreeId] ?? null + const activeGroup = + (activeGroupId ? groups.find((group) => group.id === activeGroupId) : null) ?? groups[0] ?? null + const activeUnifiedTab = + activeGroup?.activeTabId != null + ? ((state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id + ) ?? null) + : null + const restoredFileId = state.activeFileIdByWorktree[worktreeId] ?? null + const restoredBrowserTabId = state.activeBrowserTabIdByWorktree[worktreeId] ?? null + const restoredTerminalTabId = state.activeTabIdByWorktree[worktreeId] ?? null + const browserTabs = state.browserTabsByWorktree[worktreeId] ?? [] + const terminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const fileStillOpen = restoredFileId + ? state.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === worktreeId) + : false + const browserTabStillOpen = restoredBrowserTabId + ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) + : false + const terminalTabStillExists = restoredTerminalTabId + ? terminalTabs.some((tab) => tab.id === restoredTerminalTabId) + : false + const hasGroupOwnedSurface = groups.length > 0 || Boolean(state.layoutByWorktree[worktreeId]) + + let activeFileId: string | null + let activeBrowserTabId: string | null + let activeTabType: WorkspaceVisibleTabType + + if (activeUnifiedTab) { + activeFileId = + activeUnifiedTab.contentType === 'editor' || + activeUnifiedTab.contentType === 'diff' || + activeUnifiedTab.contentType === 'conflict-review' || + activeUnifiedTab.contentType === 'check-details' + ? activeUnifiedTab.entityId + : fileStillOpen + ? restoredFileId + : null + activeBrowserTabId = + activeUnifiedTab.contentType === 'browser' + ? activeUnifiedTab.entityId + : browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = toVisibleTabType(activeUnifiedTab.contentType) + } else if (hasGroupOwnedSurface) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) + // Why: focusing an empty split should target its default terminal area, not the previously active browser/editor in another group. + activeTabType = 'terminal' + } else if (browserTabStillOpen) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = restoredBrowserTabId + activeTabType = 'browser' + } else if (fileStillOpen) { + activeFileId = restoredFileId + activeBrowserTabId = browserTabs[0]?.id ?? null + activeTabType = 'editor' + } else { + const fallbackFile = state.openFiles.find((file) => file.worktreeId === worktreeId) ?? null + const fallbackBrowserTab = browserTabs[0] ?? null + activeFileId = fallbackFile?.id ?? null + activeBrowserTabId = fallbackBrowserTab?.id ?? null + activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' + } + + return { + activeBrowserTabId, + activeFileId, + activeTabId: + activeUnifiedTab?.contentType === 'terminal' + ? activeUnifiedTab.entityId + : terminalTabStillExists + ? restoredTerminalTabId + : (terminalTabs[0]?.id ?? null), + activeTabType + } +} + +export function buildActiveSurfacePatch( + state: Pick< + AppState, + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + >, + worktreeId: string, + preferredGroupId?: string | null +): Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' +> { + const derived = deriveActiveSurfaceForWorktree(state, worktreeId, preferredGroupId) + return { + activeBrowserTabId: derived.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...state.activeBrowserTabIdByWorktree, + [worktreeId]: derived.activeBrowserTabId + }, + activeFileId: derived.activeFileId, + activeFileIdByWorktree: { + ...state.activeFileIdByWorktree, + [worktreeId]: derived.activeFileId + }, + activeTabId: derived.activeTabId, + activeTabIdByWorktree: { + ...state.activeTabIdByWorktree, + [worktreeId]: derived.activeTabId + }, + activeTabType: derived.activeTabType, + activeTabTypeByWorktree: { + ...state.activeTabTypeByWorktree, + [worktreeId]: derived.activeTabType + } + } +} + +export function activeSurfacePatchMatchesState( + state: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + >, + worktreeId: string, + patch: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + > +): boolean { + return ( + state.activeBrowserTabId === patch.activeBrowserTabId && + state.activeBrowserTabIdByWorktree[worktreeId] === + patch.activeBrowserTabIdByWorktree[worktreeId] && + state.activeFileId === patch.activeFileId && + state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] && + state.activeTabId === patch.activeTabId && + state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] && + state.activeTabType === patch.activeTabType && + state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId] + ) +} diff --git a/src/renderer/src/store/slices/tabs/tabs-tab-order.ts b/src/renderer/src/store/slices/tabs/tabs-tab-order.ts new file mode 100644 index 00000000000..236465ba6d1 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-tab-order.ts @@ -0,0 +1,41 @@ +import type { Tab } from '../../../../../shared/tab-types' +import { dedupeTabOrder } from '../tab-group-state' + +export function partitionPinnedTabOrder( + tabOrder: string[], + tabs: Tab[], + movingTabId: string +): string[] { + const tabById = new Map(tabs.map((tab) => [tab.id, tab])) + const withoutMoving = dedupeTabOrder(tabOrder).filter((id) => id !== movingTabId) + const pinnedIds = withoutMoving.filter((id) => tabById.get(id)?.isPinned) + const unpinnedIds = withoutMoving.filter((id) => !tabById.get(id)?.isPinned) + return [...pinnedIds, movingTabId, ...unpinnedIds] +} + +export function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] { + const orderMap = new Map(tabOrder.map((id, index) => [id, index])) + return tabs.map((tab) => { + const sortOrder = orderMap.get(tab.id) + return sortOrder === undefined ? tab : { ...tab, sortOrder } + }) +} + +export function isReplaceablePreviewContentType(contentType: Tab['contentType']): boolean { + return ( + contentType === 'editor' || + contentType === 'diff' || + contentType === 'conflict-review' || + contentType === 'check-details' + ) +} + +export function canReplacePreviewContentType( + incomingContentType: Tab['contentType'], + existingContentType: Tab['contentType'] +): boolean { + if (isReplaceablePreviewContentType(incomingContentType)) { + return isReplaceablePreviewContentType(existingContentType) + } + return existingContentType === incomingContentType +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 8db85a78e25..c62d1d64d24 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -1,2905 +1,43 @@ -/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { normalizeRightSidebarRoute } from '../right-sidebar-route' -import { settleEvictedModalData } from './modal-slot-dismissal' -import { - findPrevLiveNonTaskStackHistoryIndex, - rewindHistoryIndexPastView -} from './worktree-nav-history' -import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' -import type { JiraIssue } from '../../../../shared/jira-types' -import type { LinearIssue } from '../../../../shared/linear/issue-types' -import type { PersistedTrustedOrcaHooks } from '../../../../shared/orca-yaml-hook-types' -import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' -import type { CustomPet } from '../../../../shared/pet-types' -import type { TaskProvider } from '../../../../shared/task-providers' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { - AgentActivityDisplayMode, - ManualRepoOrderEntry, - ProjectOrderBy, - StatusBarItem, - TaskResumeState, - TaskViewPresetId, - TopLevelView, - VisibleWorkspaceHostIds, - WorkspaceHostOrder, - WorkspaceHostScope, - WorktreeCardMode, - WorktreeCardProperty -} from '../../../../shared/ui-chrome-types' -import type { ChangelogData, UpdateStatus } from '../../../../shared/update-status-types' -import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' -import { - applyManualRepoOrder, - normalizeManualRepoOrder -} from '../../../../shared/manual-repo-order' -import { isTopLevelView } from '../../../../shared/top-level-view' -import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' -import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' -import { - DEFAULT_USAGE_PERCENTAGE_DISPLAY, - normalizeUsagePercentageDisplay -} from '../../../../shared/usage-percentage-display' -import { - DEFAULT_STATUS_BAR_USAGE_MODE, - normalizeStatusBarUsageMode, - type StatusBarUsageMode -} from '../../../../shared/status-bar-usage-mode' -import type { GitLabWorkItem } from '../../../../shared/gitlab-types' -import type { LaunchSource } from '../../../../shared/telemetry-events' -import type { TaskSourceContext } from '../../../../shared/task-source-context' -import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/pet-types' -import { - WORKSPACE_CLEANUP_CLASSIFIER_VERSION, - type WorkspaceCleanupDismissal -} from '../../../../shared/workspace-cleanup' -import { normalizeWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' -import { normalizeFeatureTipIds, type FeatureTipId } from '../../../../shared/feature-tips' -import { - hasFeatureInteraction, - normalizeFeatureInteractions, - type FeatureInteractionId, - type FeatureInteractionState -} from '../../../../shared/feature-interactions' -import { - getContextualTour, - normalizeContextualTourIds, - type ContextualTourId -} from '../../../../shared/contextual-tours' -import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' -import { - normalizeVisibleTaskProviders, - restoreAvailableDefaultTaskProvider, - resolveVisibleTaskProvider -} from '../../../../shared/task-providers' -import { - DEFAULT_HIDE_SLEEPING_WORKSPACES, - DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, - DEFAULT_SHOW_SLEEPING_WORKSPACES, - DEFAULT_STATUS_BAR_ITEMS, - DEFAULT_WORKTREE_CARD_PROPERTIES, - getWorktreeCardModeUpdates, - normalizeAgentActivityDisplayMode, - normalizeWorktreeCardProperties -} from '../../../../shared/constants' -import { - DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - normalizeBrowserPageZoomLevel -} from '../../../../shared/browser-page-zoom' -import { persistedUIValuesEqual } from '../../../../shared/persisted-ui-equality' -import { - ALL_AUTOMATION_HOSTS_FILTER, - parsePersistedAutomationHostFilter, - toPersistedAutomationHostFilter, - type AutomationHostFilter -} from '../../../../shared/automation-host-filter' -import { - normalizeExecutionHostOrder, - normalizeExecutionHostScope, - normalizeVisibleExecutionHostIds, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { - WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, - clampWorkspaceBoardColumnWidth, - clampWorkspaceBoardOpacity, - cloneDefaultWorkspaceStatuses, - normalizeWorkspaceStatuses -} from '../../../../shared/workspace-statuses' -import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' -import { clampCombinedDiffFileTreeWidth } from '../../../../shared/combined-diff-file-tree-width' -import { normalizeKagiSessionLink } from '../../../../shared/browser-url' -import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' -import { - isSettingsNavigationTarget, - type SettingsNavigationTarget -} from '@/lib/settings-navigation-types' -import { - filterSetupScriptPromptDismissalsToValidRepos, - getSetupScriptPromptDismissalKey, - sanitizeSetupScriptPromptDismissals -} from '../../lib/setup-script-prompt' -import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models' -import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache' -import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' -import { - getContextualTourRequestDecision, - hasContextualTourTarget, - getNextVisibleContextualTourStepIndex, - getPreviousVisibleContextualTourStepIndex -} from '../../components/contextual-tours/contextual-tour-gate' -import { agentKindForAgentType, formatAgentTypeLabel } from '../../lib/agent-status' -import { - deriveRunningAgentSendTargets, - resolveRunningAgentSendTarget -} from '../../lib/running-agent-targets' -import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' -import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { translate } from '@/i18n/i18n' -import { getRepoHostIdentity } from './repo-host-identity' -import { - capturePersistedUIWriteBaseline, - diffPersistedUIWriteFields, - type PersistedUIWriteBaseline -} from './persisted-ui-write-baseline' - -export type PendingSidebarWorktreeReveal = { - worktreeId: string - executionHostId?: ExecutionHostId - behavior: 'auto' | 'smooth' - highlight?: boolean - beginRename?: boolean -} - -export type PendingSidebarRowReveal = { - rowKey: string - behavior: 'auto' | 'smooth' - highlight?: boolean -} - -export type AgentSendPopoverTargetMode = { - id: string - instanceId: string - worktreeId: string - source: 'diff-notes' | 'browser-annotations' - prompt: string - label: string - launchSource: LaunchSource - eligiblePaneKeys: string[] - disabledPaneKeys: Record - status: 'open' | 'sending' | 'error' - sendingPaneKey?: string - error?: string - onPromptDelivered?: () => void -} - -export type OpenAgentSendPopoverTargetModeArgs = { - id: string - worktreeId: string - source: AgentSendPopoverTargetMode['source'] - prompt: string - label: string - launchSource: LaunchSource - onPromptDelivered?: () => void -} - -function mergeFeatureInteractionState( - current: FeatureInteractionState, - incoming: PersistedUIState['featureInteractions'] -): FeatureInteractionState { - const currentNormalized = normalizeFeatureInteractions(current) - const incomingNormalized = normalizeFeatureInteractions(incoming) - const merged: FeatureInteractionState = { ...currentNormalized } - for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { - const featureId = id as FeatureInteractionId - const currentRecord = currentNormalized[featureId] - merged[featureId] = currentRecord - ? { - firstInteractedAt: Math.min( - currentRecord.firstInteractedAt, - incomingRecord.firstInteractedAt - ), - interactionCount: Math.max( - currentRecord.interactionCount, - incomingRecord.interactionCount - ) - } - : incomingRecord - } - return merged -} - -function mergeContextualTourSeenIds( - current: readonly ContextualTourId[], - incoming: PersistedUIState['contextualToursSeenIds'] -): ContextualTourId[] { - const merged = new Set(normalizeContextualTourIds(current)) - for (const id of normalizeContextualTourIds(incoming)) { - merged.add(id) - } - return [...merged] -} - -function getContextualTourProgressionForFeatureInteraction( - state: AppState, - id: FeatureInteractionId -): 'advance' | 'complete' | 'reveal-sidebar-and-advance' | null { - if (!state.activeContextualTourId) { - return null - } - const tour = getContextualTour(state.activeContextualTourId) - const step = tour.steps[state.activeContextualTourStepIndex] - if (step?.advanceOnFeatureInteraction !== id) { - return null - } - const nextStepIndex = getNextVisibleContextualTourStepIndex({ - tour, - currentStepIndex: state.activeContextualTourStepIndex, - targetExists: hasContextualTourTarget - }) - if (nextStepIndex !== null) { - return 'advance' - } - if ( - state.activeContextualTourId === 'workspace-agent-sessions' && - state.activeContextualTourStepIndex === 0 && - id === 'terminal-pane-split' && - !state.sidebarOpen - ) { - return 'reveal-sidebar-and-advance' - } - return 'complete' -} - -function clampPetSize(size: number): number { - if (!Number.isFinite(size)) { - return PET_SIZE_DEFAULT - } - return Math.max(PET_SIZE_MIN, Math.min(PET_SIZE_MAX, Math.round(size))) -} - -// Why: local copy of TaskPage's preset→query mapping avoids a store ↔ lib circular import while warming the exact cache key. -function presetToQuery(presetId: TaskViewPresetId | null): string { - switch (presetId) { - case 'all': - case 'issues': - return 'is:issue is:open' - case 'my-issues': - return 'assignee:@me is:issue is:open' - case 'prs': - return 'is:pr is:open' - case 'review': - return 'review-requested:@me is:pr is:open' - case 'my-prs': - return 'author:@me is:pr is:open' - case null: - return 'is:issue is:open' - } -} - -// Why: migrate legacy memory+sessions ids → resource-usage; keep unknown ids so downgrade→upgrade can't strip a newer build's ids. -function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { - const source = items ?? DEFAULT_STATUS_BAR_ITEMS - const out: string[] = [] - for (const id of source) { - const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id - if (!out.includes(mapped)) { - out.push(mapped) - } - } - return out as StatusBarItem[] -} - -const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' -const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' -const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' -const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' -const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' - -function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { - const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) - if (visibleHostIds) { - return visibleHostIds - } - const legacyScope = normalizeExecutionHostScope(ui.workspaceHostScope) - return legacyScope === 'all' ? null : [legacyScope] -} - -const MIN_SIDEBAR_WIDTH = 220 -const MAX_LEFT_SIDEBAR_WIDTH = 500 -// Why: right-sidebar resize is window-relative, so widths can far exceed 500px on wide displays; this ceiling is only a corruption safety net. -const MAX_RIGHT_SIDEBAR_WIDTH = 4000 -const LINEAR_TASK_PREFETCH_LIMIT = 36 -// Why: bound disk growth across hard quits (crash paths leave acks pinned); mirrors HYDRATE_MAX_AGE_MS in agent-hooks/server.ts. -const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 -const VALID_TASK_PRESETS = new Set([ - 'all', - 'issues', - 'review', - 'my-issues', - 'my-prs', - 'prs' -]) -const VALID_LINEAR_PRESETS = new Set>([ - 'assigned', - 'created', - 'all', - 'completed' -]) -const VALID_LINEAR_MODES = new Set>([ - 'issues', - 'projects', - 'views', - 'in-orca' -]) -const VALID_JIRA_PRESETS = new Set>([ - 'assigned', - 'reported', - 'all', - 'done' -]) - -function resolvePaneKeyWorktreeIdFromTabs(state: AppState, paneKey: string): string | null { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { - if (tabs.some((tab) => tab.id === parsed.tabId)) { - return worktreeId - } - } - return null -} - -function collectAcknowledgedAgentNotificationId({ - ids, - worktreeId, - paneKey, - stateStartedAt, - previousAckAt -}: { - ids: Set - worktreeId: string | null | undefined - paneKey: string - stateStartedAt: number | null | undefined - previousAckAt: number -}): void { - if (typeof stateStartedAt !== 'number' || previousAckAt >= stateStartedAt) { - return - } - const id = buildAgentNotificationId({ worktreeId, paneKey, stateStartedAt }) - if (id) { - ids.add(id) - } -} - -function usableTimestamp(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 -} - -/** Newest turn timestamp an unread check can compare against for one agent row. */ -function latestAgentTurnTimestamp(entry: { - stateStartedAt?: number - stateHistory?: { startedAt?: number }[] -}): number { - let latest = usableTimestamp(entry.stateStartedAt) - // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. - for (const history of entry.stateHistory ?? []) { - latest = Math.max(latest, usableTimestamp(history.startedAt)) - } - return latest -} - -function isPlainPersistedRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function sanitizePersistedRepoIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - return value.filter((repoId): repoId is string => typeof repoId === 'string') -} - -function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks { - if (!isPlainPersistedRecord(trust)) { - return {} - } - const next: PersistedTrustedOrcaHooks = {} - for (const [repoId, entry] of Object.entries(trust)) { - if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) { - continue - } - next[repoId] = entry as PersistedTrustedOrcaHooks[string] - } - return next -} - -function filterTrustedOrcaHooksToValidRepos( - trust: unknown, - validRepoIds: Set -): PersistedTrustedOrcaHooks { - const sanitized = sanitizeTrustedOrcaHooks(trust) - const next: PersistedTrustedOrcaHooks = {} - for (const [repoId, entry] of Object.entries(sanitized)) { - if (validRepoIds.has(repoId)) { - next[repoId] = entry - } - } - return next -} - -function hydrateTrustedOrcaHooks( - trust: unknown, - validRepoIds: Set -): PersistedTrustedOrcaHooks { - const sanitized = sanitizeTrustedOrcaHooks(trust) - if (validRepoIds.size === 0) { - return sanitized - } - return filterTrustedOrcaHooksToValidRepos(sanitized, validRepoIds) -} - -function isSafePersistedRecordKey(key: string): boolean { - return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' -} - -function sanitizeShowDotfilesByWorktree(value: unknown): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const out: Record = {} - for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { - if (!worktreeId || !isSafePersistedRecordKey(worktreeId) || typeof showDotfiles !== 'boolean') { - continue - } - out[worktreeId] = showDotfiles - } - return out -} - -function sanitizePersistedSidebarWidth(width: unknown, fallback: number, maxWidth: number): number { - if (typeof width !== 'number' || !Number.isFinite(width)) { - return fallback - } - return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width)) -} - -// Why: persisted JSON may be tampered/corrupt — reject arrays, prototype-pollution keys, and non-finite values; drop past-TTL entries. -function sanitizeAcknowledgedAgentsByPaneKey(value: unknown): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const cutoff = Date.now() - HYDRATE_MAX_AGE_MS - const out: Record = {} - for (const [key, ackAt] of Object.entries(value as Record)) { - if (typeof key !== 'string' || !isSafePersistedRecordKey(key)) { - continue - } - if (typeof ackAt !== 'number' || !Number.isFinite(ackAt) || ackAt <= 0) { - continue - } - if (ackAt < cutoff) { - continue - } - out[key] = ackAt - } - return out -} - -function sanitizeWorkspaceCleanupDismissals( - value: unknown -): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const out: Record = {} - for (const [key, raw] of Object.entries(value as Record)) { - if (key === '__proto__' || key === 'constructor' || key === 'prototype') { - continue - } - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - continue - } - const input = raw as Record - if ( - typeof input.worktreeId !== 'string' || - typeof input.dismissedAt !== 'number' || - !Number.isFinite(input.dismissedAt) || - typeof input.fingerprint !== 'string' || - input.classifierVersion !== WORKSPACE_CLEANUP_CLASSIFIER_VERSION - ) { - continue - } - out[key] = { - worktreeId: input.worktreeId, - dismissedAt: input.dismissedAt, - fingerprint: input.fingerprint, - classifierVersion: input.classifierVersion - } - } - return out -} - -function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial): boolean { - return Object.entries(hydrated).every(([key, value]) => - persistedUIValuesEqual(state[key as keyof AppState], value) - ) -} - -function sanitizeHydratedActiveView( - value: PersistedUIState['activeView'], - experimentalActivityEnabled: boolean -): TopLevelView { - // Why: older data (pre-activeView) or a view a different build doesn't have - // falls back to terminal rather than rendering nothing. - if (!isTopLevelView(value)) { - return 'terminal' - } - // Why: activity is hidden when its setting is off, so gate only it (mobile/automations stay functional when hidden). - if (value === 'activity' && !experimentalActivityEnabled) { - return 'terminal' - } - return value -} - -let agentSendTargetModeInstanceCounter = 0 - -function createAgentSendTargetModeInstanceId(): string { - agentSendTargetModeInstanceCounter += 1 - return `${Date.now()}:${agentSendTargetModeInstanceCounter}` -} - -function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined { - if (!value || typeof value !== 'object') { - return undefined - } - const input = value as Record - const next: TaskResumeState = {} - - if (input.githubMode === 'items' || input.githubMode === 'project') { - next.githubMode = input.githubMode - } - if (input.githubItemsPreset === null) { - next.githubItemsPreset = null - } else if (typeof input.githubItemsPreset === 'string') { - if (VALID_TASK_PRESETS.has(input.githubItemsPreset as TaskViewPresetId)) { - next.githubItemsPreset = input.githubItemsPreset as TaskViewPresetId - } - } - if (typeof input.githubItemsQuery === 'string') { - next.githubItemsQuery = input.githubItemsQuery - } - if ( - typeof input.linearPreset === 'string' && - VALID_LINEAR_PRESETS.has(input.linearPreset as NonNullable) - ) { - next.linearPreset = input.linearPreset as NonNullable - } - if ( - typeof input.linearMode === 'string' && - VALID_LINEAR_MODES.has(input.linearMode as NonNullable) - ) { - next.linearMode = input.linearMode as NonNullable - } - if (typeof input.linearQuery === 'string') { - next.linearQuery = input.linearQuery - } - if (input.linearContext && typeof input.linearContext === 'object') { - const context = input.linearContext as Record - if ( - (context.kind === 'project' || context.kind === 'view') && - typeof context.id === 'string' && - context.id.trim() && - typeof context.workspaceId === 'string' && - context.workspaceId.trim() && - context.workspaceId !== 'all' - ) { - next.linearContext = { - kind: context.kind, - id: context.id, - workspaceId: context.workspaceId, - model: context.model === 'issue' || context.model === 'project' ? context.model : undefined - } - } - } - if ( - typeof input.jiraPreset === 'string' && - VALID_JIRA_PRESETS.has(input.jiraPreset as NonNullable) - ) { - next.jiraPreset = input.jiraPreset as NonNullable - } - if (typeof input.jiraQuery === 'string') { - next.jiraQuery = input.jiraQuery - } - - return Object.keys(next).length > 0 ? next : undefined -} - -export type UISlice = { - sidebarOpen: boolean - sidebarWidth: number - toggleSidebar: () => void - setSidebarOpen: (open: boolean) => void - setSidebarWidth: (width: number) => void - agentSendPopoverTargetMode: AgentSendPopoverTargetMode | null - openAgentSendPopoverTargetMode: (args: OpenAgentSendPopoverTargetModeArgs) => void - closeAgentSendPopoverTargetMode: (id?: string, instanceId?: string) => void - sendPromptToSidebarAgentTarget: (paneKey: string) => Promise - /** Bumped to ask the active worktree's Source Control notes send menu to open (keyboard shortcut). `issuedAt` bounds staleness so a request the menu never consumed can't reopen it much later. */ - diffNotesSendMenuOpenRequest: { worktreeId: string; nonce: number; issuedAt: number } | null - /** Reveal Source Control and request its notes send menu open; returns false (no-op) when the active worktree has no unsent notes. */ - openDiffNotesSendMenuForActiveWorktree: () => boolean - consumeDiffNotesSendMenuOpenRequest: (worktreeId: string) => void - /** Per-agent "I've looked at this" timestamps (paneKey → ts). A row is unvisited when no ack exists or stateStartedAt is newer than the last ack. Persisted so visited rows don't return bold on relaunch. */ - acknowledgedAgentsByPaneKey: Record - acknowledgeAgents: (paneKeys: string[]) => void - unacknowledgeAgents: (paneKeys: string[]) => void - activeView: TopLevelView - previousViewBeforeTasks: - | 'terminal' - | 'settings' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeSettings: - | 'terminal' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeActivity: - | 'terminal' - | 'settings' - | 'tasks' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeAutomations: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeSpace: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeSkills: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'artifacts' - | 'mobile' - previousViewBeforeMobile: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - previousViewBeforeArtifacts: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'mobile' - setActiveView: (view: UISlice['activeView']) => void - taskPageData: { - preselectedRepoId?: string - prefilledName?: string - taskSource?: TaskProvider - openGitHubWorkItem?: GitHubWorkItem - openGitHubSourceContext?: TaskSourceContext | null - openGitHubInitialTab?: 'conversation' | 'checks' | 'files' - openGitLabWorkItem?: GitLabWorkItem - openGitLabSourceContext?: TaskSourceContext | null - openLinearIssue?: LinearIssue - openLinearSourceContext?: TaskSourceContext | null - openJiraIssue?: JiraIssue - openJiraSourceContext?: TaskSourceContext | null - } - taskResumeState: TaskResumeState | undefined - setTaskResumeState: (updates: Partial) => void - taskListPosition: { contextKey: string; page: number; scrollTop: number } | null - setTaskListPosition: (position: UISlice['taskListPosition']) => void - githubTaskDrawerWorkItem: GitHubWorkItem | null - setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void - newWorkspaceDraft: { - repoId: string | null - // Why: project-first creation uses these when present; old drafts keep using only repoId during the additive migration. - projectId?: string | null - projectGroupId?: string | null - hostId?: ExecutionHostId | null - projectHostSetupId?: string | null - name: string - prompt: string - note: string - attachments: string[] - linkedWorkItem: { - provider?: 'github' | 'gitlab' | 'linear' | 'jira' - type: 'issue' | 'pr' | 'mr' - number: number - title: string - url: string - linearIdentifier?: string - linearBranchName?: string - jiraIdentifier?: string - repoId?: string - } | null - /** Preserve where provider data came from, separately from the host chosen to run the workspace. */ - taskSourceContext?: TaskSourceContext | null - linkedTaskSourceContext?: TaskSourceContext | null - agent: TuiAgent - linkedIssue: string - linkedPR: number | null - /** GitLab parallels — number for an issue, iid for an MR. Optional so pre-GitLab drafts still load without migration. */ - linkedGitLabIssue?: number | null - linkedGitLabMR?: number | null - // Why: repo-scoped start ref from the "Start from" picker; absent means "use the repo's effective base ref". - baseBranch?: string - // Why: review worktrees start from a head ref/SHA while Source Control compares against the provider target branch. - compareBaseRef?: string - } | null - openTaskPage: ( - data?: UISlice['taskPageData'], - options?: { recordTasksInteraction?: boolean } - ) => void - closeTaskPage: () => void - openActivityPage: () => void - closeActivityPage: () => void - selectedAutomationId: string | null - setSelectedAutomationId: (id: string | null) => void - pendingAutomationRunNavigation: { - automationId: string - runId: string | null - hostId?: ExecutionHostId - } | null - setPendingAutomationRunNavigation: ( - navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null - ) => void - openAutomationsPage: () => void - closeAutomationsPage: () => void - openSpacePage: () => void - closeSpacePage: () => void - openSkillsPage: () => void - closeSkillsPage: () => void - pendingSkillShareId: string | null - openSkillShare: (shareId: string) => void - clearPendingSkillShare: () => void - /** Set when another surface links straight to the page's shared-links view. */ - pendingSkillsSharedView: boolean - openSkillsSharedLinks: () => void - clearPendingSkillsSharedView: () => void - openArtifactsPage: () => void - closeArtifactsPage: () => void - openMobilePage: () => void - closeMobilePage: () => void - setNewWorkspaceDraft: (draft: NonNullable) => void - clearNewWorkspaceDraft: () => void - openSettingsPage: () => void - closeSettingsPage: () => void - settingsNavigationTarget: SettingsNavigationTarget | null - openSettingsTarget: (target: NonNullable) => void - clearSettingsTarget: () => void - /** Which host the Projects Settings pane shows per project (keyed by projectId). Ephemeral on purpose — never persisted, so reload reopens on the effective host. */ - settingsProjectHostSelection: Record - settingsProjectSetupSelection: Record - setSettingsProjectHostSelection: ( - projectId: string, - hostId: ExecutionHostId, - setupId?: string - ) => void - /** One-shot Appearance accordion to expand for nested Settings deep links (e.g. Usage percentages under Window & Sidebar). Cleared when Appearance consumes it. */ - appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null - setAppearanceAccordionDeepLink: ( - section: NonNullable - ) => void - clearAppearanceAccordionDeepLink: () => void - activeModal: - | 'none' - | 'create-worktree' - | 'edit-meta' - | 'delete-worktree' - | 'preserved-branch-review' - | 'forget-ssh-workspace' - | 'confirm-add-project-from-folder' - | 'confirm-non-git-folder' - | 'confirm-remove-folder' - | 'add-repo' - | 'quick-open' - | 'worktree-palette' - | 'workspace-cleanup' - | 'project-added' - | 'worktree-visibility' - | 'setup-guide' - | 'feature-wall' - | 'feature-tips' - | 'new-workspace-composer' - | 'confirm-orca-yaml-hooks' - modalData: Record - openModal: (modal: UISlice['activeModal'], data?: Record) => void - closeModal: () => void - featureTipsSeenIds: FeatureTipId[] - markFeatureTipsSeen: (ids: FeatureTipId[]) => void - featureInteractions: FeatureInteractionState - recordFeatureInteraction: (id: FeatureInteractionId) => Promise - contextualToursSeenIds: ContextualTourId[] - contextualToursAutoEligible: boolean | null - activeContextualTourId: ContextualTourId | null - activeContextualTourStepIndex: number - activeContextualTourSource: string | null - activeContextualTourSourceDetached: boolean - activeContextualTourWasFeaturePreviouslyInteracted: boolean - contextualTourNavigationInteractionSnapshot: Partial> - activeContextualTourSuppressed: boolean - contextualTourShownThisSession: boolean - contextualToursOnboardingVisible: boolean - contextualToursBlockingSurfaceVisible: boolean - lastCompletedContextualTourId: ContextualTourId | null - setContextualToursAutoEligible: (eligible: boolean) => void - setContextualToursOnboardingVisible: (visible: boolean) => void - setContextualToursBlockingSurfaceVisible: (visible: boolean) => void - requestContextualTour: ( - id: ContextualTourId, - source: string, - wasFeaturePreviouslyInteracted?: boolean, - options?: { force?: boolean } - ) => void - suppressContextualTour: (id: ContextualTourId, source: string) => void - detachContextualTourSource: (id: ContextualTourId, source: string) => void - advanceContextualTour: () => void - regressContextualTour: () => void - dismissContextualTour: (id?: ContextualTourId) => void - completeContextualTour: (id?: ContextualTourId) => void - cancelContextualTour: (id?: ContextualTourId) => void - markContextualToursSeen: (ids: ContextualTourId[]) => void - trustedOrcaHooks: PersistedTrustedOrcaHooks - markOrcaHookScriptConfirmed: ( - repoId: string, - kind: OrcaHookScriptKind, - contentHash: string - ) => void - markOrcaHookRepoAlwaysTrusted: (repoId: string) => void - clearOrcaHookTrustForRepo: (repoId: string) => void - setupScriptPromptDismissedRepoIds: readonly string[] - dismissSetupScriptPrompt: (repoHostIdentity: string) => void - setupGuideSidebarDismissed: boolean - setSetupGuideSidebarDismissed: (dismissed: boolean) => void - setupGuideBrowserMilestoneMigrated: boolean - setupGuideBrowserMilestoneLegacyComplete: boolean - markSetupGuideBrowserMilestoneMigrated: (legacyComplete: boolean) => void - browserImportHintHidden: boolean - setBrowserImportHintHidden: (hidden: boolean) => void - mobileEmulatorTabIntroDismissed: boolean - dismissMobileEmulatorTabIntro: () => void - mobileEmulatorAgentSetupDismissed: boolean - dismissMobileEmulatorAgentSetup: () => void - projectOrderManualDefaultNoticeDismissed: boolean - dismissProjectOrderManualDefaultNotice: () => void - usagePercentageDisplayChangeNoticeDismissed: boolean - dismissUsagePercentageDisplayChangeNotice: () => void - usageEmptyStateDismissed: boolean - dismissUsageEmptyState: () => void - groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' - setGroupBy: (g: UISlice['groupBy']) => void - sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' - setSortBy: (s: UISlice['sortBy']) => void - projectOrderBy: ProjectOrderBy - setProjectOrderBy: (p: ProjectOrderBy) => void - showActiveOnly: boolean - setShowActiveOnly: (v: boolean) => void - showSleepingWorkspaces: boolean - setShowSleepingWorkspaces: (v: boolean) => void - workspaceHostScope: WorkspaceHostScope - setWorkspaceHostScope: (scope: WorkspaceHostScope) => void - visibleWorkspaceHostIds: VisibleWorkspaceHostIds - setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void - workspaceHostOrder: WorkspaceHostOrder - setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void - /** Automations page host filter, in stable form. Never written from an unhydrated catalog. */ - automationHostFilter: AutomationHostFilter - setAutomationHostFilter: (filter: AutomationHostFilter) => void - manualRepoOrder: ManualRepoOrderEntry[] - hideDefaultBranchWorkspace: boolean - setHideDefaultBranchWorkspace: (v: boolean) => void - hideAutomationGeneratedWorkspaces: boolean - setHideAutomationGeneratedWorkspaces: (v: boolean) => void - hideCliCreatedWorkspaces: boolean - setHideCliCreatedWorkspaces: (v: boolean) => void - hideDetachedHeadWorkspaces: boolean - setHideDetachedHeadWorkspaces: (v: boolean) => void - hideWorkspacesFromOtherDevices: boolean - setHideWorkspacesFromOtherDevices: (v: boolean) => void - alwaysShowDefaultBranchWorkspace: boolean - setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void - showDotfilesByWorktree: Record - setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void - toggleShowDotfilesForWorktree: (worktreeId: string) => void - filterRepoIds: readonly string[] - setFilterRepoIds: (ids: readonly string[]) => void - collapsedGroups: Set - toggleCollapsedGroup: (key: string) => void - worktreeCardProperties: WorktreeCardProperty[] - _worktreeCardModeDefaulted: boolean - setWorktreeCardMode: (mode: WorktreeCardMode) => void - setWorktreeCardProperties: (properties: readonly WorktreeCardProperty[]) => void - agentActivityDisplayMode: AgentActivityDisplayMode - setAgentActivityDisplayMode: (mode: AgentActivityDisplayMode) => void - workspaceStatuses: WorkspaceStatusDefinition[] - setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void - workspaceBoardOpacity: number - setWorkspaceBoardOpacity: (opacity: number) => void - workspaceBoardColumnWidth: number - setWorkspaceBoardColumnWidth: (width: number) => void - syncTaskStatusFromWorkspaceBoard: boolean - setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void - /** Transient: the in-window Agent Dashboard companion drawer is open. Not persisted. */ - agentDashboardDrawerOpen: boolean - setAgentDashboardDrawerOpen: (open: boolean) => void - statusBarItems: StatusBarItem[] - toggleStatusBarItem: (item: StatusBarItem) => void - statusBarVisible: boolean - setStatusBarVisible: (v: boolean) => void - usagePercentageDisplay: UsagePercentageDisplay - setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void - statusBarUsageMode: StatusBarUsageMode - setStatusBarUsageMode: (mode: StatusBarUsageMode) => void - workspacePortScan: { key: string; result: WorkspacePortScanResult } | null - workspacePortScansByKey: Record - workspacePortScanRefreshing: boolean - setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void - setWorkspacePortScanProjection: ( - scan: { key: string; result: WorkspacePortScanResult } | null - ) => void - replaceWorkspacePortScans: ( - scansByKey: Record, - projection: { key: string; result: WorkspacePortScanResult } | null - ) => void - setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void - setWorkspacePortScanRefreshing: (refreshing: boolean) => void - /** Whether the pet overlay is currently visible. Persisted so "Hide pet" survives reload. Independent of the experimentalPet flag (which gates whether it can render at all). */ - petVisible: boolean - setPetVisible: (v: boolean) => void - /** Which pet is active — a bundled id or a custom UUID. Persisted via PersistedUIState. */ - petId: string - setPetId: (id: string) => void - /** User-uploaded pet images. Metadata only — bytes live in main's userData. */ - customPets: CustomPet[] - addCustomPet: (model: CustomPet) => void - removeCustomPet: (id: string) => void - /** Pet overlay size in CSS pixels (square). User-adjustable so an oversized imported sprite isn't stuck on screen. */ - petSize: number - setPetSize: (size: number) => void - pendingRevealWorktree: PendingSidebarWorktreeReveal | null - pendingRevealSidebarRow: PendingSidebarRowReveal | null - revealWorktreeInSidebar: ( - worktreeId: string, - options?: { - behavior?: PendingSidebarWorktreeReveal['behavior'] - highlight?: boolean - beginRename?: boolean - executionHostId?: ExecutionHostId - } - ) => void - revealSidebarRow: ( - rowKey: string, - options?: { - behavior?: PendingSidebarRowReveal['behavior'] - highlight?: boolean - } - ) => void - clearPendingRevealWorktreeId: () => void - clearPendingRevealSidebarRow: () => void - // Why: cleared by the diff decorator after it reveals the line, so the same id can be requested again without a stale value. - scrollToDiffCommentId: string | null - setScrollToDiffCommentId: (id: string | null) => void - persistedUIReady: boolean - /** Writer-owned fields as last hydrated from main or flushed by the writer; the debounced writer diffs against this so it only persists fields this client changed (STA-5781). */ - persistedUIWriteBaseline: PersistedUIWriteBaseline | null - /** Fields with a ui.set round-trip in flight; hydration keeps the mirror's value for them so an echo of the in-flight write can't revert a newer flip-back. */ - persistedUIWriteInFlightCounts: Partial> - /** Bumped whenever hydration replaces the baseline; an ack whose write predates the bump must not fold, or it would erase a remote write that landed during the round trip. */ - persistedUIWriteBaselineGeneration: number - notePersistedUIWriteStarted: (fields: readonly (keyof PersistedUIWriteBaseline)[]) => void - /** Settle an in-flight write: fold the acked patch into the baseline (null = rejected, leaving the fields dirty so the next change re-flushes them). */ - notePersistedUIWriteSettled: ( - fields: readonly (keyof PersistedUIWriteBaseline)[], - flushed: Partial | null, - options?: { sentAtGeneration: number } - ) => void - uiZoomLevel: number - setUIZoomLevel: (level: number) => void - editorFontZoomLevel: number - setEditorFontZoomLevel: (level: number) => void - hydratePersistedUI: (ui: PersistedUIState, source?: 'startup' | 'sync') => void - updateStatus: UpdateStatus - setUpdateStatus: (status: UpdateStatus) => void - // Why: cache last-'available' changelog so the card keeps rich content while downloading; cleared on idle/checking to avoid staleness. - updateChangelog: ChangelogData | null - // Why: UpdateCard is lazy-loaded and may miss the transient checking status; hold manual-check intent until a terminal state consumes it. - updateUserInitiatedCycle: boolean - dismissedUpdateVersion: string | null - dismissUpdate: (versionOverride?: string) => void - clearDismissedUpdateVersion: () => void - /** Dev-only channel override; null follows the running build's own channel. */ - releaseChannelOverride: ReleaseChannel | null - setReleaseChannelOverride: (channel: ReleaseChannel | null) => void - // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). - updateCardCollapsed: boolean - setUpdateCardCollapsed: (collapsed: boolean) => void - updateReassuranceSeen: boolean - markUpdateReassuranceSeen: () => void - /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ - osc52ClipboardDefaultOnNoticePending: boolean - clearOsc52ClipboardDefaultOnNotice: () => void - isFullScreen: boolean - setIsFullScreen: (v: boolean) => void - /** URL opened when a new browser tab is created. Null = blank tab (default). */ - browserDefaultUrl: string | null - setBrowserDefaultUrl: (url: string | null) => void - browserDefaultSearchEngine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null - setBrowserDefaultSearchEngine: (engine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null) => void - browserDefaultZoomLevel: number - setBrowserDefaultZoomLevel: (level: number) => void - browserKagiSessionLink: string | null - setBrowserKagiSessionLink: (link: string | null) => void -} - -export const createUISlice: StateCreator = (set, get) => ({ - sidebarOpen: true, - sidebarWidth: 280, - toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), - setSidebarOpen: (open) => set({ sidebarOpen: open }), - setSidebarWidth: (width) => set({ sidebarWidth: width }), - agentSendPopoverTargetMode: null, - openAgentSendPopoverTargetMode: (args) => { - const targets = deriveRunningAgentSendTargets(get(), args.worktreeId) - const previousMode = get().agentSendPopoverTargetMode - if (previousMode?.id === args.id && previousMode.status === 'sending') { - return - } - const disabledPaneKeys: Record = {} - for (const target of targets) { - if (target.status === 'disabled' && target.disabledReason) { - disabledPaneKeys[target.paneKey] = target.disabledReason - } - } - set({ - agentSendPopoverTargetMode: { - ...args, - instanceId: createAgentSendTargetModeInstanceId(), - eligiblePaneKeys: targets - .filter((target) => target.status === 'eligible') - .map((target) => target.paneKey), - disabledPaneKeys, - status: 'open' - } - }) - if ( - targets.some((target) => target.status === 'eligible') && - (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) - ) { - get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) - } - }, - diffNotesSendMenuOpenRequest: null, - openDiffNotesSendMenuForActiveWorktree: () => { - const worktreeId = get().activeWorktreeId - if (!worktreeId) { - return false - } - // Why: no unsent notes means nothing to send, so don't hijack focus or reveal the panel. - if ( - !get() - .getDiffComments(worktreeId) - .some((comment) => !comment.sentAt) - ) { - return false - } - get().setRightSidebarTab('source-control') - get().setRightSidebarOpen(true) - const nonce = (get().diffNotesSendMenuOpenRequest?.nonce ?? 0) + 1 - set({ diffNotesSendMenuOpenRequest: { worktreeId, nonce, issuedAt: Date.now() } }) - return true - }, - consumeDiffNotesSendMenuOpenRequest: (worktreeId) => - set((s) => - s.diffNotesSendMenuOpenRequest?.worktreeId === worktreeId - ? { diffNotesSendMenuOpenRequest: null } - : s - ), - closeAgentSendPopoverTargetMode: (id, instanceId) => - set((s) => { - if (!s.agentSendPopoverTargetMode) { - return s - } - if (id && s.agentSendPopoverTargetMode.id !== id) { - return s - } - if (instanceId && s.agentSendPopoverTargetMode.instanceId !== instanceId) { - return s - } - return { agentSendPopoverTargetMode: null } - }), - sendPromptToSidebarAgentTarget: async (paneKey) => { - const mode = get().agentSendPopoverTargetMode - if (!mode || mode.status === 'sending') { - return false - } - - const target = resolveRunningAgentSendTarget(get(), mode.worktreeId, paneKey) - if (!target || target.status !== 'eligible' || !target.ptyId) { - // Why: eligibility can drop after the menu opened; keep the picker open (row title explains) rather than adding toast noise. - return false - } - - set((s) => - s.agentSendPopoverTargetMode?.id === mode.id && - s.agentSendPopoverTargetMode.instanceId === mode.instanceId - ? { - agentSendPopoverTargetMode: { - ...s.agentSendPopoverTargetMode, - status: 'sending', - sendingPaneKey: paneKey, - error: undefined - } - } - : s - ) - - const label = formatAgentTypeLabel(target.entry.agentType) - const { activeAgentNotesSendFailureMessage, sendNotesToActiveAgentSession } = - await import('@/lib/active-agent-note-send') - const result = await sendNotesToActiveAgentSession({ - worktreeId: mode.worktreeId, - prompt: mode.prompt, - noteTarget: { tabId: target.tabId, leafId: target.leafId } - }).catch((error) => { - console.error('Failed to send notes to sidebar agent target:', error) - return { status: 'no-active-terminal' as const } - }) - - const stillCurrent = (): boolean => { - const current = get().agentSendPopoverTargetMode - return current?.id === mode.id && current.instanceId === mode.instanceId - } - - if (result.status !== 'sent') { - const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) - set((s) => - s.agentSendPopoverTargetMode?.id === mode.id && - s.agentSendPopoverTargetMode.instanceId === mode.instanceId - ? { - agentSendPopoverTargetMode: { - ...s.agentSendPopoverTargetMode, - status: 'error', - sendingPaneKey: undefined, - error: message - } - } - : s - ) - const { toast } = await import('sonner') - if (!stillCurrent()) { - return false - } - toast.error( - translate('auto.store.slices.ui.53883b7bc3', "Couldn't send to {{value0}}", { - value0: label - }), - { description: message } - ) - return false - } - - // Delivery ack, telemetry, and toast belong to the completed send, not to the - // picker that launched it; only the close below is scoped to this instance. - mode.onPromptDelivered?.() - const [{ toast }, { track }] = await Promise.all([import('sonner'), import('@/lib/telemetry')]) - track('agent_prompt_sent', { - agent_kind: agentKindForAgentType(target.entry.agentType), - launch_source: mode.launchSource, - request_kind: 'followup' - }) - toast.success( - translate('auto.store.slices.ui.66e3bd7ce6', 'Sent to {{value0}}', { value0: label }) - ) - get().closeAgentSendPopoverTargetMode(mode.id, mode.instanceId) - return true - }, - - acknowledgedAgentsByPaneKey: {}, - acknowledgeAgents: (paneKeys) => { - const notificationIdsToDismiss = new Set() - set((s) => { - if (paneKeys.length === 0) { - return s - } - const now = Date.now() - const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {}) - // Why: only reallocate if an ack advances; compare prev | null = null - for (const key of paneKeys) { - const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 - // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, - // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the - // row, so its auto-ack effect re-fires on each new millisecond forever (React #185). - let stamp = now - const liveEntry = s.agentStatusByPaneKey?.[key] - if (liveEntry) { - collectAcknowledgedAgentNotificationId({ - ids: notificationIdsToDismiss, - worktreeId: resolvePaneKeyWorktreeIdFromTabs(s, key) ?? liveEntry.worktreeId, - paneKey: key, - stateStartedAt: liveEntry.stateStartedAt, - previousAckAt: prev - }) - stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry)) - } - const retained = s.retainedAgentsByPaneKey?.[key] - if (retained) { - collectAcknowledgedAgentNotificationId({ - ids: notificationIdsToDismiss, - worktreeId: retained.worktreeId, - paneKey: key, - stateStartedAt: retained.entry.stateStartedAt, - previousAckAt: prev - }) - stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry)) - } - for (const unsupported of migrationUnsupported) { - // Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn. - if (unsupported.paneKey === key) { - stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt)) - } - } - if (prev < stamp) { - if (next === null) { - next = { ...s.acknowledgedAgentsByPaneKey } - } - next[key] = stamp - } - } - return next ? { acknowledgedAgentsByPaneKey: next } : s - }) - const notificationIds = [...notificationIdsToDismiss] - if (notificationIds.length > 0 && typeof window !== 'undefined') { - void window.api?.notifications?.dismiss?.(notificationIds) - } - }, - unacknowledgeAgents: (paneKeys) => - set((s) => { - if (paneKeys.length === 0) { - return s - } - let next: Record | null = null - for (const key of paneKeys) { - if (s.acknowledgedAgentsByPaneKey[key] !== undefined) { - if (next === null) { - next = { ...s.acknowledgedAgentsByPaneKey } - } - delete next[key] - } - } - return next ? { acknowledgedAgentsByPaneKey: next } : s - }), - - activeView: 'terminal', - previousViewBeforeTasks: 'terminal', - previousViewBeforeSettings: 'terminal', - previousViewBeforeActivity: 'terminal', - previousViewBeforeAutomations: 'terminal', - previousViewBeforeSpace: 'terminal', - previousViewBeforeSkills: 'terminal', - pendingSkillShareId: null, - pendingSkillsSharedView: false, - previousViewBeforeMobile: 'terminal', - previousViewBeforeArtifacts: 'terminal', - setActiveView: (view) => set({ activeView: view }), - taskPageData: {}, - taskResumeState: undefined, - taskListPosition: null, - githubTaskDrawerWorkItem: null, - newWorkspaceDraft: null, - openTaskPage: (data = {}, options = {}) => { - if (options.recordTasksInteraction !== false) { - const wasTasksPreviouslyInteracted = hasFeatureInteraction(get().featureInteractions, 'tasks') - set((state) => ({ - contextualTourNavigationInteractionSnapshot: { - ...state.contextualTourNavigationInteractionSnapshot, - tasks: wasTasksPreviouslyInteracted - } - })) - get().recordFeatureInteraction?.('tasks') - } - if (data.openGitHubWorkItem) { - get().recordFeatureInteraction?.('github-tasks') - } - if (data.openGitLabWorkItem) { - get().recordFeatureInteraction?.('gitlab-tasks') - } - if (data.openLinearIssue) { - get().recordFeatureInteraction?.('linear-tasks') - } - if (data.openJiraIssue) { - get().recordFeatureInteraction?.('jira-tasks') - } - // Why: record a Tasks visit in shared back/forward history; all task-source variants collapse to one deduped 'tasks' entry. - const detailEntry = data.openGitHubWorkItem - ? ({ - kind: 'task-detail', - source: 'github', - workItem: data.openGitHubWorkItem, - sourceContext: data.openGitHubSourceContext, - initialTab: data.openGitHubInitialTab - } as const) - : data.openGitLabWorkItem - ? ({ - kind: 'task-detail', - source: 'gitlab', - workItem: data.openGitLabWorkItem, - sourceContext: data.openGitLabSourceContext - } as const) - : data.openLinearIssue - ? ({ - kind: 'task-detail', - source: 'linear', - issue: data.openLinearIssue, - sourceContext: data.openLinearSourceContext - } as const) - : data.openJiraIssue - ? ({ - kind: 'task-detail', - source: 'jira', - issue: data.openJiraIssue, - sourceContext: data.openJiraSourceContext - } as const) - : null - const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex] - const currentIsTaskStack = - currentEntry === 'tasks' || - (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') - if (!detailEntry || !currentIsTaskStack) { - get().recordViewVisit('tasks') - } - if (detailEntry) { - get().recordViewVisit(detailEntry) - } - set((state) => ({ - activeView: 'tasks', - previousViewBeforeTasks: - state.activeView === 'tasks' ? state.previousViewBeforeTasks : state.activeView, - taskPageData: data - })) - // Why: prefetch the work-item list during first render so the page's effect hits a warm/in-flight SWR cache (~300–800ms win). - const state = get() - const preferredVisibleTaskProviders = normalizeVisibleTaskProviders( - state.settings?.visibleTaskProviders - ) - const visibleTaskProviders = restoreAvailableDefaultTaskProvider( - preferredVisibleTaskProviders, - { - gitlabInstalled: state.preflightStatus?.glab?.installed === true, - linearConnected: state.linearStatus?.connected === true - }, - state.settings?.defaultTaskSource - ) - const resolvedSource = resolveVisibleTaskProvider( - data.taskSource ?? state.settings?.defaultTaskSource, - visibleTaskProviders - ) - const resolvedMode = state.taskResumeState?.githubMode ?? 'items' - if (resolvedSource === 'github' && resolvedMode === 'items') { - const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo) && repo.path) - const selectedRepos = (() => { - const preferred = data.preselectedRepoId - if (preferred) { - const repo = eligibleRepos.find((r) => r.id === preferred) - return repo ? [repo] : [] - } - const persisted = state.settings?.defaultRepoSelection - if (Array.isArray(persisted)) { - const selected = eligibleRepos.filter((repo) => persisted.includes(repo.id)) - if (selected.length > 0) { - return selected - } - } - return eligibleRepos - })() - - const resume = state.taskResumeState - const defaultPreset = state.settings?.defaultTaskViewPreset ?? 'all' - // Why: must match the query TaskPage's resume effect mounts with, else the warm cache key misses and prefetch is wasted. - const query = - resume?.githubItemsPreset === null - ? (resume.githubItemsQuery ?? '').trim() - : presetToQuery(resume?.githubItemsPreset ?? defaultPreset) - for (const repo of selectedRepos) { - state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query, { - sourceContext: - data.openGitHubSourceContext?.provider === 'github' && - data.openGitHubSourceContext.repoId === repo.id - ? data.openGitHubSourceContext - : null - }) - } - } - if (resolvedSource === 'linear' && typeof state.prefetchLinearIssues === 'function') { - const resume = state.taskResumeState - const query = (resume?.linearQuery ?? '').trim() - const sourceContext = - data.openLinearSourceContext?.provider === 'linear' ? data.openLinearSourceContext : null - if (query) { - state.prefetchLinearIssues( - { kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }, - { sourceContext } - ) - } else { - // Why: TaskPage no longer exposes Linear preset filters; keep prefetch aligned with the default unsearched issue list. - state.prefetchLinearIssues( - { - kind: 'list', - filter: 'all', - limit: LINEAR_TASK_PREFETCH_LIMIT - }, - { sourceContext } - ) - } - } - }, - setTaskResumeState: (updates) => - set((s) => { - const next = { ...s.taskResumeState, ...updates } - window.api.ui.set({ taskResumeState: next }).catch(console.error) - return { taskResumeState: next } - }), - setTaskListPosition: (taskListPosition) => set({ taskListPosition }), - setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }), - closeTaskPage: () => - set((state) => { - // Why: if parked on a 'tasks' entry, rewind the history index so Back/Forward aren't no-ops; keep 0 if it's the only entry. - const currentEntry = state.worktreeNavHistory[state.worktreeNavHistoryIndex] - let nextHistoryIndex = state.worktreeNavHistoryIndex - if ( - currentEntry === 'tasks' || - (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') - ) { - const prev = findPrevLiveNonTaskStackHistoryIndex(state) - if (prev !== null) { - nextHistoryIndex = prev - } else if (typeof currentEntry === 'object' && state.worktreeNavHistory[0] === 'tasks') { - nextHistoryIndex = 0 - } - } - return { - activeView: state.previousViewBeforeTasks, - taskPageData: {}, - githubTaskDrawerWorkItem: null, - worktreeNavHistoryIndex: nextHistoryIndex - } - }), - openActivityPage: () => { - if (get().settings?.experimentalActivity !== true) { - return - } - set((state) => ({ - activeView: 'activity', - previousViewBeforeActivity: - state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView - })) - }, - closeActivityPage: () => - set((state) => ({ - activeView: state.previousViewBeforeActivity - })), - selectedAutomationId: null, - setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), - pendingAutomationRunNavigation: null, - setPendingAutomationRunNavigation: (navigation) => - set({ pendingAutomationRunNavigation: navigation }), - openAutomationsPage: () => { - get().recordViewVisit('automations') - set((state) => ({ - activeView: 'automations', - previousViewBeforeAutomations: - state.activeView === 'automations' ? state.previousViewBeforeAutomations : state.activeView - })) - }, - closeAutomationsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeAutomations, - worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'automations') - })), - openSpacePage: () => { - get().recordFeatureInteraction?.('workspace-cleanup') - set((state) => ({ - activeView: 'space', - previousViewBeforeSpace: - state.activeView === 'space' ? state.previousViewBeforeSpace : state.activeView - })) - }, - closeSpacePage: () => - set((state) => ({ - activeView: state.previousViewBeforeSpace - })), - openSkillsPage: () => { - get().recordViewVisit('skills') - set((state) => ({ - activeView: 'skills', - previousViewBeforeSkills: - state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView - })) - }, - closeSkillsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeSkills, - worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'skills') - })), - openSkillShare: (shareId) => { - get().recordViewVisit('skills') - set((state) => ({ - activeView: 'skills', - previousViewBeforeSkills: - state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, - pendingSkillShareId: shareId - })) - }, - clearPendingSkillShare: () => set({ pendingSkillShareId: null }), - openSkillsSharedLinks: () => { - get().recordViewVisit('skills') - set((state) => ({ - activeView: 'skills', - previousViewBeforeSkills: - state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, - pendingSkillsSharedView: true - })) - }, - clearPendingSkillsSharedView: () => set({ pendingSkillsSharedView: false }), - openArtifactsPage: () => { - get().recordViewVisit('artifacts') - set((state) => ({ - activeView: 'artifacts', - previousViewBeforeArtifacts: - state.activeView === 'artifacts' ? state.previousViewBeforeArtifacts : state.activeView - })) - }, - closeArtifactsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeArtifacts, - worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'artifacts') - })), - openMobilePage: () => - set((state) => ({ - activeView: 'mobile', - previousViewBeforeMobile: - state.activeView === 'mobile' ? state.previousViewBeforeMobile : state.activeView - })), - closeMobilePage: () => - set((state) => ({ - activeView: state.previousViewBeforeMobile - })), - setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), - clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }), - openSettingsPage: () => { - // Why: settings search is a transient filter; opening Settings shouldn't inherit hidden sections from last visit. - get().setSettingsSearchQuery('') - set((state) => ({ - activeView: 'settings', - // Why: preserve the originating view so Settings back returns there (e.g. in-progress draft), not always terminal. - previousViewBeforeSettings: - state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView - })) - }, - closeSettingsPage: () => - set((state) => { - const previousView = - state.previousViewBeforeSettings === 'activity' && - state.settings?.experimentalActivity !== true - ? 'terminal' - : state.previousViewBeforeSettings - return { activeView: previousView } - }), - settingsNavigationTarget: null, - openSettingsTarget: (target) => { - if (!isSettingsNavigationTarget(target)) { - if (import.meta.env.DEV) { - throw new TypeError('openSettingsTarget received an invalid navigation target') - } - return - } - set({ settingsNavigationTarget: target }) - }, - clearSettingsTarget: () => set({ settingsNavigationTarget: null }), - settingsProjectHostSelection: {}, - settingsProjectSetupSelection: {}, - // Why: renderer-only, never persisted — no window.api.ui.set, and absent from the debounced UI writer in App.tsx. - setSettingsProjectHostSelection: (projectId, hostId, setupId) => - set((s) => { - const nextSetupSelections = { ...s.settingsProjectSetupSelection } - if (setupId) { - nextSetupSelections[projectId] = setupId - } else { - delete nextSetupSelections[projectId] - } - if ( - s.settingsProjectHostSelection[projectId] === hostId && - s.settingsProjectSetupSelection[projectId] === setupId - ) { - return s - } - return { - settingsProjectHostSelection: { - ...s.settingsProjectHostSelection, - [projectId]: hostId - }, - settingsProjectSetupSelection: nextSetupSelections - } - }), - appearanceAccordionDeepLink: null, - setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }), - clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }), - - activeModal: 'none', - modalData: {}, - openModal: (modal, data = {}) => { - if (modal === 'add-repo' || modal === 'create-worktree') { - get().recordFeatureInteraction?.('workspace-creation') - } - const evicted = get().modalData - set({ - activeModal: modal, - modalData: data - }) - settleEvictedModalData(evicted) - }, - closeModal: () => { - const evicted = get().modalData - set({ activeModal: 'none', modalData: {} }) - settleEvictedModalData(evicted) - }, - featureTipsSeenIds: [], - markFeatureTipsSeen: (ids) => - set((s) => { - if (ids.length === 0) { - return s - } - const current = new Set(s.featureTipsSeenIds) - let changed = false - for (const id of ids) { - if (!current.has(id)) { - current.add(id) - changed = true - } - } - if (!changed) { - return s - } - const next = [...current] - window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error) - return { featureTipsSeenIds: next } - }), - featureInteractions: {}, - recordFeatureInteraction: (id) => { - let tourProgression: ReturnType = null - let persistPromise = Promise.resolve() - set((s) => { - if (!s.persistedUIReady) { - return s - } - tourProgression = getContextualTourProgressionForFeatureInteraction(s, id) - const existing = s.featureInteractions[id] - const next: FeatureInteractionState = { - ...s.featureInteractions, - [id]: { - firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: (existing?.interactionCount ?? 0) + 1 - } - } - if (typeof window !== 'undefined') { - const recordInteraction = window.api.ui.recordFeatureInteraction - const persist = recordInteraction - ? recordInteraction(id).then((ui) => { - set((current) => ({ - featureInteractions: mergeFeatureInteractionState( - current.featureInteractions, - ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - current.contextualToursSeenIds, - ui.contextualToursSeenIds - ) - })) - }) - : window.api.ui.set({ featureInteractions: next }) - persistPromise = persist.catch(console.error) - } - if (tourProgression === 'reveal-sidebar-and-advance') { - // Why: split can fire from keyboard/menu with the sidebar closed, but the next tour target lives in the sidebar. - return { - featureInteractions: next, - sidebarOpen: true, - activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 - } - } - return { featureInteractions: next } - }) - if (tourProgression === 'complete') { - get().completeContextualTour() - } else if (tourProgression === 'advance') { - get().advanceContextualTour() - } - return persistPromise - }, - contextualToursSeenIds: [], - contextualToursAutoEligible: null, - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - contextualTourNavigationInteractionSnapshot: {}, - activeContextualTourSuppressed: false, - contextualTourShownThisSession: false, - contextualToursOnboardingVisible: false, - contextualToursBlockingSurfaceVisible: false, - lastCompletedContextualTourId: null, - setContextualToursAutoEligible: (eligible) => - set((s) => { - if (s.contextualToursAutoEligible === eligible) { - return s - } - if (typeof window !== 'undefined') { - window.api.ui.set({ contextualToursAutoEligible: eligible }).catch(console.error) - } - return { contextualToursAutoEligible: eligible } - }), - setContextualToursOnboardingVisible: (visible) => - set((s) => - s.contextualToursOnboardingVisible === visible - ? s - : { contextualToursOnboardingVisible: visible } - ), - setContextualToursBlockingSurfaceVisible: (visible) => - set((s) => - s.contextualToursBlockingSurfaceVisible === visible - ? s - : { contextualToursBlockingSurfaceVisible: visible } - ), - requestContextualTour: (id, source, wasFeaturePreviouslyInteracted, options) => - set((s) => { - const tour = getContextualTour(id) - const decision = getContextualTourRequestDecision({ - tour, - persistedUIReady: s.persistedUIReady, - autoEligible: options?.force === true || s.contextualToursAutoEligible === true, - onboardingVisible: s.contextualToursOnboardingVisible, - seenIds: options?.force === true ? [] : s.contextualToursSeenIds, - sessionConsumed: options?.force === true ? false : s.contextualTourShownThisSession, - activeTourId: s.activeContextualTourId, - activeModal: s.activeModal, - blockingSurfaceVisible: s.contextualToursBlockingSurfaceVisible, - targetExists: hasContextualTourTarget - }) - if (decision.kind !== 'start') { - if (s.contextualTourNavigationInteractionSnapshot[id] === undefined) { - return s - } - const { [id]: _consumed, ...remainingNavigationSnapshot } = - s.contextualTourNavigationInteractionSnapshot - void _consumed - return { contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot } - } - const navigationSnapshot = s.contextualTourNavigationInteractionSnapshot[id] - const { [id]: _consumed, ...remainingNavigationSnapshot } = - s.contextualTourNavigationInteractionSnapshot - void _consumed - return { - activeContextualTourId: id, - activeContextualTourStepIndex: decision.stepIndex, - activeContextualTourSource: source, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: - wasFeaturePreviouslyInteracted ?? - navigationSnapshot ?? - hasFeatureInteraction(s.featureInteractions, id), - contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot, - activeContextualTourSuppressed: false, - contextualTourShownThisSession: true, - lastCompletedContextualTourId: null - } - }), - suppressContextualTour: (id, source) => - set((s) => { - if ( - s.activeContextualTourId !== id || - s.activeContextualTourSource !== source || - s.activeContextualTourSourceDetached - ) { - return s - } - return s.activeContextualTourSuppressed ? s : { activeContextualTourSuppressed: true } - }), - detachContextualTourSource: (id, source) => - set((s) => { - if (s.activeContextualTourId !== id || s.activeContextualTourSource !== source) { - return s - } - return s.activeContextualTourSourceDetached ? s : { activeContextualTourSourceDetached: true } - }), - advanceContextualTour: () => - set((s) => { - if (!s.activeContextualTourId) { - return s - } - const tour = getContextualTour(s.activeContextualTourId) - const nextStepIndex = getNextVisibleContextualTourStepIndex({ - tour, - currentStepIndex: s.activeContextualTourStepIndex, - targetExists: hasContextualTourTarget - }) - if (nextStepIndex !== null) { - return { activeContextualTourStepIndex: nextStepIndex } - } - // Why: browser step 3's target lives in a closed menu until that step is active. - if ( - s.activeContextualTourId === 'browser' && - s.activeContextualTourStepIndex + 1 < tour.steps.length - ) { - return { activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 } - } - return s - }), - regressContextualTour: () => - set((s) => { - if (!s.activeContextualTourId) { - return s - } - const previousStepIndex = getPreviousVisibleContextualTourStepIndex({ - tour: getContextualTour(s.activeContextualTourId), - currentStepIndex: s.activeContextualTourStepIndex, - targetExists: hasContextualTourTarget - }) - if (previousStepIndex === null) { - return s - } - return { activeContextualTourStepIndex: previousStepIndex } - }), - dismissContextualTour: (id) => { - const activeTourId = get().activeContextualTourId - if (id && activeTourId !== id) { - return - } - const tourId = id ?? activeTourId - if (tourId) { - get().markContextualToursSeen([tourId]) - } - set((s) => { - if (id && s.activeContextualTourId !== id) { - return s - } - return { - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - activeContextualTourSuppressed: false, - lastCompletedContextualTourId: null - } - }) - }, - completeContextualTour: (id) => { - const activeTourId = get().activeContextualTourId - if (id && activeTourId !== id) { - return - } - const tourId = id ?? activeTourId - if (tourId) { - get().markContextualToursSeen([tourId]) - } - set((s) => { - if (id && s.activeContextualTourId !== id) { - return s - } - return { - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - activeContextualTourSuppressed: false, - lastCompletedContextualTourId: tourId ?? null - } - }) - }, - cancelContextualTour: (id) => - set((s) => { - const activeTourId = s.activeContextualTourId - const tourId = id ?? activeTourId - if (!tourId || (id && activeTourId !== id)) { - return s - } - const alreadyShown = s.contextualToursSeenIds.includes(tourId) - return { - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - activeContextualTourSuppressed: false, - lastCompletedContextualTourId: null, - contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false - } - }), - markContextualToursSeen: (ids) => - set((s) => { - if (ids.length === 0) { - return s - } - const current = new Set(s.contextualToursSeenIds) - let changed = false - for (const id of ids) { - if (!current.has(id)) { - current.add(id) - changed = true - } - } - if (!changed) { - return s - } - const next = [...current] - if (typeof window !== 'undefined') { - window.api.ui.set({ contextualToursSeenIds: next }).catch(console.error) - } - return { contextualToursSeenIds: next } - }), - trustedOrcaHooks: {}, - markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => - set((s) => { - const existing = s.trustedOrcaHooks[repoId] - const currentEntry = existing?.[kind] - if (currentEntry?.contentHash === contentHash) { - return s - } - const nextRepo = { - ...existing, - [kind]: { contentHash, approvedAt: Date.now() } - } - const next = { ...s.trustedOrcaHooks, [repoId]: nextRepo } - window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) - return { trustedOrcaHooks: next } - }), - markOrcaHookRepoAlwaysTrusted: (repoId) => - set((s) => { - const existing = s.trustedOrcaHooks[repoId] - if (existing?.all) { - return s - } - const next = { - ...s.trustedOrcaHooks, - [repoId]: { - ...existing, - all: { approvedAt: Date.now() } - } - } - window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) - return { trustedOrcaHooks: next } - }), - clearOrcaHookTrustForRepo: (repoId) => - set((s) => { - if (!(repoId in s.trustedOrcaHooks)) { - return s - } - const next = { ...s.trustedOrcaHooks } - delete next[repoId] - window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) - return { trustedOrcaHooks: next } - }), - setupScriptPromptDismissedRepoIds: [], - dismissSetupScriptPrompt: (repoHostIdentity) => - set((s) => { - const dismissalKey = getSetupScriptPromptDismissalKey(repoHostIdentity) - if (!repoHostIdentity || s.setupScriptPromptDismissedRepoIds.includes(dismissalKey)) { - return s - } - const next = [...s.setupScriptPromptDismissedRepoIds, dismissalKey] - window.api.ui.set({ setupScriptPromptDismissedRepoIds: next }).catch(console.error) - return { setupScriptPromptDismissedRepoIds: next } - }), - setupGuideSidebarDismissed: false, - setSetupGuideSidebarDismissed: (dismissed) => - set((s) => { - if (s.setupGuideSidebarDismissed === dismissed) { - return s - } - window.api.ui.set({ setupGuideSidebarDismissed: dismissed }).catch(console.error) - return { setupGuideSidebarDismissed: dismissed } - }), - setupGuideBrowserMilestoneMigrated: true, - setupGuideBrowserMilestoneLegacyComplete: false, - markSetupGuideBrowserMilestoneMigrated: (legacyComplete) => - set((s) => { - if ( - s.setupGuideBrowserMilestoneMigrated && - s.setupGuideBrowserMilestoneLegacyComplete === legacyComplete - ) { - return s - } - const updates = { - setupGuideBrowserMilestoneMigrated: true, - setupGuideBrowserMilestoneLegacyComplete: legacyComplete - } - window.api.ui.set(updates).catch(console.error) - return updates - }), - browserImportHintHidden: false, - setBrowserImportHintHidden: (hidden) => - set((s) => { - if (s.browserImportHintHidden === hidden) { - return s - } - window.api.ui.set({ browserImportHintHidden: hidden }).catch(console.error) - return { browserImportHintHidden: hidden } - }), - mobileEmulatorTabIntroDismissed: false, - dismissMobileEmulatorTabIntro: () => - set((s) => { - if (s.mobileEmulatorTabIntroDismissed) { - return s - } - window.api.ui.set({ mobileEmulatorTabIntroDismissed: true }).catch(console.error) - return { mobileEmulatorTabIntroDismissed: true } - }), - mobileEmulatorAgentSetupDismissed: false, - dismissMobileEmulatorAgentSetup: () => - set((s) => { - if (s.mobileEmulatorAgentSetupDismissed) { - return s - } - window.api.ui.set({ mobileEmulatorAgentSetupDismissed: true }).catch(console.error) - return { mobileEmulatorAgentSetupDismissed: true } - }), - projectOrderManualDefaultNoticeDismissed: true, - dismissProjectOrderManualDefaultNotice: () => - set((s) => { - if (s.projectOrderManualDefaultNoticeDismissed) { - return s - } - window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) - return { projectOrderManualDefaultNoticeDismissed: true } - }), - // Why: default true so pre-hydration / new sessions never flash the change notice before persistence resolves. - usagePercentageDisplayChangeNoticeDismissed: true, - dismissUsagePercentageDisplayChangeNotice: () => - set((s) => { - if (s.usagePercentageDisplayChangeNoticeDismissed) { - return s - } - window.api.ui.set({ usagePercentageDisplayChangeNoticeDismissed: true }).catch(console.error) - return { usagePercentageDisplayChangeNoticeDismissed: true } - }), - usageEmptyStateDismissed: false, - dismissUsageEmptyState: () => - set((s) => { - if (s.usageEmptyStateDismissed) { - return s - } - window.api.ui.set({ usageEmptyStateDismissed: true }).catch(console.error) - return { usageEmptyStateDismissed: true } - }), - - groupBy: 'repo', - // Why: group keys are mode-specific, so clear collapsed state on mode switch — stale keys are meaningless and accumulate. - setGroupBy: (g) => { - window.api.ui.set({ groupBy: g, collapsedGroups: [] }).catch(console.error) - set({ groupBy: g, collapsedGroups: new Set() }) - }, - - sortBy: 'recent', - setSortBy: (s) => set({ sortBy: s }), - - // Why: bare set — persists only via the debounced window.api.ui.set writer in App.tsx, not on its own. - projectOrderBy: 'manual', - setProjectOrderBy: (p) => set({ projectOrderBy: p }), - - showActiveOnly: false, - setShowActiveOnly: (v) => set({ showActiveOnly: v }), - - showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, - setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), - - workspaceHostScope: 'all', - // Why: host scope is presentation/filtering only — must never trigger resource teardown (terminals, browser pages). - setWorkspaceHostScope: (scope) => { - const normalized = normalizeExecutionHostScope(scope) - const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] - set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) - window.api.ui - .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) - .catch(console.error) - }, - visibleWorkspaceHostIds: null, - setVisibleWorkspaceHostIds: (ids) => { - const normalized = normalizeVisibleExecutionHostIds(ids) - // Why: workspaceHostScope stays the compat/default-host signal for creation flows; visibility can now be multi-select. - let workspaceHostScope: WorkspaceHostScope = get().workspaceHostScope - if (normalized === null) { - workspaceHostScope = 'all' - } else if (normalized.length === 1) { - workspaceHostScope = normalized[0] - } - set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) - window.api.ui - .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) - .catch(console.error) - }, - workspaceHostOrder: [], - setWorkspaceHostOrder: (ids) => { - const workspaceHostOrder = normalizeExecutionHostOrder(ids) - set({ workspaceHostOrder }) - window.api.ui.set({ workspaceHostOrder }).catch(console.error) - }, - automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, - setAutomationHostFilter: (filter) => { - window.api.ui - .set({ automationHostFilter: toPersistedAutomationHostFilter(filter) }) - .catch(console.error) - set({ automationHostFilter: filter }) - }, - manualRepoOrder: [], - - hideDefaultBranchWorkspace: false, - setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), - hideAutomationGeneratedWorkspaces: false, - setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), - hideCliCreatedWorkspaces: false, - setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), - hideDetachedHeadWorkspaces: false, - setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), - hideWorkspacesFromOtherDevices: false, - setHideWorkspacesFromOtherDevices: (v) => set({ hideWorkspacesFromOtherDevices: v }), - alwaysShowDefaultBranchWorkspace: true, - setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), - - showDotfilesByWorktree: {}, - setShowDotfilesForWorktree: (worktreeId, showDotfiles) => - set((s) => { - if (!worktreeId) { - return s - } - const current = s.showDotfilesByWorktree[worktreeId] ?? true - if (current === showDotfiles) { - return s - } - const next = { ...s.showDotfilesByWorktree } - // Why: showing dotfiles is the default; only persist worktree-level opt-outs. - if (showDotfiles) { - delete next[worktreeId] - } else { - next[worktreeId] = false - } - return { showDotfilesByWorktree: next } - }), - toggleShowDotfilesForWorktree: (worktreeId) => - set((s) => { - if (!worktreeId) { - return s - } - const nextShowDotfiles = !(s.showDotfilesByWorktree[worktreeId] ?? true) - const next = { ...s.showDotfilesByWorktree } - if (nextShowDotfiles) { - delete next[worktreeId] - } else { - next[worktreeId] = false - } - return { showDotfilesByWorktree: next } - }), - - filterRepoIds: [], - setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), - - collapsedGroups: new Set(), - toggleCollapsedGroup: (key) => - set((s) => { - const next = new Set(s.collapsedGroups) - if (next.has(key)) { - next.delete(key) - } else { - next.add(key) - } - window.api.ui.set({ collapsedGroups: [...next] }).catch(console.error) - return { collapsedGroups: next } - }), - - worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES], - _worktreeCardModeDefaulted: true, - setWorktreeCardMode: (mode) => { - const updates = getWorktreeCardModeUpdates(mode) - set((s) => ({ - settings: s.settings ? { ...s.settings, ...updates.settings } : s.settings, - worktreeCardProperties: updates.ui.worktreeCardProperties, - _worktreeCardModeDefaulted: true - })) - void Promise.all([ - window.api.settings.set(updates.settings).then((nextSettings) => { - if (nextSettings) { - set({ settings: nextSettings }) - } - }), - window.api.ui.set(updates.ui) - ]).catch(console.error) - }, - setWorktreeCardProperties: (properties) => { - const normalized = normalizeWorktreeCardProperties(properties) - set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) - window.api.ui - .set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) - .catch(console.error) - }, - agentActivityDisplayMode: DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, - setAgentActivityDisplayMode: (mode) => { - const normalized = normalizeAgentActivityDisplayMode(mode) - window.api.ui.set({ agentActivityDisplayMode: normalized }).catch(console.error) - set({ agentActivityDisplayMode: normalized }) - }, - - workspaceStatuses: cloneDefaultWorkspaceStatuses(), - setWorkspaceStatuses: (statuses) => { - const normalized = normalizeWorkspaceStatuses(statuses) - window.api.ui.set({ workspaceStatuses: normalized }).catch(console.error) - set({ workspaceStatuses: normalized }) - }, - - workspaceBoardOpacity: 1, - setWorkspaceBoardOpacity: (opacity) => { - const clamped = clampWorkspaceBoardOpacity(opacity) - window.api.ui.set({ workspaceBoardOpacity: clamped }).catch(console.error) - set({ workspaceBoardOpacity: clamped }) - }, - - workspaceBoardColumnWidth: WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, - setWorkspaceBoardColumnWidth: (width) => { - const clamped = clampWorkspaceBoardColumnWidth(width) - window.api.ui.set({ workspaceBoardColumnWidth: clamped }).catch(console.error) - set({ workspaceBoardColumnWidth: clamped }) - }, - - syncTaskStatusFromWorkspaceBoard: false, - setSyncTaskStatusFromWorkspaceBoard: (enabled) => { - window.api.ui.set({ syncTaskStatusFromWorkspaceBoard: enabled }).catch(console.error) - set({ syncTaskStatusFromWorkspaceBoard: enabled }) - }, - - statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], - toggleStatusBarItem: (item) => - set((s) => { - const current = s.statusBarItems || DEFAULT_STATUS_BAR_ITEMS - const updated = current.includes(item) - ? current.filter((i) => i !== item) - : [...current, item] - window.api.ui.set({ statusBarItems: updated }).catch(console.error) - return { statusBarItems: updated } - }), - - agentDashboardDrawerOpen: false, - setAgentDashboardDrawerOpen: (open) => set({ agentDashboardDrawerOpen: open }), - statusBarVisible: true, - setStatusBarVisible: (v) => { - window.api.ui.set({ statusBarVisible: v }).catch(console.error) - set({ statusBarVisible: v }) - }, - usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, - setUsagePercentageDisplay: (display) => { - const normalized = normalizeUsagePercentageDisplay(display) - // Why: changing the control is the discovery path, so permanently dismiss the one-time change notice. - window.api.ui - .set({ - usagePercentageDisplay: normalized, - usagePercentageDisplayChangeNoticeDismissed: true - }) - .catch(console.error) - set({ - usagePercentageDisplay: normalized, - usagePercentageDisplayChangeNoticeDismissed: true - }) - }, - statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, - setStatusBarUsageMode: (mode) => { - const normalized = normalizeStatusBarUsageMode(mode) - window.api.ui.set({ statusBarUsageMode: normalized }).catch(console.error) - set({ statusBarUsageMode: normalized }) - }, - workspacePortScan: null, - workspacePortScansByKey: {}, - workspacePortScanRefreshing: false, - setWorkspacePortScan: (scan) => - set((state) => { - if (!scan) { - if (!state.workspacePortScan && Object.keys(state.workspacePortScansByKey).length === 0) { - return state - } - return { workspacePortScan: null, workspacePortScansByKey: {} } - } - if ( - state.workspacePortScan?.key === scan.key && - state.workspacePortScan.result === scan.result && - state.workspacePortScansByKey[scan.key] === scan.result - ) { - return state - } - return { - workspacePortScan: scan, - workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } - } - }), - // Why: target changes rebuild the aggregate without republishing or clearing per-host scans. - setWorkspacePortScanProjection: (scan) => - set((state) => { - if ( - state.workspacePortScan?.key === scan?.key && - state.workspacePortScan?.result === scan?.result - ) { - return state - } - return { workspacePortScan: scan } - }), - // Why: drop stale per-host scans in one store update so a large host set can't fan out notifications to every subscriber. - replaceWorkspacePortScans: (scansByKey, projection) => - set((state) => { - if ( - state.workspacePortScansByKey === scansByKey && - state.workspacePortScan?.key === projection?.key && - state.workspacePortScan?.result === projection?.result - ) { - return state - } - return { workspacePortScansByKey: scansByKey, workspacePortScan: projection } - }), - setWorkspacePortScanForKey: (key, result) => - set((state) => { - const currentResult = state.workspacePortScansByKey[key] - if (currentResult === result || (!result && !currentResult)) { - return state - } - const nextScansByKey = { ...state.workspacePortScansByKey } - if (result) { - nextScansByKey[key] = result - } else { - delete nextScansByKey[key] - } - return { - workspacePortScansByKey: nextScansByKey, - workspacePortScan: - state.workspacePortScan?.key === key - ? result - ? { key, result } - : null - : state.workspacePortScan - } - }), - setWorkspacePortScanRefreshing: (refreshing) => set({ workspacePortScanRefreshing: refreshing }), - - // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). - petVisible: true, - setPetVisible: (v) => { - window.api.ui.set({ petVisible: v }).catch(console.error) - set({ petVisible: v }) - }, - - petId: DEFAULT_PET_ID, - setPetId: (id) => { - window.api.ui.set({ petId: id }).catch(console.error) - set({ petId: id }) - }, - - petSize: PET_SIZE_DEFAULT, - setPetSize: (size) => { - const clamped = clampPetSize(size) - window.api.ui.set({ petSize: clamped }).catch(console.error) - set({ petSize: clamped }) - }, - - customPets: [], - addCustomPet: (model) => - set((s) => { - const next = [...s.customPets.filter((m) => m.id !== model.id), model] - window.api.ui.set({ customPets: next }).catch(console.error) - return { customPets: next } - }), - removeCustomPet: (id) => - set((s) => { - const target = s.customPets.find((m) => m.id === id) - if (!target) { - return s - } - const next = s.customPets.filter((m) => m.id !== id) - // Why: removing the active custom pet falls back to bundled default so the overlay isn't empty. - const fallback = s.petId === id ? DEFAULT_PET_ID : s.petId - // Why: single combined IPC update so customPets and petId persist atomically. - const ipcPayload: { customPets: CustomPet[]; petId?: string } = { - customPets: next - } - if (fallback !== s.petId) { - ipcPayload.petId = fallback - } - window.api.ui.set(ipcPayload).catch(console.error) - // Why: revoke the cached blob: URL so the Blob is released, not leaked for the session. - revokeCustomPetBlobUrl(id) - // Why: best-effort delete — bytes owned by main; fresh-UUID imports mean an orphaned file is never re-referenced. - window.api.pet.delete(id, target.fileName, target.kind).catch(console.error) - const partial: Partial = { customPets: next } - if (fallback !== s.petId) { - partial.petId = fallback - } - return partial - }), - - pendingRevealWorktree: null, - pendingRevealSidebarRow: null, - revealWorktreeInSidebar: (worktreeId, options) => - set({ - pendingRevealWorktree: { - worktreeId, - ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight ? { highlight: true } : {}), - ...(options?.beginRename ? { beginRename: true } : {}) - } - }), - revealSidebarRow: (rowKey, options) => - set({ - pendingRevealSidebarRow: { - rowKey, - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight === false ? {} : { highlight: true }) - } - }), - clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), - clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), - scrollToDiffCommentId: null, - setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), - persistedUIReady: false, - persistedUIWriteBaseline: null, - persistedUIWriteInFlightCounts: {}, - notePersistedUIWriteStarted: (fields) => - set((s) => { - const counts = { ...s.persistedUIWriteInFlightCounts } - for (const field of fields) { - counts[field] = (counts[field] ?? 0) + 1 - } - return { persistedUIWriteInFlightCounts: counts } - }), - persistedUIWriteBaselineGeneration: 0, - notePersistedUIWriteSettled: (fields, flushed, options) => - set((s) => { - const counts = { ...s.persistedUIWriteInFlightCounts } - for (const field of fields) { - const next = (counts[field] ?? 0) - 1 - if (next > 0) { - counts[field] = next - } else { - delete counts[field] - } - } - // Why the generation guard: a hydration during the round trip made the - // baseline authoritative for state NEWER than this write; folding the - // sent values over it would blank the mirror-vs-baseline diff and leave - // mirror and authority divergent with nothing left to reconcile them. - // Skipping the fold keeps the diff alive so the trailing flush re-sends. - // Why options is required for folding: an unguarded fold from a future - // caller could silently erase a remote write that landed mid-round-trip. - const foldable = - flushed && - s.persistedUIWriteBaseline && - options !== undefined && - options.sentAtGeneration === s.persistedUIWriteBaselineGeneration - return { - persistedUIWriteInFlightCounts: counts, - ...(foldable - ? { persistedUIWriteBaseline: { ...s.persistedUIWriteBaseline!, ...flushed } } - : {}) - } - }), - uiZoomLevel: 0, - setUIZoomLevel: (level) => set({ uiZoomLevel: level }), - editorFontZoomLevel: 0, - setEditorFontZoomLevel: (level) => set({ editorFontZoomLevel: level }), - - hydratePersistedUI: (ui, source = 'sync') => - set((s) => { - const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) - const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) - const validRepoIds = new Set(s.repos.map((repo) => repo.id)) - const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) - const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) - // Why: pre-rename builds used sidekick* keys; read as fallback only so new pet* writes win after upgrade. - const customPets = Array.isArray(ui.customPets) - ? ui.customPets - : Array.isArray(ui.customSidekicks) - ? ui.customSidekicks - : [] - const petId = ui.petId ?? ui.sidekickId - // Migration: one-shot old-'recent'→'smart' runs in main (_sortBySmartMigrated), not here, so a deliberate 'recent' choice survives restart. - const sortBy = ui.sortBy - const migratedStatusBarItems = migrateStatusBarItems(ui.statusBarItems) - const statusBarItemsWithPorts = - ui._portsStatusBarDefaultAdded || migratedStatusBarItems.includes('ports') - ? migratedStatusBarItems - : [...migratedStatusBarItems, DEFAULT_ON_PORTS_STATUS_BAR_ITEM] - const statusBarItems = - ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') - ? statusBarItemsWithPorts - : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] - const statusBarItemsWithMiniMax = - ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') - ? statusBarItems - : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] - const statusBarItemsWithAntigravity = - ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity') - ? statusBarItemsWithMiniMax - : [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM] - const statusBarItemsWithGrok = - ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') - ? statusBarItemsWithAntigravity - : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] - if ( - (!ui._portsStatusBarDefaultAdded || - !ui._kimiStatusBarDefaultAdded || - !ui._minimaxStatusBarDefaultAdded || - !ui._antigravityStatusBarDefaultAdded || - !ui._grokStatusBarDefaultAdded) && - typeof window !== 'undefined' - ) { - window.api.ui - .set({ - statusBarItems: statusBarItemsWithGrok, - _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true, - _antigravityStatusBarDefaultAdded: true, - _grokStatusBarDefaultAdded: true - }) - .catch(console.error) - } - const rightSidebarRoute = normalizeRightSidebarRoute( - ui.rightSidebarTab, - ui.rightSidebarExplorerView - ) - const hydrated = { - // Why: persisted widths may be stale/corrupt/hand-edited; clamp during hydration so invalid values can't break layout. - sidebarWidth: sanitizePersistedSidebarWidth( - ui.sidebarWidth, - s.sidebarWidth, - MAX_LEFT_SIDEBAR_WIDTH - ), - rightSidebarWidth: sanitizePersistedSidebarWidth( - ui.rightSidebarWidth, - s.rightSidebarWidth, - MAX_RIGHT_SIDEBAR_WIDTH - ), - markdownTocPanelWidth: clampMarkdownTocPanelWidth( - ui.markdownTocPanelWidth, - undefined, - s.markdownTocPanelWidth - ), - combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( - ui.combinedDiffFileTreeWidth, - undefined, - s.combinedDiffFileTreeWidth - ), - rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, - rightSidebarTab: rightSidebarRoute.rightSidebarTab, - rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, - groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, - sortBy, - // Why: main-process getUI() already normalized this (defaulting to 'manual'); read it through without migrating. - projectOrderBy: ui.projectOrderBy, - // Why: Active-only was retired; force the old flag off so an old profile can't invisibly narrow the workspace list. - showActiveOnly: false, - // Why: ignore older positive-form keys so old profiles start from the new default (sleeping workspaces visible). - showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), - workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), - visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), - workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), - // Why: a malformed or legacy filter value must degrade to All hosts, never throw during hydration. - automationHostFilter: parsePersistedAutomationHostFilter(ui.automationHostFilter), - manualRepoOrder, - // Why: apply the desktop-owned overlay immediately since UI state can arrive after a catalog or from another client. - repos: orderedRepos, - hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, - hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, - hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, - hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, - hideWorkspacesFromOtherDevices: ui.hideWorkspacesFromOtherDevices === true, - // Why !== false: profiles written before #8873 have no key, and they are - // precisely the ones showing the bug, so absence must mean "exempt". - alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, - showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), - // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. - filterRepoIds: - validRepoIds.size === 0 - ? persistedFilterRepoIds - : persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)), - collapsedGroups: new Set(ui.collapsedGroups ?? []), - uiZoomLevel: ui.uiZoomLevel ?? 0, - editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, - worktreeCardProperties: normalizeWorktreeCardProperties(ui.worktreeCardProperties), - _worktreeCardModeDefaulted: ui._worktreeCardModeDefaulted === true, - agentActivityDisplayMode: normalizeAgentActivityDisplayMode(ui.agentActivityDisplayMode), - workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), - workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), - workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), - syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems: statusBarItemsWithGrok, - statusBarVisible: ui.statusBarVisible ?? true, - usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), - statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode), - // Why: default true so existing users see the pet on first enabling the flag; only an explicit Hide persists false. - petVisible: ui.petVisible ?? ui.sidekickVisible ?? true, - petSize: clampPetSize(ui.petSize ?? ui.sidekickSize ?? PET_SIZE_DEFAULT), - customPets, - // Why: fall back to default when the persisted id is unknown (e.g. custom pet removed elsewhere) so the overlay renders. - petId: ((): string => { - const id = petId - if (typeof id !== 'string') { - return DEFAULT_PET_ID - } - if (isBundledPetId(id)) { - return id - } - if (customPets.some((m) => m.id === id)) { - return id - } - return DEFAULT_PET_ID - })(), - dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, - // Why: a persisted value from a build that knew a different channel set - // would otherwise survive as-is; activeChannel only falls back on null, - // so an unknown string reaches listBuilds and the segmented control. - releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride) - ? ui.releaseChannelOverride - : null, - updateReassuranceSeen: ui.updateReassuranceSeen ?? false, - osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, - browserDefaultUrl: ui.browserDefaultUrl ?? null, - browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, - browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(ui.browserDefaultZoomLevel), - browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''), - taskResumeState: sanitizeTaskResumeState(ui.taskResumeState), - featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds), - featureInteractions: normalizeFeatureInteractions(ui.featureInteractions), - contextualToursSeenIds: normalizeContextualTourIds(ui.contextualToursSeenIds), - contextualToursAutoEligible: - typeof ui.contextualToursAutoEligible === 'boolean' - ? ui.contextualToursAutoEligible - : null, - trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds), - setupScriptPromptDismissedRepoIds: - validRepoHostIdentities.size === 0 - ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) - : filterSetupScriptPromptDismissalsToValidRepos( - ui.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities - ), - setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, - setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true, - setupGuideBrowserMilestoneLegacyComplete: - ui.setupGuideBrowserMilestoneLegacyComplete === true, - browserImportHintHidden: ui.browserImportHintHidden === true, - mobileEmulatorTabIntroDismissed: ui.mobileEmulatorTabIntroDismissed === true, - mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true, - projectOrderManualDefaultNoticeDismissed: - ui.projectOrderManualDefaultNoticeDismissed === true, - // Why: treat only explicit true as dismissed so a false from migration still surfaces. - usagePercentageDisplayChangeNoticeDismissed: - ui.usagePercentageDisplayChangeNoticeDismissed === true, - // Why: default false so existing users still see the CTA; only explicit dismissal persists true. - usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, - // Why: stale acks are inert (paneKey reuse beats them via stateStartedAt); sanitizer bounds growth past HYDRATE_MAX_AGE_MS. - acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey( - ui.acknowledgedAgentsByPaneKey - ), - workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( - ui.workspaceCleanup?.dismissals - ), - // Why the normalizer rather than a cast: this blob is hand-editable and - // may come from an older or newer build; it degrades field by field - // instead of bricking the cleanup dialog. - // Why: a sync broadcast can carry stale browse state while its writer is debounced. - workspaceCleanupBrowse: - source === 'startup' - ? normalizeWorkspaceCleanupBrowseState(ui.workspaceCleanup?.browse) - : s.workspaceCleanupBrowse, - // Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view. - activeView: - source === 'startup' - ? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true) - : s.activeView, - persistedUIReady: true - } - // The incoming payload is authoritative for the writer-owned fields, so it becomes the - // writer's new diff baseline — but fields with an unflushed local edit (mirror diverged - // from the previous baseline) keep the local value so a broadcast arriving inside the - // writer's debounce window can't silently revert what the user just toggled (STA-5781). - // Order matters: capture the baseline BEFORE overlaying pending edits, or the baseline - // would equal the pending value, the diff would go empty, and the toggle would be dropped. - // Note the width sanitizers above fall back to the CURRENT store value only for - // non-numeric input (numbers are clamped in place), so a captured width can differ - // from what main holds only for garbage payloads; at worst main keeps an - // out-of-range width until the next drag re-writes it. - const nextWriteBaseline = capturePersistedUIWriteBaseline(hydrated) - const previousBaseline = s.persistedUIWriteBaseline - if (previousBaseline) { - const pendingLocalEdits = diffPersistedUIWriteFields( - capturePersistedUIWriteBaseline(s), - previousBaseline - ) - Object.assign(hydrated, pendingLocalEdits) - // In-flight fields too: a flip-back to the baseline value diffs empty, - // yet the in-flight write's echo must not revert it (PR#17057 review). - for (const field of Object.keys( - s.persistedUIWriteInFlightCounts - ) as (keyof PersistedUIWriteBaseline)[]) { - ;(hydrated as Record)[field] = s[field] - } - } - // Why: return the same ref on identical hydration so App's debounced writer doesn't echo it back to main. - // The baseline must still advance when it moved (a remote same-field write during an in-flight - // ack pins the only visibly differing field, and our own echo precedes every ack) — but only - // the two baseline keys, or every ordinary write's echo would churn the store's collection - // identities and re-render identity-compared selectors once per write. - // Why the generation bumps only on baseline movement: an unrelated-field - // broadcast during an in-flight write would otherwise void that write's - // fold and cost a redundant trailing re-send of identical values. - const writeBaselineMoved = - !previousBaseline || - Object.keys(diffPersistedUIWriteFields(nextWriteBaseline, previousBaseline)).length > 0 - const nextWriteBaselineGeneration = writeBaselineMoved - ? s.persistedUIWriteBaselineGeneration + 1 - : s.persistedUIWriteBaselineGeneration - if (hydratedUIPartialMatchesState(s, hydrated)) { - if (!writeBaselineMoved) { - return s - } - return { - persistedUIWriteBaseline: nextWriteBaseline, - persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration - } - } - return { - ...hydrated, - persistedUIWriteBaseline: nextWriteBaseline, - persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration - } - }), - - updateStatus: { state: 'idle' }, - setUpdateStatus: (status) => { - const prevState = get().updateStatus.state - const update: Partial< - Pick< - UISlice, - 'updateStatus' | 'updateChangelog' | 'updateCardCollapsed' | 'updateUserInitiatedCycle' - > - > = { - updateStatus: status - } - if (status.state === 'checking') { - update.updateUserInitiatedCycle = status.userInitiated === true - } else if (status.state === 'idle') { - update.updateUserInitiatedCycle = false - } - if (status.state === 'available') { - // Why: always overwrite (even with null) so a prior version's changelog can't leak into a later simple-mode update. - update.updateChangelog = status.changelog ?? null - } else if ( - status.state === 'idle' || - status.state === 'checking' || - status.state === 'not-available' - ) { - // Why: reset on cycle-boundary states so stale rich content from a previous cycle can't resurface. - update.updateChangelog = null - } - // 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content. - if (status.state !== prevState) { - // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`. - update.updateCardCollapsed = false - } - set(update) - }, - updateChangelog: null, - updateUserInitiatedCycle: false, - dismissedUpdateVersion: null, - clearDismissedUpdateVersion: () => { - set({ dismissedUpdateVersion: null }) - }, - releaseChannelOverride: null, - setReleaseChannelOverride: (channel) => { - void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error) - set({ releaseChannelOverride: channel }) - }, - dismissUpdate: (versionOverride?: string) => - set((s) => { - // Why: the 'error' variant has no version field, so the card passes it via versionOverride. - const dismissedUpdateVersion = - versionOverride ?? ('version' in s.updateStatus ? (s.updateStatus.version ?? null) : null) - const activeNudgeId = - 'activeNudgeId' in s.updateStatus ? (s.updateStatus.activeNudgeId ?? null) : null - // Why: persist dismissal so relaunch doesn't immediately re-show the same card until a newer release. - void window.api.ui.set({ dismissedUpdateVersion }).catch(console.error) - // Why: main can't otherwise tell an offered update was abandoned, which keeps a local-build session pinned and stalls background checks. - void window.api.updater.dismissAvailableUpdate().catch(console.error) - // Why: only consume the nudge campaign for cards from a nudge cycle, not ordinary dismissals. - if (activeNudgeId) { - void window.api.updater.dismissNudge().catch(console.error) - } - return { dismissedUpdateVersion, updateUserInitiatedCycle: false } - }), - updateCardCollapsed: false, - setUpdateCardCollapsed: (collapsed) => set({ updateCardCollapsed: collapsed }), - updateReassuranceSeen: false, - markUpdateReassuranceSeen: () => { - void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) - set({ updateReassuranceSeen: true }) - }, - osc52ClipboardDefaultOnNoticePending: false, - clearOsc52ClipboardDefaultOnNotice: () => { - // Why clear locally first: a failed persist must not re-toast this session. It will - // re-arm on the next launch, which is the safe direction for a one-shot notice. - set({ osc52ClipboardDefaultOnNoticePending: false }) - void window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }).catch(console.error) - }, - isFullScreen: false, - setIsFullScreen: (v) => set({ isFullScreen: v }), - browserDefaultUrl: null, - setBrowserDefaultUrl: (url) => { - void window.api.ui.set({ browserDefaultUrl: url }).catch(console.error) - set({ browserDefaultUrl: url }) - }, - browserDefaultSearchEngine: null, - setBrowserDefaultSearchEngine: (engine) => { - void window.api.ui.set({ browserDefaultSearchEngine: engine }).catch(console.error) - set({ browserDefaultSearchEngine: engine }) - }, - browserDefaultZoomLevel: DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - setBrowserDefaultZoomLevel: (level) => { - const normalized = normalizeBrowserPageZoomLevel(level) - void window.api.ui.set({ browserDefaultZoomLevel: normalized }).catch(console.error) - set({ browserDefaultZoomLevel: normalized }) - }, - browserKagiSessionLink: null, - setBrowserKagiSessionLink: (link) => { - const normalized = link ? normalizeKagiSessionLink(link) : null - void window.api.ui.set({ browserKagiSessionLink: normalized }).catch(console.error) - set({ browserKagiSessionLink: normalized }) - } -}) +import type { UISlice } from './ui/ui-slice-contract' +import { createUiAgentActions } from './ui/ui-slice-agent-actions' +import { createUiTaskActions } from './ui/ui-slice-task-actions' +import { createUiViewActions } from './ui/ui-slice-view-actions' +import { createUiSettingsActions } from './ui/ui-slice-settings-actions' +import { createUiModalActions } from './ui/ui-slice-modal-actions' +import { createUiFeatureActions } from './ui/ui-slice-feature-actions' +import { createUiTourActions } from './ui/ui-slice-tour-actions' +import { createUiTrustActions } from './ui/ui-slice-trust-actions' +import { createUiPreferenceActions } from './ui/ui-slice-preference-actions' +import { createUiSurfaceActions } from './ui/ui-slice-surface-actions' +import { createUiPersistenceActions } from './ui/ui-slice-persistence-actions' +import { createUiHydrationActions } from './ui/ui-slice-hydration-actions' +import { createUiUpdateActions } from './ui/ui-slice-update-actions' + +export type { + AgentSendPopoverTargetMode, + NewWorkspaceDraft, + OpenAgentSendPopoverTargetModeArgs, + PendingSidebarRowReveal, + PendingSidebarWorktreeReveal, + TaskPageData, + UISlice +} from './ui/ui-slice-contract' + +export const createUISlice: StateCreator = (set, get) => + ({ + ...createUiAgentActions(set, get), + ...createUiTaskActions(set, get), + ...createUiViewActions(set, get), + ...createUiSettingsActions(set, get), + ...createUiModalActions(set, get), + ...createUiFeatureActions(set, get), + ...createUiTourActions(set, get), + ...createUiTrustActions(set, get), + ...createUiPreferenceActions(set, get), + ...createUiSurfaceActions(set, get), + ...createUiPersistenceActions(set, get), + ...createUiHydrationActions(set, get), + ...createUiUpdateActions(set, get) + }) as UISlice diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts new file mode 100644 index 00000000000..fba107882a5 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -0,0 +1,279 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { formatAgentTypeLabel, agentKindForAgentType } from '../../../lib/agent-status' +import { + deriveRunningAgentSendTargets, + resolveRunningAgentSendTarget +} from '../../../lib/running-agent-targets' +import { translate } from '@/i18n/i18n' +import { + collectAcknowledgedAgentNotificationId, + createAgentSendTargetModeInstanceId, + latestAgentTurnTimestamp, + resolvePaneKeyWorktreeIdFromTabs, + usableTimestamp +} from './ui-slice-agent-helpers' + +export function createUiAgentActions( + set: UISliceSet, + get: UISliceGet +): Pick< + UISlice, + | 'sidebarOpen' + | 'sidebarWidth' + | 'toggleSidebar' + | 'setSidebarOpen' + | 'setSidebarWidth' + | 'agentSendPopoverTargetMode' + | 'openAgentSendPopoverTargetMode' + | 'closeAgentSendPopoverTargetMode' + | 'sendPromptToSidebarAgentTarget' + | 'diffNotesSendMenuOpenRequest' + | 'openDiffNotesSendMenuForActiveWorktree' + | 'consumeDiffNotesSendMenuOpenRequest' + | 'acknowledgedAgentsByPaneKey' + | 'acknowledgeAgents' + | 'unacknowledgeAgents' +> { + return { + sidebarOpen: true, + sidebarWidth: 280, + toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), + setSidebarOpen: (open) => set({ sidebarOpen: open }), + setSidebarWidth: (width) => set({ sidebarWidth: width }), + agentSendPopoverTargetMode: null, + openAgentSendPopoverTargetMode: (args) => { + const targets = deriveRunningAgentSendTargets(get(), args.worktreeId) + const previousMode = get().agentSendPopoverTargetMode + if (previousMode?.id === args.id && previousMode.status === 'sending') { + return + } + const disabledPaneKeys: Record = {} + for (const target of targets) { + if (target.status === 'disabled' && target.disabledReason) { + disabledPaneKeys[target.paneKey] = target.disabledReason + } + } + set({ + agentSendPopoverTargetMode: { + ...args, + instanceId: createAgentSendTargetModeInstanceId(), + eligiblePaneKeys: targets + .filter((target) => target.status === 'eligible') + .map((target) => target.paneKey), + disabledPaneKeys, + status: 'open' + } + }) + if ( + targets.some((target) => target.status === 'eligible') && + (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) + ) { + get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) + } + }, + diffNotesSendMenuOpenRequest: null, + openDiffNotesSendMenuForActiveWorktree: () => { + const worktreeId = get().activeWorktreeId + if (!worktreeId) { + return false + } + // Why: no unsent notes means nothing to send, so don't hijack focus or reveal the panel. + if ( + !get() + .getDiffComments(worktreeId) + .some((comment) => !comment.sentAt) + ) { + return false + } + get().setRightSidebarTab('source-control') + get().setRightSidebarOpen(true) + const nonce = (get().diffNotesSendMenuOpenRequest?.nonce ?? 0) + 1 + set({ diffNotesSendMenuOpenRequest: { worktreeId, nonce, issuedAt: Date.now() } }) + return true + }, + consumeDiffNotesSendMenuOpenRequest: (worktreeId) => + set((s) => + s.diffNotesSendMenuOpenRequest?.worktreeId === worktreeId + ? { diffNotesSendMenuOpenRequest: null } + : s + ), + closeAgentSendPopoverTargetMode: (id, instanceId) => + set((s) => { + if (!s.agentSendPopoverTargetMode) { + return s + } + if (id && s.agentSendPopoverTargetMode.id !== id) { + return s + } + if (instanceId && s.agentSendPopoverTargetMode.instanceId !== instanceId) { + return s + } + return { agentSendPopoverTargetMode: null } + }), + sendPromptToSidebarAgentTarget: async (paneKey) => { + const mode = get().agentSendPopoverTargetMode + if (!mode || mode.status === 'sending') { + return false + } + + const target = resolveRunningAgentSendTarget(get(), mode.worktreeId, paneKey) + if (!target || target.status !== 'eligible' || !target.ptyId) { + // Why: eligibility can drop after the menu opened; keep the picker open (row title explains) rather than adding toast noise. + return false + } + + set((s) => + s.agentSendPopoverTargetMode?.id === mode.id && + s.agentSendPopoverTargetMode.instanceId === mode.instanceId + ? { + agentSendPopoverTargetMode: { + ...s.agentSendPopoverTargetMode, + status: 'sending', + sendingPaneKey: paneKey, + error: undefined + } + } + : s + ) + + const label = formatAgentTypeLabel(target.entry.agentType) + const { activeAgentNotesSendFailureMessage, sendNotesToActiveAgentSession } = + await import('@/lib/active-agent-note-send') + const result = await sendNotesToActiveAgentSession({ + worktreeId: mode.worktreeId, + prompt: mode.prompt, + noteTarget: { tabId: target.tabId, leafId: target.leafId } + }).catch((error) => { + console.error('Failed to send notes to sidebar agent target:', error) + return { status: 'no-active-terminal' as const } + }) + + const stillCurrent = (): boolean => { + const current = get().agentSendPopoverTargetMode + return current?.id === mode.id && current.instanceId === mode.instanceId + } + + if (result.status !== 'sent') { + const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) + set((s) => + s.agentSendPopoverTargetMode?.id === mode.id && + s.agentSendPopoverTargetMode.instanceId === mode.instanceId + ? { + agentSendPopoverTargetMode: { + ...s.agentSendPopoverTargetMode, + status: 'error', + sendingPaneKey: undefined, + error: message + } + } + : s + ) + const { toast } = await import('sonner') + if (!stillCurrent()) { + return false + } + toast.error( + translate('auto.store.slices.ui.53883b7bc3', "Couldn't send to {{value0}}", { + value0: label + }), + { description: message } + ) + return false + } + + // Delivery ack, telemetry, and toast belong to the completed send, not to the + // picker that launched it; only the close below is scoped to this instance. + mode.onPromptDelivered?.() + const [{ toast }, { track }] = await Promise.all([ + import('sonner'), + import('@/lib/telemetry') + ]) + track('agent_prompt_sent', { + agent_kind: agentKindForAgentType(target.entry.agentType), + launch_source: mode.launchSource, + request_kind: 'followup' + }) + toast.success( + translate('auto.store.slices.ui.66e3bd7ce6', 'Sent to {{value0}}', { value0: label }) + ) + get().closeAgentSendPopoverTargetMode(mode.id, mode.instanceId) + return true + }, + + acknowledgedAgentsByPaneKey: {}, + acknowledgeAgents: (paneKeys) => { + const notificationIdsToDismiss = new Set() + set((s) => { + if (paneKeys.length === 0) { + return s + } + const now = Date.now() + const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {}) + // Why: only reallocate if an ack advances; compare prev | null = null + for (const key of paneKeys) { + const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 + // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, + // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the + // row, so its auto-ack effect re-fires on each new millisecond forever (React #185). + let stamp = now + const liveEntry = s.agentStatusByPaneKey?.[key] + if (liveEntry) { + collectAcknowledgedAgentNotificationId({ + ids: notificationIdsToDismiss, + worktreeId: resolvePaneKeyWorktreeIdFromTabs(s, key) ?? liveEntry.worktreeId, + paneKey: key, + stateStartedAt: liveEntry.stateStartedAt, + previousAckAt: prev + }) + stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry)) + } + const retained = s.retainedAgentsByPaneKey?.[key] + if (retained) { + collectAcknowledgedAgentNotificationId({ + ids: notificationIdsToDismiss, + worktreeId: retained.worktreeId, + paneKey: key, + stateStartedAt: retained.entry.stateStartedAt, + previousAckAt: prev + }) + stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry)) + } + for (const unsupported of migrationUnsupported) { + // Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn. + if (unsupported.paneKey === key) { + stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt)) + } + } + if (prev < stamp) { + if (next === null) { + next = { ...s.acknowledgedAgentsByPaneKey } + } + next[key] = stamp + } + } + return next ? { acknowledgedAgentsByPaneKey: next } : s + }) + const notificationIds = [...notificationIdsToDismiss] + if (notificationIds.length > 0 && typeof window !== 'undefined') { + void window.api?.notifications?.dismiss?.(notificationIds) + } + }, + unacknowledgeAgents: (paneKeys) => + set((s) => { + if (paneKeys.length === 0) { + return s + } + let next: Record | null = null + for (const key of paneKeys) { + if (s.acknowledgedAgentsByPaneKey[key] !== undefined) { + if (next === null) { + next = { ...s.acknowledgedAgentsByPaneKey } + } + delete next[key] + } + } + return next ? { acknowledgedAgentsByPaneKey: next } : s + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts new file mode 100644 index 00000000000..b9b578d5476 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts @@ -0,0 +1,62 @@ +import type { AppState } from '../../types' +import { buildAgentNotificationId } from '../../../../../shared/agent-notification-id' +import { parsePaneKey } from '../../../../../shared/stable-pane-id' + +export function resolvePaneKeyWorktreeIdFromTabs(state: AppState, paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { + if (tabs.some((tab) => tab.id === parsed.tabId)) { + return worktreeId + } + } + return null +} + +export function collectAcknowledgedAgentNotificationId({ + ids, + worktreeId, + paneKey, + stateStartedAt, + previousAckAt +}: { + ids: Set + worktreeId: string | null | undefined + paneKey: string + stateStartedAt: number | null | undefined + previousAckAt: number +}): void { + if (typeof stateStartedAt !== 'number' || previousAckAt >= stateStartedAt) { + return + } + const id = buildAgentNotificationId({ worktreeId, paneKey, stateStartedAt }) + if (id) { + ids.add(id) + } +} + +export function usableTimestamp(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +/** Newest turn timestamp an unread check can compare against for one agent row. */ +export function latestAgentTurnTimestamp(entry: { + stateStartedAt?: number + stateHistory?: { startedAt?: number }[] +}): number { + let latest = usableTimestamp(entry.stateStartedAt) + // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. + for (const history of entry.stateHistory ?? []) { + latest = Math.max(latest, usableTimestamp(history.startedAt)) + } + return latest +} + +let agentSendTargetModeInstanceCounter = 0 + +export function createAgentSendTargetModeInstanceId(): string { + agentSendTargetModeInstanceCounter += 1 + return `${Date.now()}:${agentSendTargetModeInstanceCounter}` +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts new file mode 100644 index 00000000000..0f997f5aeea --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -0,0 +1,117 @@ +import type { PersistedTrustedOrcaHooks } from '../../../../../shared/orca-yaml-hook-types' +import type { + FeatureInteractionId, + FeatureInteractionState +} from '../../../../../shared/feature-interactions' +import type { FeatureTipId } from '../../../../../shared/feature-tips' +import type { ContextualTourId } from '../../../../../shared/contextual-tours' +import type { OrcaHookScriptKind } from '../../../lib/orca-hook-trust' +import type { SettingsNavigationTarget } from '../../../lib/settings-navigation-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' + +export type UISliceContextual = { + openSettingsPage: () => void + closeSettingsPage: () => void + settingsNavigationTarget: SettingsNavigationTarget | null + openSettingsTarget: (target: NonNullable) => void + clearSettingsTarget: () => void + /** Which host the Projects Settings pane shows per project (keyed by projectId). Ephemeral on purpose — never persisted, so reload reopens on the effective host. */ + settingsProjectHostSelection: Record + settingsProjectSetupSelection: Record + setSettingsProjectHostSelection: ( + projectId: string, + hostId: ExecutionHostId, + setupId?: string + ) => void + /** One-shot Appearance accordion to expand for nested Settings deep links (e.g. Usage percentages under Window & Sidebar). Cleared when Appearance consumes it. */ + appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null + setAppearanceAccordionDeepLink: ( + section: NonNullable + ) => void + clearAppearanceAccordionDeepLink: () => void + activeModal: + | 'none' + | 'create-worktree' + | 'edit-meta' + | 'delete-worktree' + | 'preserved-branch-review' + | 'forget-ssh-workspace' + | 'confirm-add-project-from-folder' + | 'confirm-non-git-folder' + | 'confirm-remove-folder' + | 'add-repo' + | 'quick-open' + | 'worktree-palette' + | 'workspace-cleanup' + | 'project-added' + | 'worktree-visibility' + | 'setup-guide' + | 'feature-wall' + | 'feature-tips' + | 'new-workspace-composer' + | 'confirm-orca-yaml-hooks' + modalData: Record + openModal: (modal: UISliceContextual['activeModal'], data?: Record) => void + closeModal: () => void + featureTipsSeenIds: FeatureTipId[] + markFeatureTipsSeen: (ids: FeatureTipId[]) => void + featureInteractions: FeatureInteractionState + recordFeatureInteraction: (id: FeatureInteractionId) => Promise + contextualToursSeenIds: ContextualTourId[] + contextualToursAutoEligible: boolean | null + activeContextualTourId: ContextualTourId | null + activeContextualTourStepIndex: number + activeContextualTourSource: string | null + activeContextualTourSourceDetached: boolean + activeContextualTourWasFeaturePreviouslyInteracted: boolean + contextualTourNavigationInteractionSnapshot: Partial> + activeContextualTourSuppressed: boolean + contextualTourShownThisSession: boolean + contextualToursOnboardingVisible: boolean + contextualToursBlockingSurfaceVisible: boolean + lastCompletedContextualTourId: ContextualTourId | null + setContextualToursAutoEligible: (eligible: boolean) => void + setContextualToursOnboardingVisible: (visible: boolean) => void + setContextualToursBlockingSurfaceVisible: (visible: boolean) => void + requestContextualTour: ( + id: ContextualTourId, + source: string, + wasFeaturePreviouslyInteracted?: boolean, + options?: { force?: boolean } + ) => void + suppressContextualTour: (id: ContextualTourId, source: string) => void + detachContextualTourSource: (id: ContextualTourId, source: string) => void + advanceContextualTour: () => void + regressContextualTour: () => void + dismissContextualTour: (id?: ContextualTourId) => void + completeContextualTour: (id?: ContextualTourId) => void + cancelContextualTour: (id?: ContextualTourId) => void + markContextualToursSeen: (ids: ContextualTourId[]) => void + trustedOrcaHooks: PersistedTrustedOrcaHooks + markOrcaHookScriptConfirmed: ( + repoId: string, + kind: OrcaHookScriptKind, + contentHash: string + ) => void + markOrcaHookRepoAlwaysTrusted: (repoId: string) => void + clearOrcaHookTrustForRepo: (repoId: string) => void + setupScriptPromptDismissedRepoIds: readonly string[] + dismissSetupScriptPrompt: (repoHostIdentity: string) => void + setupGuideSidebarDismissed: boolean + setSetupGuideSidebarDismissed: (dismissed: boolean) => void + setupGuideBrowserMilestoneMigrated: boolean + setupGuideBrowserMilestoneLegacyComplete: boolean + markSetupGuideBrowserMilestoneMigrated: (legacyComplete: boolean) => void + browserImportHintHidden: boolean + setBrowserImportHintHidden: (hidden: boolean) => void + mobileEmulatorTabIntroDismissed: boolean + dismissMobileEmulatorTabIntro: () => void + mobileEmulatorAgentSetupDismissed: boolean + dismissMobileEmulatorAgentSetup: () => void + projectOrderManualDefaultNoticeDismissed: boolean + dismissProjectOrderManualDefaultNotice: () => void + usagePercentageDisplayChangeNoticeDismissed: boolean + dismissUsagePercentageDisplayChangeNotice: () => void + usageEmptyStateDismissed: boolean + dismissUsageEmptyState: () => void +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts new file mode 100644 index 00000000000..cd2af73b123 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts @@ -0,0 +1,211 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../../shared/gitlab-types' +import type { JiraIssue } from '../../../../../shared/jira-types' +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { TaskProvider } from '../../../../../shared/task-providers' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import type { LaunchSource } from '../../../../../shared/telemetry-events' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { TaskResumeState, TopLevelView } from '../../../../../shared/ui-chrome-types' + +export type PendingSidebarWorktreeReveal = { + worktreeId: string + executionHostId?: ExecutionHostId + behavior: 'auto' | 'smooth' + highlight?: boolean + beginRename?: boolean +} + +export type PendingSidebarRowReveal = { + rowKey: string + behavior: 'auto' | 'smooth' + highlight?: boolean +} + +export type AgentSendPopoverTargetMode = { + id: string + instanceId: string + worktreeId: string + source: 'diff-notes' | 'browser-annotations' + prompt: string + label: string + launchSource: LaunchSource + eligiblePaneKeys: string[] + disabledPaneKeys: Record + status: 'open' | 'sending' | 'error' + sendingPaneKey?: string + error?: string + onPromptDelivered?: () => void +} + +export type OpenAgentSendPopoverTargetModeArgs = { + id: string + worktreeId: string + source: AgentSendPopoverTargetMode['source'] + prompt: string + label: string + launchSource: LaunchSource + onPromptDelivered?: () => void +} + +export type TaskPageData = { + preselectedRepoId?: string + prefilledName?: string + taskSource?: TaskProvider + openGitHubWorkItem?: GitHubWorkItem + openGitHubSourceContext?: TaskSourceContext | null + openGitHubInitialTab?: 'conversation' | 'checks' | 'files' + openGitLabWorkItem?: GitLabWorkItem + openGitLabSourceContext?: TaskSourceContext | null + openLinearIssue?: LinearIssue + openLinearSourceContext?: TaskSourceContext | null + openJiraIssue?: JiraIssue + openJiraSourceContext?: TaskSourceContext | null +} + +export type NewWorkspaceDraft = { + repoId: string | null + // Why: project-first creation uses these when present; old drafts keep using only repoId during the additive migration. + projectId?: string | null + projectGroupId?: string | null + hostId?: ExecutionHostId | null + projectHostSetupId?: string | null + name: string + prompt: string + note: string + attachments: string[] + linkedWorkItem: { + provider?: 'github' | 'gitlab' | 'linear' | 'jira' + type: 'issue' | 'pr' | 'mr' + number: number + title: string + url: string + linearIdentifier?: string + linearBranchName?: string + jiraIdentifier?: string + repoId?: string + } | null + /** Preserve where provider data came from, separately from the host chosen to run the workspace. */ + taskSourceContext?: TaskSourceContext | null + linkedTaskSourceContext?: TaskSourceContext | null + agent: TuiAgent + linkedIssue: string + linkedPR: number | null + /** GitLab parallels — number for an issue, iid for an MR. Optional so pre-GitLab drafts still load without migration. */ + linkedGitLabIssue?: number | null + linkedGitLabMR?: number | null + // Why: repo-scoped start ref from the "Start from" picker; absent means "use the repo's effective base ref". + baseBranch?: string + // Why: review worktrees start from a head ref/SHA while Source Control compares against the provider target branch. + compareBaseRef?: string +} + +export type UiViewHistory = + | 'terminal' + | 'settings' + | 'tasks' + | 'activity' + | 'automations' + | 'space' + | 'skills' + | 'artifacts' + | 'mobile' + +export type UISliceCore = { + sidebarOpen: boolean + sidebarWidth: number + toggleSidebar: () => void + setSidebarOpen: (open: boolean) => void + setSidebarWidth: (width: number) => void + agentSendPopoverTargetMode: AgentSendPopoverTargetMode | null + openAgentSendPopoverTargetMode: (args: OpenAgentSendPopoverTargetModeArgs) => void + closeAgentSendPopoverTargetMode: (id?: string, instanceId?: string) => void + sendPromptToSidebarAgentTarget: (paneKey: string) => Promise + /** Bumped to ask the active worktree's Source Control notes send menu to open (keyboard shortcut). `issuedAt` bounds staleness so a request the menu never consumed can't reopen it much later. */ + diffNotesSendMenuOpenRequest: { worktreeId: string; nonce: number; issuedAt: number } | null + /** Reveal Source Control and request its notes send menu open; returns false (no-op) when the active worktree has no unsent notes. */ + openDiffNotesSendMenuForActiveWorktree: () => boolean + consumeDiffNotesSendMenuOpenRequest: (worktreeId: string) => void + /** Per-agent "I've looked at this" timestamps (paneKey → ts). A row is unvisited when no ack exists or stateStartedAt is newer than the last ack. Persisted so visited rows don't return bold on relaunch. */ + acknowledgedAgentsByPaneKey: Record + acknowledgeAgents: (paneKeys: string[]) => void + unacknowledgeAgents: (paneKeys: string[]) => void + activeView: TopLevelView + previousViewBeforeTasks: Exclude + previousViewBeforeSettings: Exclude + previousViewBeforeActivity: Exclude + previousViewBeforeAutomations: Exclude + previousViewBeforeSpace: Exclude + previousViewBeforeSkills: Exclude + previousViewBeforeMobile: Exclude + previousViewBeforeArtifacts: Exclude + setActiveView: (view: UISliceCore['activeView']) => void + taskPageData: TaskPageData + taskResumeState: TaskResumeState | undefined + setTaskResumeState: (updates: Partial) => void + taskListPosition: { contextKey: string; page: number; scrollTop: number } | null + setTaskListPosition: (position: UISliceCore['taskListPosition']) => void + githubTaskDrawerWorkItem: GitHubWorkItem | null + setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void + newWorkspaceDraft: NewWorkspaceDraft | null + openTaskPage: ( + data?: UISliceCore['taskPageData'], + options?: { recordTasksInteraction?: boolean } + ) => void + closeTaskPage: () => void + openActivityPage: () => void + closeActivityPage: () => void + selectedAutomationId: string | null + setSelectedAutomationId: (id: string | null) => void + pendingAutomationRunNavigation: { + automationId: string + runId: string | null + hostId?: ExecutionHostId + } | null + setPendingAutomationRunNavigation: ( + navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null + ) => void + openAutomationsPage: () => void + closeAutomationsPage: () => void + openSpacePage: () => void + closeSpacePage: () => void + openSkillsPage: () => void + closeSkillsPage: () => void + pendingSkillShareId: string | null + openSkillShare: (shareId: string) => void + clearPendingSkillShare: () => void + /** Set when another surface links straight to the page's shared-links view. */ + pendingSkillsSharedView: boolean + openSkillsSharedLinks: () => void + clearPendingSkillsSharedView: () => void + openArtifactsPage: () => void + closeArtifactsPage: () => void + openMobilePage: () => void + closeMobilePage: () => void + setNewWorkspaceDraft: (draft: NonNullable) => void + clearNewWorkspaceDraft: () => void + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + revealWorktreeInSidebar: ( + worktreeId: string, + options?: { + behavior?: PendingSidebarWorktreeReveal['behavior'] + highlight?: boolean + beginRename?: boolean + executionHostId?: ExecutionHostId + } + ) => void + revealSidebarRow: ( + rowKey: string, + options?: { + behavior?: PendingSidebarRowReveal['behavior'] + highlight?: boolean + } + ) => void +} + +export type UISliceSet = Parameters>[0] +export type UISliceGet = Parameters>[1] diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts new file mode 100644 index 00000000000..8d7561b7642 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -0,0 +1,182 @@ +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { + AgentActivityDisplayMode, + ManualRepoOrderEntry, + ProjectOrderBy, + StatusBarItem, + WorktreeCardMode, + WorktreeCardProperty, + WorkspaceHostOrder +} from '../../../../../shared/ui-chrome-types' +import type { UsagePercentageDisplay } from '../../../../../shared/usage-percentage-display' +import type { AutomationHostFilter } from '../../../../../shared/automation-host-filter' +import type { WorkspaceStatusDefinition } from '../../../../../shared/worktree/types' +import type { WorkspacePortScanResult } from '../../../../../shared/workspace-ports' +import type { CustomPet } from '../../../../../shared/pet-types' +import type { ReleaseChannel } from '../../../../../shared/release-channel' +import type { ChangelogData, UpdateStatus } from '../../../../../shared/update-status-types' +import type { StatusBarUsageMode } from '../../../../../shared/status-bar-usage-mode' +import type { PersistedUIWriteBaseline } from '../persisted-ui-write-baseline' +import type { + WorkspaceHostScope, + VisibleWorkspaceHostIds +} from '../../../../../shared/ui-chrome-types' +import type { UISliceCore } from './ui-slice-contract-core' + +export type UISlicePreferences = { + groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' + setGroupBy: (g: UISlicePreferences['groupBy']) => void + sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' + setSortBy: (s: UISlicePreferences['sortBy']) => void + projectOrderBy: ProjectOrderBy + setProjectOrderBy: (p: ProjectOrderBy) => void + showActiveOnly: boolean + setShowActiveOnly: (v: boolean) => void + showSleepingWorkspaces: boolean + setShowSleepingWorkspaces: (v: boolean) => void + workspaceHostScope: WorkspaceHostScope + setWorkspaceHostScope: (scope: WorkspaceHostScope) => void + visibleWorkspaceHostIds: VisibleWorkspaceHostIds + setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void + workspaceHostOrder: WorkspaceHostOrder + setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void + /** Automations page host filter, in stable form. Never written from an unhydrated catalog. */ + automationHostFilter: AutomationHostFilter + setAutomationHostFilter: (filter: AutomationHostFilter) => void + manualRepoOrder: ManualRepoOrderEntry[] + hideDefaultBranchWorkspace: boolean + setHideDefaultBranchWorkspace: (v: boolean) => void + hideAutomationGeneratedWorkspaces: boolean + setHideAutomationGeneratedWorkspaces: (v: boolean) => void + hideCliCreatedWorkspaces: boolean + setHideCliCreatedWorkspaces: (v: boolean) => void + hideDetachedHeadWorkspaces: boolean + setHideDetachedHeadWorkspaces: (v: boolean) => void + hideWorkspacesFromOtherDevices: boolean + setHideWorkspacesFromOtherDevices: (v: boolean) => void + alwaysShowDefaultBranchWorkspace: boolean + setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void + showDotfilesByWorktree: Record + setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void + toggleShowDotfilesForWorktree: (worktreeId: string) => void + filterRepoIds: readonly string[] + setFilterRepoIds: (ids: readonly string[]) => void + collapsedGroups: Set + toggleCollapsedGroup: (key: string) => void + worktreeCardProperties: WorktreeCardProperty[] + _worktreeCardModeDefaulted: boolean + setWorktreeCardMode: (mode: WorktreeCardMode) => void + setWorktreeCardProperties: (properties: readonly WorktreeCardProperty[]) => void + agentActivityDisplayMode: AgentActivityDisplayMode + setAgentActivityDisplayMode: (mode: AgentActivityDisplayMode) => void + workspaceStatuses: WorkspaceStatusDefinition[] + setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void + workspaceBoardOpacity: number + setWorkspaceBoardOpacity: (opacity: number) => void + workspaceBoardColumnWidth: number + setWorkspaceBoardColumnWidth: (width: number) => void + syncTaskStatusFromWorkspaceBoard: boolean + setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void + /** Transient: the in-window Agent Dashboard companion drawer is open. Not persisted. */ + agentDashboardDrawerOpen: boolean + setAgentDashboardDrawerOpen: (open: boolean) => void + statusBarItems: StatusBarItem[] + toggleStatusBarItem: (item: StatusBarItem) => void + statusBarVisible: boolean + setStatusBarVisible: (v: boolean) => void + usagePercentageDisplay: UsagePercentageDisplay + setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void + statusBarUsageMode: StatusBarUsageMode + setStatusBarUsageMode: (mode: StatusBarUsageMode) => void +} + +export type UISliceSurfaces = { + workspacePortScan: { key: string; result: WorkspacePortScanResult } | null + workspacePortScansByKey: Record + workspacePortScanRefreshing: boolean + setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void + setWorkspacePortScanProjection: ( + scan: { key: string; result: WorkspacePortScanResult } | null + ) => void + replaceWorkspacePortScans: ( + scansByKey: Record, + projection: { key: string; result: WorkspacePortScanResult } | null + ) => void + setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void + setWorkspacePortScanRefreshing: (refreshing: boolean) => void + /** Whether the pet overlay is currently visible. Persisted so "Hide pet" survives reload. Independent of the experimentalPet flag (which gates whether it can render at all). */ + petVisible: boolean + setPetVisible: (v: boolean) => void + /** Which pet is active — a bundled id or a custom UUID. Persisted via PersistedUIState. */ + petId: string + setPetId: (id: string) => void + /** User-uploaded pet images. Metadata only — bytes live in main's userData. */ + customPets: CustomPet[] + addCustomPet: (model: CustomPet) => void + removeCustomPet: (id: string) => void + /** Pet overlay size in CSS pixels (square). User-adjustable so an oversized imported sprite isn't stuck on screen. */ + petSize: number + setPetSize: (size: number) => void + pendingRevealWorktree: UISliceCore['pendingRevealWorktree'] + pendingRevealSidebarRow: UISliceCore['pendingRevealSidebarRow'] + revealWorktreeInSidebar: UISliceCore['revealWorktreeInSidebar'] + revealSidebarRow: UISliceCore['revealSidebarRow'] + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + // Why: cleared by the diff decorator after it reveals the line, so the same id can be requested again without a stale value. + scrollToDiffCommentId: string | null + setScrollToDiffCommentId: (id: string | null) => void +} + +export type UISlicePersistence = { + persistedUIReady: boolean + /** Writer-owned fields as last hydrated from main or flushed by the writer; the debounced writer diffs against this so it only persists fields this client changed (STA-5781). */ + persistedUIWriteBaseline: PersistedUIWriteBaseline | null + /** Fields with a ui.set round-trip in flight; hydration keeps the mirror's value for them so an echo of the in-flight write can't revert a newer flip-back. */ + persistedUIWriteInFlightCounts: Partial> + /** Bumped whenever hydration replaces the baseline; an ack whose write predates the bump must not fold, or it would erase a remote write that landed during the round trip. */ + persistedUIWriteBaselineGeneration: number + notePersistedUIWriteStarted: (fields: readonly (keyof PersistedUIWriteBaseline)[]) => void + /** Settle an in-flight write: fold the acked patch into the baseline (null = rejected, leaving the fields dirty so the next change re-flushes them). */ + notePersistedUIWriteSettled: ( + fields: readonly (keyof PersistedUIWriteBaseline)[], + flushed: Partial | null, + options?: { sentAtGeneration: number } + ) => void + uiZoomLevel: number + setUIZoomLevel: (level: number) => void + editorFontZoomLevel: number + setEditorFontZoomLevel: (level: number) => void + hydratePersistedUI: (ui: PersistedUIState, source?: 'startup' | 'sync') => void + updateStatus: UpdateStatus + setUpdateStatus: (status: UpdateStatus) => void + // Why: cache last-'available' changelog so the card keeps rich content while downloading; cleared on idle/checking to avoid staleness. + updateChangelog: ChangelogData | null + // Why: UpdateCard is lazy-loaded and may miss the transient checking status; hold manual-check intent until a terminal state consumes it. + updateUserInitiatedCycle: boolean + dismissedUpdateVersion: string | null + dismissUpdate: (versionOverride?: string) => void + clearDismissedUpdateVersion: () => void + /** Dev-only channel override; null follows the running build's own channel. */ + releaseChannelOverride: ReleaseChannel | null + setReleaseChannelOverride: (channel: ReleaseChannel | null) => void + // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). + updateCardCollapsed: boolean + setUpdateCardCollapsed: (collapsed: boolean) => void + updateReassuranceSeen: boolean + markUpdateReassuranceSeen: () => void + /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ + osc52ClipboardDefaultOnNoticePending: boolean + clearOsc52ClipboardDefaultOnNotice: () => void + isFullScreen: boolean + setIsFullScreen: (v: boolean) => void + /** URL opened when a new browser tab is created. Null = blank tab (default). */ + browserDefaultUrl: string | null + setBrowserDefaultUrl: (url: string | null) => void + browserDefaultSearchEngine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null + setBrowserDefaultSearchEngine: (engine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null) => void + browserDefaultZoomLevel: number + setBrowserDefaultZoomLevel: (level: number) => void + browserKagiSessionLink: string | null + setBrowserKagiSessionLink: (link: string | null) => void +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract.ts b/src/renderer/src/store/slices/ui/ui-slice-contract.ts new file mode 100644 index 00000000000..e330d069cc0 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract.ts @@ -0,0 +1,36 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { UISliceCore } from './ui-slice-contract-core' +import type { UISliceContextual } from './ui-slice-contract-contextual' +import type { + UISlicePersistence, + UISlicePreferences, + UISliceSurfaces +} from './ui-slice-contract-preferences' + +export type { + AgentSendPopoverTargetMode, + NewWorkspaceDraft, + OpenAgentSendPopoverTargetModeArgs, + PendingSidebarRowReveal, + PendingSidebarWorktreeReveal, + TaskPageData, + UISliceCore, + UiViewHistory +} from './ui-slice-contract-core' +export type { UISliceContextual } from './ui-slice-contract-contextual' +export type { + UISlicePersistence, + UISlicePreferences, + UISliceSurfaces +} from './ui-slice-contract-preferences' + +export type UISlice = UISliceCore & + UISliceContextual & + UISlicePreferences & + UISliceSurfaces & + UISlicePersistence + +type UISliceStateCreator = StateCreator +export type UISliceSet = Parameters[0] +export type UISliceGet = Parameters[1] diff --git a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts new file mode 100644 index 00000000000..ee96ec86d46 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts @@ -0,0 +1,84 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { mergeFeatureInteractionState } from './ui-slice-hydration-values' +import { mergeContextualTourSeenIds } from './ui-slice-hydration-values' +import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-tour-helpers' +import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' + +export function createUiFeatureActions(set: UISliceSet, get: UISliceGet): Partial { + return { + featureTipsSeenIds: [], + markFeatureTipsSeen: (ids) => + set((s) => { + if (ids.length === 0) { + return s + } + const current = new Set(s.featureTipsSeenIds) + let changed = false + for (const id of ids) { + if (!current.has(id)) { + current.add(id) + changed = true + } + } + if (!changed) { + return s + } + const next = [...current] + window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error) + return { featureTipsSeenIds: next } + }), + featureInteractions: {}, + recordFeatureInteraction: (id) => { + let tourProgression: ReturnType = + null + let persistPromise = Promise.resolve() + set((s) => { + if (!s.persistedUIReady) { + return s + } + tourProgression = getContextualTourProgressionForFeatureInteraction(s, id) + const existing = s.featureInteractions[id] + const next: FeatureInteractionState = { + ...s.featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + if (typeof window !== 'undefined') { + const recordInteraction = window.api.ui.recordFeatureInteraction + const persist = recordInteraction + ? recordInteraction(id).then((ui) => { + set((current) => ({ + featureInteractions: mergeFeatureInteractionState( + current.featureInteractions, + ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + current.contextualToursSeenIds, + ui.contextualToursSeenIds + ) + })) + }) + : window.api.ui.set({ featureInteractions: next }) + persistPromise = persist.catch(console.error) + } + if (tourProgression === 'reveal-sidebar-and-advance') { + // Why: split can fire from keyboard/menu with the sidebar closed, but the next tour target lives in the sidebar. + return { + featureInteractions: next, + sidebarOpen: true, + activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 + } + } + return { featureInteractions: next } + }) + if (tourProgression === 'complete') { + get().completeContextualTour() + } else if (tourProgression === 'advance') { + get().advanceContextualTour() + } + return persistPromise + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts new file mode 100644 index 00000000000..41dfbaa079c --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -0,0 +1,343 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { normalizeRightSidebarRoute } from '../../right-sidebar-route' +import { + applyManualRepoOrder, + normalizeManualRepoOrder +} from '../../../../../shared/manual-repo-order' +import { normalizeWorkspaceCleanupBrowseState } from '../../../../../shared/workspace-cleanup-browse-state' +import { + normalizeExecutionHostScope, + normalizeExecutionHostOrder +} from '../../../../../shared/execution-host' +import { normalizeFeatureInteractions } from '../../../../../shared/feature-interactions' +import { normalizeContextualTourIds } from '../../../../../shared/contextual-tours' +import { normalizeFeatureTipIds } from '../../../../../shared/feature-tips' +import { DEFAULT_HIDE_SLEEPING_WORKSPACES } from '../../../../../shared/constants' +import { + clampWorkspaceBoardColumnWidth, + clampWorkspaceBoardOpacity, + normalizeWorkspaceStatuses +} from '../../../../../shared/workspace-statuses' +import { + normalizeWorktreeCardProperties, + normalizeAgentActivityDisplayMode +} from '../../../../../shared/constants' +import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../../shared/pet-types' +import { clampMarkdownTocPanelWidth } from '../../../../../shared/markdown-toc-panel-width' +import { clampCombinedDiffFileTreeWidth } from '../../../../../shared/combined-diff-file-tree-width' +import { parsePersistedAutomationHostFilter } from '../../../../../shared/automation-host-filter' +import { normalizeUsagePercentageDisplay } from '../../../../../shared/usage-percentage-display' +import { normalizeStatusBarUsageMode } from '../../../../../shared/status-bar-usage-mode' +import { normalizeBrowserPageZoomLevel } from '../../../../../shared/browser-page-zoom' +import { normalizeKagiSessionLink } from '../../../../../shared/browser-url' +import { isReleaseChannel } from '../../../../../shared/release-channel' +import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' +import { + filterSetupScriptPromptDismissalsToValidRepos, + sanitizeSetupScriptPromptDismissals +} from '../../../lib/setup-script-prompt' +import { isBundledPetId, DEFAULT_PET_ID } from '../../../components/pet/pet-models' +import { getRepoHostIdentity } from '../repo-host-identity' +import type { PersistedUIWriteBaseline } from '../persisted-ui-write-baseline' +import { + capturePersistedUIWriteBaseline, + diffPersistedUIWriteFields +} from '../persisted-ui-write-baseline' +import { + hydrateTrustedOrcaHooks, + normalizeHydratedVisibleWorkspaceHostIds, + sanitizeAcknowledgedAgentsByPaneKey, + sanitizeHydratedActiveView, + sanitizePersistedRepoIds, + sanitizeShowDotfilesByWorktree, + sanitizeWorkspaceCleanupDismissals, + sanitizePersistedSidebarWidth, + hydratedUIPartialMatchesState, + migrateStatusBarItems +} from './ui-slice-hydration-sanitizers' +import { sanitizeTaskResumeState } from './ui-slice-hydration-values' +import { clampPetSize } from './ui-slice-hydration-sanitizers' + +const MAX_LEFT_SIDEBAR_WIDTH = 500 +const MAX_RIGHT_SIDEBAR_WIDTH = 4000 +const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' +const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' +const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' +const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' + +export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + hydratePersistedUI: (ui, source = 'sync') => + set((s) => { + const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) + const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) + const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) + const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) + // Why: pre-rename builds used sidekick* keys; read as fallback only so new pet* writes win after upgrade. + const customPets = Array.isArray(ui.customPets) + ? ui.customPets + : Array.isArray(ui.customSidekicks) + ? ui.customSidekicks + : [] + const petId = ui.petId ?? ui.sidekickId + // Migration: one-shot old-'recent'→'smart' runs in main (_sortBySmartMigrated), not here, so a deliberate 'recent' choice survives restart. + const sortBy = ui.sortBy + const migratedStatusBarItems = migrateStatusBarItems(ui.statusBarItems) + const statusBarItemsWithPorts: StatusBarItem[] = + ui._portsStatusBarDefaultAdded || migratedStatusBarItems.includes('ports') + ? migratedStatusBarItems + : [...migratedStatusBarItems, DEFAULT_ON_PORTS_STATUS_BAR_ITEM] + const statusBarItems: StatusBarItem[] = + ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') + ? statusBarItemsWithPorts + : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] + const statusBarItemsWithMiniMax: StatusBarItem[] = + ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') + ? statusBarItems + : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] + const statusBarItemsWithAntigravity: StatusBarItem[] = + ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity') + ? statusBarItemsWithMiniMax + : [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM] + const statusBarItemsWithGrok: StatusBarItem[] = + ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') + ? statusBarItemsWithAntigravity + : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] + if ( + (!ui._portsStatusBarDefaultAdded || + !ui._kimiStatusBarDefaultAdded || + !ui._minimaxStatusBarDefaultAdded || + !ui._antigravityStatusBarDefaultAdded || + !ui._grokStatusBarDefaultAdded) && + typeof window !== 'undefined' + ) { + window.api.ui + .set({ + statusBarItems: statusBarItemsWithGrok, + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true + }) + .catch(console.error) + } + const rightSidebarRoute = normalizeRightSidebarRoute( + ui.rightSidebarTab, + ui.rightSidebarExplorerView + ) + const hydrated = { + // Why: persisted widths may be stale/corrupt/hand-edited; clamp during hydration so invalid values can't break layout. + sidebarWidth: sanitizePersistedSidebarWidth( + ui.sidebarWidth, + s.sidebarWidth, + MAX_LEFT_SIDEBAR_WIDTH + ), + rightSidebarWidth: sanitizePersistedSidebarWidth( + ui.rightSidebarWidth, + s.rightSidebarWidth, + MAX_RIGHT_SIDEBAR_WIDTH + ), + markdownTocPanelWidth: clampMarkdownTocPanelWidth( + ui.markdownTocPanelWidth, + undefined, + s.markdownTocPanelWidth + ), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + ui.combinedDiffFileTreeWidth, + undefined, + s.combinedDiffFileTreeWidth + ), + rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, + rightSidebarTab: rightSidebarRoute.rightSidebarTab, + rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, + groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, + sortBy, + // Why: main-process getUI() already normalized this (defaulting to 'manual'); read it through without migrating. + projectOrderBy: ui.projectOrderBy, + // Why: Active-only was retired; force the old flag off so an old profile can't invisibly narrow the workspace list. + showActiveOnly: false, + // Why: ignore older positive-form keys so old profiles start from the new default (sleeping workspaces visible). + showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), + workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), + visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), + workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), + // Why: a malformed or legacy filter value must degrade to All hosts, never throw during hydration. + automationHostFilter: parsePersistedAutomationHostFilter(ui.automationHostFilter), + manualRepoOrder, + // Why: apply the desktop-owned overlay immediately since UI state can arrive after a catalog or from another client. + repos: orderedRepos, + hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, + hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, + hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, + hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, + hideWorkspacesFromOtherDevices: ui.hideWorkspacesFromOtherDevices === true, + // Why !== false: profiles written before #8873 have no key, and they are + // precisely the ones showing the bug, so absence must mean "exempt". + alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, + showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), + // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. + filterRepoIds: + validRepoIds.size === 0 + ? persistedFilterRepoIds + : persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)), + collapsedGroups: new Set(ui.collapsedGroups ?? []), + uiZoomLevel: ui.uiZoomLevel ?? 0, + editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, + worktreeCardProperties: normalizeWorktreeCardProperties(ui.worktreeCardProperties), + _worktreeCardModeDefaulted: ui._worktreeCardModeDefaulted === true, + agentActivityDisplayMode: normalizeAgentActivityDisplayMode(ui.agentActivityDisplayMode), + workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), + workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), + syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, + statusBarItems: statusBarItemsWithGrok, + statusBarVisible: ui.statusBarVisible ?? true, + usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), + statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode), + // Why: default true so existing users see the pet on first enabling the flag; only an explicit Hide persists false. + petVisible: ui.petVisible ?? ui.sidekickVisible ?? true, + petSize: clampPetSize(ui.petSize ?? ui.sidekickSize ?? PET_SIZE_DEFAULT, { + min: PET_SIZE_MIN, + max: PET_SIZE_MAX, + fallback: PET_SIZE_DEFAULT + }), + customPets, + // Why: fall back to default when the persisted id is unknown (e.g. custom pet removed elsewhere) so the overlay renders. + petId: ((): string => { + const id = petId + if (typeof id !== 'string') { + return DEFAULT_PET_ID + } + if (isBundledPetId(id)) { + return id + } + if (customPets.some((m) => m.id === id)) { + return id + } + return DEFAULT_PET_ID + })(), + dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + // Why: a persisted value from a build that knew a different channel set + // would otherwise survive as-is; activeChannel only falls back on null, + // so an unknown string reaches listBuilds and the segmented control. + releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride) + ? ui.releaseChannelOverride + : null, + updateReassuranceSeen: ui.updateReassuranceSeen ?? false, + osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, + browserDefaultUrl: ui.browserDefaultUrl ?? null, + browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, + browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(ui.browserDefaultZoomLevel), + browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''), + taskResumeState: sanitizeTaskResumeState(ui.taskResumeState), + featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds), + featureInteractions: normalizeFeatureInteractions(ui.featureInteractions), + contextualToursSeenIds: normalizeContextualTourIds(ui.contextualToursSeenIds), + contextualToursAutoEligible: + typeof ui.contextualToursAutoEligible === 'boolean' + ? ui.contextualToursAutoEligible + : null, + trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds), + setupScriptPromptDismissedRepoIds: + validRepoHostIdentities.size === 0 + ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) + : filterSetupScriptPromptDismissalsToValidRepos( + ui.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, + setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true, + setupGuideBrowserMilestoneLegacyComplete: + ui.setupGuideBrowserMilestoneLegacyComplete === true, + browserImportHintHidden: ui.browserImportHintHidden === true, + mobileEmulatorTabIntroDismissed: ui.mobileEmulatorTabIntroDismissed === true, + mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true, + projectOrderManualDefaultNoticeDismissed: + ui.projectOrderManualDefaultNoticeDismissed === true, + // Why: treat only explicit true as dismissed so a false from migration still surfaces. + usagePercentageDisplayChangeNoticeDismissed: + ui.usagePercentageDisplayChangeNoticeDismissed === true, + // Why: default false so existing users still see the CTA; only explicit dismissal persists true. + usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, + // Why: stale acks are inert (paneKey reuse beats them via stateStartedAt); sanitizer bounds growth past HYDRATE_MAX_AGE_MS. + acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey( + ui.acknowledgedAgentsByPaneKey + ), + workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( + ui.workspaceCleanup?.dismissals + ), + // Why the normalizer rather than a cast: this blob is hand-editable and + // may come from an older or newer build; it degrades field by field + // instead of bricking the cleanup dialog. + // Why: a sync broadcast can carry stale browse state while its writer is debounced. + workspaceCleanupBrowse: + source === 'startup' + ? normalizeWorkspaceCleanupBrowseState(ui.workspaceCleanup?.browse) + : s.workspaceCleanupBrowse, + // Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view. + activeView: + source === 'startup' + ? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true) + : s.activeView, + persistedUIReady: true + } + // The incoming payload is authoritative for the writer-owned fields, so it becomes the + // writer's new diff baseline — but fields with an unflushed local edit (mirror diverged + // from the previous baseline) keep the local value so a broadcast arriving inside the + // writer's debounce window can't silently revert what the user just toggled (STA-5781). + // Order matters: capture the baseline BEFORE overlaying pending edits, or the baseline + // would equal the pending value, the diff would go empty, and the toggle would be dropped. + // Note the width sanitizers above fall back to the CURRENT store value only for + // non-numeric input (numbers are clamped in place), so a captured width can differ + // from what main holds only for garbage payloads; at worst main keeps an + // out-of-range width until the next drag re-writes it. + const nextWriteBaseline = capturePersistedUIWriteBaseline(hydrated) + const previousBaseline = s.persistedUIWriteBaseline + if (previousBaseline) { + const pendingLocalEdits = diffPersistedUIWriteFields( + capturePersistedUIWriteBaseline(s), + previousBaseline + ) + Object.assign(hydrated, pendingLocalEdits) + // In-flight fields too: a flip-back to the baseline value diffs empty, + // yet the in-flight write's echo must not revert it (PR#17057 review). + for (const field of Object.keys( + s.persistedUIWriteInFlightCounts + ) as (keyof PersistedUIWriteBaseline)[]) { + ;(hydrated as Record)[field] = s[field] + } + } + // Why: return the same ref on identical hydration so App's debounced writer doesn't echo it back to main. + // The baseline must still advance when it moved (a remote same-field write during an in-flight + // ack pins the only visibly differing field, and our own echo precedes every ack) — but only + // the two baseline keys, or every ordinary write's echo would churn the store's collection + // identities and re-render identity-compared selectors once per write. + // Why the generation bumps only on baseline movement: an unrelated-field + // broadcast during an in-flight write would otherwise void that write's + // fold and cost a redundant trailing re-send of identical values. + const writeBaselineMoved = + !previousBaseline || + Object.keys(diffPersistedUIWriteFields(nextWriteBaseline, previousBaseline)).length > 0 + const nextWriteBaselineGeneration = writeBaselineMoved + ? s.persistedUIWriteBaselineGeneration + 1 + : s.persistedUIWriteBaselineGeneration + if (hydratedUIPartialMatchesState(s, hydrated as Partial)) { + if (!writeBaselineMoved) { + return s + } + return { + persistedUIWriteBaseline: nextWriteBaseline, + persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration + } + } + return { + ...hydrated, + persistedUIWriteBaseline: nextWriteBaseline, + persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration + } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts new file mode 100644 index 00000000000..c557a75f246 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -0,0 +1,221 @@ +import type { AppState } from '../../types' +import type { PersistedTrustedOrcaHooks } from '../../../../../shared/orca-yaml-hook-types' +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { + TaskViewPresetId, + TopLevelView, + VisibleWorkspaceHostIds +} from '../../../../../shared/ui-chrome-types' +import type { WorkspaceCleanupDismissal } from '../../../../../shared/workspace-cleanup' +import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION } from '../../../../../shared/workspace-cleanup' +import { isTopLevelView } from '../../../../../shared/top-level-view' +import { + normalizeVisibleExecutionHostIds, + normalizeExecutionHostScope +} from '../../../../../shared/execution-host' +import { persistedUIValuesEqual } from '../../../../../shared/persisted-ui-equality' +import { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants' +import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' +import type { UISlice } from './ui-slice-contract' + +const MIN_SIDEBAR_WIDTH = 220 +const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +export function isPlainPersistedRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function isSafePersistedRecordKey(key: string): boolean { + return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' +} + +export function sanitizePersistedRepoIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((repoId): repoId is string => typeof repoId === 'string') +} + +export function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks { + if (!isPlainPersistedRecord(trust)) { + return {} + } + const next: PersistedTrustedOrcaHooks = {} + for (const [repoId, entry] of Object.entries(trust)) { + if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) { + continue + } + next[repoId] = entry as PersistedTrustedOrcaHooks[string] + } + return next +} + +export function hydrateTrustedOrcaHooks( + trust: unknown, + validRepoIds: Set +): PersistedTrustedOrcaHooks { + const sanitized = sanitizeTrustedOrcaHooks(trust) + if (validRepoIds.size === 0) { + return sanitized + } + const next: PersistedTrustedOrcaHooks = {} + for (const [repoId, entry] of Object.entries(sanitized)) { + if (validRepoIds.has(repoId)) { + next[repoId] = entry + } + } + return next +} + +export function sanitizeShowDotfilesByWorktree(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { + if (!worktreeId || !isSafePersistedRecordKey(worktreeId) || typeof showDotfiles !== 'boolean') { + continue + } + out[worktreeId] = showDotfiles + } + return out +} + +export function sanitizePersistedSidebarWidth( + width: unknown, + fallback: number, + maxWidth: number +): number { + if (typeof width !== 'number' || !Number.isFinite(width)) { + return fallback + } + return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width)) +} + +export function sanitizeAcknowledgedAgentsByPaneKey(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const cutoff = Date.now() - HYDRATE_MAX_AGE_MS + const out: Record = {} + for (const [key, ackAt] of Object.entries(value as Record)) { + if (!isSafePersistedRecordKey(key)) { + continue + } + if (typeof ackAt !== 'number' || !Number.isFinite(ackAt) || ackAt <= 0 || ackAt < cutoff) { + continue + } + out[key] = ackAt + } + return out +} + +export function sanitizeWorkspaceCleanupDismissals( + value: unknown +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + if ( + !isSafePersistedRecordKey(key) || + raw === null || + typeof raw !== 'object' || + Array.isArray(raw) + ) { + continue + } + const input = raw as Record + if ( + typeof input.worktreeId !== 'string' || + typeof input.dismissedAt !== 'number' || + !Number.isFinite(input.dismissedAt) || + typeof input.fingerprint !== 'string' || + input.classifierVersion !== WORKSPACE_CLEANUP_CLASSIFIER_VERSION + ) { + continue + } + out[key] = { + worktreeId: input.worktreeId, + dismissedAt: input.dismissedAt, + fingerprint: input.fingerprint, + classifierVersion: input.classifierVersion + } + } + return out +} + +export function sanitizeHydratedActiveView( + value: PersistedUIState['activeView'], + experimentalActivityEnabled: boolean +): TopLevelView { + // Why: older data (pre-activeView) or a view a different build doesn't have falls back to terminal rather than rendering nothing. + if (!isTopLevelView(value)) { + return 'terminal' + } + // Why: activity is hidden when its setting is off, so gate only it (mobile/automations stay functional when hidden). + if (value === 'activity' && !experimentalActivityEnabled) { + return 'terminal' + } + return value +} + +export function hydratedUIPartialMatchesState( + state: AppState, + hydrated: Partial +): boolean { + return Object.entries(hydrated).every(([key, value]) => + persistedUIValuesEqual(state[key as keyof AppState], value) + ) +} + +export function normalizeHydratedVisibleWorkspaceHostIds( + ui: PersistedUIState +): VisibleWorkspaceHostIds { + const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) + if (visibleHostIds) { + return visibleHostIds + } + const legacyScope = normalizeExecutionHostScope(ui.workspaceHostScope) + return legacyScope === 'all' ? null : [legacyScope] +} + +export function clampPetSize( + size: number, + defaults: { min: number; max: number; fallback: number } +): number { + if (!Number.isFinite(size)) { + return defaults.fallback + } + return Math.max(defaults.min, Math.min(defaults.max, Math.round(size))) +} + +export function presetToQuery(presetId: TaskViewPresetId | null): string { + switch (presetId) { + case 'all': + case 'issues': + return 'is:issue is:open' + case 'my-issues': + return 'assignee:@me is:issue is:open' + case 'prs': + return 'is:pr is:open' + case 'review': + return 'review-requested:@me is:pr is:open' + case 'my-prs': + return 'author:@me is:pr is:open' + case null: + return 'is:issue is:open' + } +} + +export function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { + const source = items ?? DEFAULT_STATUS_BAR_ITEMS + const out: string[] = [] + for (const id of source) { + const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id + if (!out.includes(mapped)) { + out.push(mapped) + } + } + return out as StatusBarItem[] +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts new file mode 100644 index 00000000000..7bb8376fb27 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts @@ -0,0 +1,134 @@ +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { TaskResumeState, TaskViewPresetId } from '../../../../../shared/ui-chrome-types' +import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' +import type { ContextualTourId } from '../../../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../../../shared/feature-interactions' +import { normalizeContextualTourIds } from '../../../../../shared/contextual-tours' + +const VALID_TASK_PRESETS = new Set([ + 'all', + 'issues', + 'review', + 'my-issues', + 'my-prs', + 'prs' +]) +const VALID_LINEAR_PRESETS = new Set>([ + 'assigned', + 'created', + 'all', + 'completed' +]) +const VALID_LINEAR_MODES = new Set>([ + 'issues', + 'projects', + 'views', + 'in-orca' +]) +const VALID_JIRA_PRESETS = new Set>([ + 'assigned', + 'reported', + 'all', + 'done' +]) + +export function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const input = value as Record + const next: TaskResumeState = {} + if (input.githubMode === 'items' || input.githubMode === 'project') { + next.githubMode = input.githubMode + } + if (input.githubItemsPreset === null) { + next.githubItemsPreset = null + } else if ( + typeof input.githubItemsPreset === 'string' && + VALID_TASK_PRESETS.has(input.githubItemsPreset as TaskViewPresetId) + ) { + next.githubItemsPreset = input.githubItemsPreset as TaskViewPresetId + } + if (typeof input.githubItemsQuery === 'string') { + next.githubItemsQuery = input.githubItemsQuery + } + if ( + typeof input.linearPreset === 'string' && + VALID_LINEAR_PRESETS.has(input.linearPreset as NonNullable) + ) { + next.linearPreset = input.linearPreset as NonNullable + } + if ( + typeof input.linearMode === 'string' && + VALID_LINEAR_MODES.has(input.linearMode as NonNullable) + ) { + next.linearMode = input.linearMode as NonNullable + } + if (typeof input.linearQuery === 'string') { + next.linearQuery = input.linearQuery + } + if (input.linearContext && typeof input.linearContext === 'object') { + const context = input.linearContext as Record + if ( + (context.kind === 'project' || context.kind === 'view') && + typeof context.id === 'string' && + context.id.trim() && + typeof context.workspaceId === 'string' && + context.workspaceId.trim() && + context.workspaceId !== 'all' + ) { + next.linearContext = { + kind: context.kind, + id: context.id, + workspaceId: context.workspaceId, + model: context.model === 'issue' || context.model === 'project' ? context.model : undefined + } + } + } + if ( + typeof input.jiraPreset === 'string' && + VALID_JIRA_PRESETS.has(input.jiraPreset as NonNullable) + ) { + next.jiraPreset = input.jiraPreset as NonNullable + } + if (typeof input.jiraQuery === 'string') { + next.jiraQuery = input.jiraQuery + } + return Object.keys(next).length > 0 ? next : undefined +} + +export function mergeFeatureInteractionState( + current: FeatureInteractionState, + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const currentRecord = currentNormalized[id as keyof FeatureInteractionState] + merged[id as keyof FeatureInteractionState] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + +export function mergeContextualTourSeenIds( + current: readonly ContextualTourId[], + incoming: PersistedUIState['contextualToursSeenIds'] +): ContextualTourId[] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts new file mode 100644 index 00000000000..278aefd5803 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts @@ -0,0 +1,25 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { settleEvictedModalData } from '../modal-slot-dismissal' + +export function createUiModalActions(set: UISliceSet, get: UISliceGet): Partial { + return { + activeModal: 'none', + modalData: {}, + openModal: (modal, data = {}) => { + if (modal === 'add-repo' || modal === 'create-worktree') { + get().recordFeatureInteraction?.('workspace-creation') + } + const evicted = get().modalData + set({ + activeModal: modal, + modalData: data + }) + settleEvictedModalData(evicted) + }, + closeModal: () => { + const evicted = get().modalData + set({ activeModal: 'none', modalData: {} }) + settleEvictedModalData(evicted) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts new file mode 100644 index 00000000000..1029d16a395 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts @@ -0,0 +1,52 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' + +export function createUiPersistenceActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + persistedUIReady: false, + persistedUIWriteBaseline: null, + persistedUIWriteInFlightCounts: {}, + notePersistedUIWriteStarted: (fields) => + set((s) => { + const counts = { ...s.persistedUIWriteInFlightCounts } + for (const field of fields) { + counts[field] = (counts[field] ?? 0) + 1 + } + return { persistedUIWriteInFlightCounts: counts } + }), + persistedUIWriteBaselineGeneration: 0, + notePersistedUIWriteSettled: (fields, flushed, options) => + set((s) => { + const counts = { ...s.persistedUIWriteInFlightCounts } + for (const field of fields) { + const next = (counts[field] ?? 0) - 1 + if (next > 0) { + counts[field] = next + } else { + delete counts[field] + } + } + // Why the generation guard: a hydration during the round trip made the + // baseline authoritative for state NEWER than this write; folding the + // sent values over it would blank the mirror-vs-baseline diff and leave + // mirror and authority divergent with nothing left to reconcile them. + // Skipping the fold keeps the diff alive so the trailing flush re-sends. + // Why options is required for folding: an unguarded fold from a future + // caller could silently erase a remote write that landed mid-round-trip. + const foldable = + flushed && + s.persistedUIWriteBaseline && + options !== undefined && + options.sentAtGeneration === s.persistedUIWriteBaselineGeneration + return { + persistedUIWriteInFlightCounts: counts, + ...(foldable + ? { persistedUIWriteBaseline: { ...s.persistedUIWriteBaseline!, ...flushed } } + : {}) + } + }), + uiZoomLevel: 0, + setUIZoomLevel: (level) => set({ uiZoomLevel: level }), + editorFontZoomLevel: 0, + setEditorFontZoomLevel: (level) => set({ editorFontZoomLevel: level }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts new file mode 100644 index 00000000000..3859ebfc75a --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts @@ -0,0 +1,261 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { + DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, + DEFAULT_SHOW_SLEEPING_WORKSPACES, + DEFAULT_STATUS_BAR_ITEMS, + DEFAULT_WORKTREE_CARD_PROPERTIES, + getWorktreeCardModeUpdates, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../../../shared/constants' +import { + DEFAULT_USAGE_PERCENTAGE_DISPLAY, + normalizeUsagePercentageDisplay +} from '../../../../../shared/usage-percentage-display' +import { + DEFAULT_STATUS_BAR_USAGE_MODE, + normalizeStatusBarUsageMode +} from '../../../../../shared/status-bar-usage-mode' +import type { WorkspaceHostScope } from '../../../../../shared/ui-chrome-types' +import { + normalizeExecutionHostOrder, + normalizeExecutionHostScope, + normalizeVisibleExecutionHostIds +} from '../../../../../shared/execution-host' +import { + ALL_AUTOMATION_HOSTS_FILTER, + toPersistedAutomationHostFilter +} from '../../../../../shared/automation-host-filter' +import { + clampWorkspaceBoardColumnWidth, + clampWorkspaceBoardOpacity, + cloneDefaultWorkspaceStatuses, + normalizeWorkspaceStatuses, + WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT +} from '../../../../../shared/workspace-statuses' + +export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Partial { + return { + groupBy: 'repo', + // Why: group keys are mode-specific, so clear collapsed state on mode switch — stale keys are meaningless and accumulate. + setGroupBy: (g) => { + window.api.ui.set({ groupBy: g, collapsedGroups: [] }).catch(console.error) + set({ groupBy: g, collapsedGroups: new Set() }) + }, + + sortBy: 'recent', + setSortBy: (s) => set({ sortBy: s }), + + // Why: bare set — persists only via the debounced window.api.ui.set writer in App.tsx, not on its own. + projectOrderBy: 'manual', + setProjectOrderBy: (p) => set({ projectOrderBy: p }), + + showActiveOnly: false, + setShowActiveOnly: (v) => set({ showActiveOnly: v }), + + showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, + setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), + + workspaceHostScope: 'all', + // Why: host scope is presentation/filtering only — must never trigger resource teardown (terminals, browser pages). + setWorkspaceHostScope: (scope) => { + const normalized = normalizeExecutionHostScope(scope) + const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] + set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + window.api.ui + .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + .catch(console.error) + }, + visibleWorkspaceHostIds: null, + setVisibleWorkspaceHostIds: (ids) => { + const normalized = normalizeVisibleExecutionHostIds(ids) + // Why: workspaceHostScope stays the compat/default-host signal for creation flows; visibility can now be multi-select. + let workspaceHostScope: WorkspaceHostScope = get().workspaceHostScope + if (normalized === null) { + workspaceHostScope = 'all' + } else if (normalized.length === 1) { + workspaceHostScope = normalized[0] + } + set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + window.api.ui + .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + .catch(console.error) + }, + workspaceHostOrder: [], + setWorkspaceHostOrder: (ids) => { + const workspaceHostOrder = normalizeExecutionHostOrder(ids) + set({ workspaceHostOrder }) + window.api.ui.set({ workspaceHostOrder }).catch(console.error) + }, + automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, + setAutomationHostFilter: (filter) => { + window.api.ui + .set({ automationHostFilter: toPersistedAutomationHostFilter(filter) }) + .catch(console.error) + set({ automationHostFilter: filter }) + }, + manualRepoOrder: [], + + hideDefaultBranchWorkspace: false, + setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), + hideAutomationGeneratedWorkspaces: false, + setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), + hideCliCreatedWorkspaces: false, + setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), + hideDetachedHeadWorkspaces: false, + setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), + hideWorkspacesFromOtherDevices: false, + setHideWorkspacesFromOtherDevices: (v) => set({ hideWorkspacesFromOtherDevices: v }), + alwaysShowDefaultBranchWorkspace: true, + setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), + + showDotfilesByWorktree: {}, + setShowDotfilesForWorktree: (worktreeId, showDotfiles) => + set((s) => { + if (!worktreeId) { + return s + } + const current = s.showDotfilesByWorktree[worktreeId] ?? true + if (current === showDotfiles) { + return s + } + const next = { ...s.showDotfilesByWorktree } + // Why: showing dotfiles is the default; only persist worktree-level opt-outs. + if (showDotfiles) { + delete next[worktreeId] + } else { + next[worktreeId] = false + } + return { showDotfilesByWorktree: next } + }), + toggleShowDotfilesForWorktree: (worktreeId) => + set((s) => { + if (!worktreeId) { + return s + } + const nextShowDotfiles = !(s.showDotfilesByWorktree[worktreeId] ?? true) + const next = { ...s.showDotfilesByWorktree } + if (nextShowDotfiles) { + delete next[worktreeId] + } else { + next[worktreeId] = false + } + return { showDotfilesByWorktree: next } + }), + + filterRepoIds: [], + setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), + + collapsedGroups: new Set(), + toggleCollapsedGroup: (key) => + set((s) => { + const next = new Set(s.collapsedGroups) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + window.api.ui.set({ collapsedGroups: [...next] }).catch(console.error) + return { collapsedGroups: next } + }), + + worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES], + _worktreeCardModeDefaulted: true, + setWorktreeCardMode: (mode) => { + const updates = getWorktreeCardModeUpdates(mode) + set((s) => ({ + settings: s.settings ? { ...s.settings, ...updates.settings } : s.settings, + worktreeCardProperties: updates.ui.worktreeCardProperties, + _worktreeCardModeDefaulted: true + })) + void Promise.all([ + window.api.settings.set(updates.settings).then((nextSettings) => { + if (nextSettings) { + set({ settings: nextSettings }) + } + }), + window.api.ui.set(updates.ui) + ]).catch(console.error) + }, + setWorktreeCardProperties: (properties) => { + const normalized = normalizeWorktreeCardProperties(properties) + set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) + window.api.ui + .set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) + .catch(console.error) + }, + agentActivityDisplayMode: DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, + setAgentActivityDisplayMode: (mode) => { + const normalized = normalizeAgentActivityDisplayMode(mode) + window.api.ui.set({ agentActivityDisplayMode: normalized }).catch(console.error) + set({ agentActivityDisplayMode: normalized }) + }, + + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + setWorkspaceStatuses: (statuses) => { + const normalized = normalizeWorkspaceStatuses(statuses) + window.api.ui.set({ workspaceStatuses: normalized }).catch(console.error) + set({ workspaceStatuses: normalized }) + }, + + workspaceBoardOpacity: 1, + setWorkspaceBoardOpacity: (opacity) => { + const clamped = clampWorkspaceBoardOpacity(opacity) + window.api.ui.set({ workspaceBoardOpacity: clamped }).catch(console.error) + set({ workspaceBoardOpacity: clamped }) + }, + + workspaceBoardColumnWidth: WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, + setWorkspaceBoardColumnWidth: (width) => { + const clamped = clampWorkspaceBoardColumnWidth(width) + window.api.ui.set({ workspaceBoardColumnWidth: clamped }).catch(console.error) + set({ workspaceBoardColumnWidth: clamped }) + }, + + syncTaskStatusFromWorkspaceBoard: false, + setSyncTaskStatusFromWorkspaceBoard: (enabled) => { + window.api.ui.set({ syncTaskStatusFromWorkspaceBoard: enabled }).catch(console.error) + set({ syncTaskStatusFromWorkspaceBoard: enabled }) + }, + + statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], + toggleStatusBarItem: (item) => + set((s) => { + const current = s.statusBarItems || DEFAULT_STATUS_BAR_ITEMS + const updated = current.includes(item) + ? current.filter((i) => i !== item) + : [...current, item] + window.api.ui.set({ statusBarItems: updated }).catch(console.error) + return { statusBarItems: updated } + }), + + agentDashboardDrawerOpen: false, + setAgentDashboardDrawerOpen: (open) => set({ agentDashboardDrawerOpen: open }), + statusBarVisible: true, + setStatusBarVisible: (v) => { + window.api.ui.set({ statusBarVisible: v }).catch(console.error) + set({ statusBarVisible: v }) + }, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, + setUsagePercentageDisplay: (display) => { + const normalized = normalizeUsagePercentageDisplay(display) + // Why: changing the control is the discovery path, so permanently dismiss the one-time change notice. + window.api.ui + .set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) + .catch(console.error) + set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) + }, + statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, + setStatusBarUsageMode: (mode) => { + const normalized = normalizeStatusBarUsageMode(mode) + window.api.ui.set({ statusBarUsageMode: normalized }).catch(console.error) + set({ statusBarUsageMode: normalized }) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts new file mode 100644 index 00000000000..93a4d0069fb --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts @@ -0,0 +1,65 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { isSettingsNavigationTarget } from '../../../lib/settings-navigation-types' + +export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Partial { + return { + openSettingsPage: () => { + // Why: settings search is a transient filter; opening Settings shouldn't inherit hidden sections from last visit. + get().setSettingsSearchQuery('') + set((state) => ({ + activeView: 'settings', + // Why: preserve the originating view so Settings back returns there (e.g. in-progress draft), not always terminal. + previousViewBeforeSettings: + state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView + })) + }, + closeSettingsPage: () => + set((state) => { + const previousView = + state.previousViewBeforeSettings === 'activity' && + state.settings?.experimentalActivity !== true + ? 'terminal' + : state.previousViewBeforeSettings + return { activeView: previousView } + }), + settingsNavigationTarget: null, + openSettingsTarget: (target) => { + if (!isSettingsNavigationTarget(target)) { + if (import.meta.env.DEV) { + throw new TypeError('openSettingsTarget received an invalid navigation target') + } + return + } + set({ settingsNavigationTarget: target }) + }, + clearSettingsTarget: () => set({ settingsNavigationTarget: null }), + settingsProjectHostSelection: {}, + settingsProjectSetupSelection: {}, + // Why: renderer-only, never persisted — no window.api.ui.set, and absent from the debounced UI writer in App.tsx. + setSettingsProjectHostSelection: (projectId, hostId, setupId) => + set((s) => { + const nextSetupSelections = { ...s.settingsProjectSetupSelection } + if (setupId) { + nextSetupSelections[projectId] = setupId + } else { + delete nextSetupSelections[projectId] + } + if ( + s.settingsProjectHostSelection[projectId] === hostId && + s.settingsProjectSetupSelection[projectId] === setupId + ) { + return s + } + return { + settingsProjectHostSelection: { + ...s.settingsProjectHostSelection, + [projectId]: hostId + }, + settingsProjectSetupSelection: nextSetupSelections + } + }), + appearanceAccordionDeepLink: null, + setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }), + clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts new file mode 100644 index 00000000000..fb83ff6fc9b --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -0,0 +1,169 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { revokeCustomPetBlobUrl } from '../../../components/pet/pet-blob-cache' +import { DEFAULT_PET_ID } from '../../../components/pet/pet-models' +import { + PET_SIZE_DEFAULT, + PET_SIZE_MAX, + PET_SIZE_MIN, + type CustomPet +} from '../../../../../shared/pet-types' +import { clampPetSize } from './ui-slice-hydration-sanitizers' + +export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + workspacePortScan: null, + workspacePortScansByKey: {}, + workspacePortScanRefreshing: false, + setWorkspacePortScan: (scan) => + set((state) => { + if (!scan) { + if (!state.workspacePortScan && Object.keys(state.workspacePortScansByKey).length === 0) { + return state + } + return { workspacePortScan: null, workspacePortScansByKey: {} } + } + if ( + state.workspacePortScan?.key === scan.key && + state.workspacePortScan.result === scan.result && + state.workspacePortScansByKey[scan.key] === scan.result + ) { + return state + } + return { + workspacePortScan: scan, + workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } + } + }), + // Why: target changes rebuild the aggregate without republishing or clearing per-host scans. + setWorkspacePortScanProjection: (scan) => + set((state) => { + if ( + state.workspacePortScan?.key === scan?.key && + state.workspacePortScan?.result === scan?.result + ) { + return state + } + return { workspacePortScan: scan } + }), + // Why: drop stale per-host scans in one store update so a large host set can't fan out notifications to every subscriber. + replaceWorkspacePortScans: (scansByKey, projection) => + set((state) => { + if ( + state.workspacePortScansByKey === scansByKey && + state.workspacePortScan?.key === projection?.key && + state.workspacePortScan?.result === projection?.result + ) { + return state + } + return { workspacePortScansByKey: scansByKey, workspacePortScan: projection } + }), + setWorkspacePortScanForKey: (key, result) => + set((state) => { + const currentResult = state.workspacePortScansByKey[key] + if (currentResult === result || (!result && !currentResult)) { + return state + } + const nextScansByKey = { ...state.workspacePortScansByKey } + if (result) { + nextScansByKey[key] = result + } else { + delete nextScansByKey[key] + } + return { + workspacePortScansByKey: nextScansByKey, + workspacePortScan: + state.workspacePortScan?.key === key + ? result + ? { key, result } + : null + : state.workspacePortScan + } + }), + setWorkspacePortScanRefreshing: (refreshing) => + set({ workspacePortScanRefreshing: refreshing }), + + // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). + petVisible: true, + setPetVisible: (v) => { + window.api.ui.set({ petVisible: v }).catch(console.error) + set({ petVisible: v }) + }, + + petId: DEFAULT_PET_ID, + setPetId: (id) => { + window.api.ui.set({ petId: id }).catch(console.error) + set({ petId: id }) + }, + + petSize: PET_SIZE_DEFAULT, + setPetSize: (size) => { + const clamped = clampPetSize(size, { + min: PET_SIZE_MIN, + max: PET_SIZE_MAX, + fallback: PET_SIZE_DEFAULT + }) + window.api.ui.set({ petSize: clamped }).catch(console.error) + set({ petSize: clamped }) + }, + + customPets: [], + addCustomPet: (model) => + set((s) => { + const next = [...s.customPets.filter((m) => m.id !== model.id), model] + window.api.ui.set({ customPets: next }).catch(console.error) + return { customPets: next } + }), + removeCustomPet: (id) => + set((s) => { + const target = s.customPets.find((m) => m.id === id) + if (!target) { + return s + } + const next = s.customPets.filter((m) => m.id !== id) + // Why: removing the active custom pet falls back to bundled default so the overlay isn't empty. + const fallback = s.petId === id ? DEFAULT_PET_ID : s.petId + // Why: single combined IPC update so customPets and petId persist atomically. + const ipcPayload: { customPets: CustomPet[]; petId?: string } = { + customPets: next + } + if (fallback !== s.petId) { + ipcPayload.petId = fallback + } + window.api.ui.set(ipcPayload).catch(console.error) + // Why: revoke the cached blob: URL so the Blob is released, not leaked for the session. + revokeCustomPetBlobUrl(id) + // Why: best-effort delete — bytes owned by main; fresh-UUID imports mean an orphaned file is never re-referenced. + window.api.pet.delete(id, target.fileName, target.kind).catch(console.error) + const partial: Partial = { customPets: next } + if (fallback !== s.petId) { + partial.petId = fallback + } + return partial + }), + + pendingRevealWorktree: null, + pendingRevealSidebarRow: null, + revealWorktreeInSidebar: (worktreeId, options) => + set({ + pendingRevealWorktree: { + worktreeId, + ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight ? { highlight: true } : {}), + ...(options?.beginRename ? { beginRename: true } : {}) + } + }), + revealSidebarRow: (rowKey, options) => + set({ + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + }), + clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), + clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts new file mode 100644 index 00000000000..efee4b93ce9 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts @@ -0,0 +1,215 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { findPrevLiveNonTaskStackHistoryIndex } from '../worktree-nav-history' +import { hasFeatureInteraction } from '../../../../../shared/feature-interactions' +import { + normalizeVisibleTaskProviders, + restoreAvailableDefaultTaskProvider, + resolveVisibleTaskProvider +} from '../../../../../shared/task-providers' +import { PER_REPO_FETCH_LIMIT } from '../../../../../shared/work-items' +import { isGitRepoKind } from '../../../../../shared/repo-kind' +import { presetToQuery } from './ui-slice-hydration-sanitizers' + +const LINEAR_TASK_PREFETCH_LIMIT = 36 + +export function createUiTaskActions(set: UISliceSet, get: UISliceGet): Partial { + return { + activeView: 'terminal', + previousViewBeforeTasks: 'terminal', + previousViewBeforeSettings: 'terminal', + previousViewBeforeActivity: 'terminal', + previousViewBeforeAutomations: 'terminal', + previousViewBeforeSpace: 'terminal', + previousViewBeforeSkills: 'terminal', + pendingSkillShareId: null, + pendingSkillsSharedView: false, + previousViewBeforeMobile: 'terminal', + previousViewBeforeArtifacts: 'terminal', + setActiveView: (view) => set({ activeView: view }), + taskPageData: {}, + taskResumeState: undefined, + taskListPosition: null, + githubTaskDrawerWorkItem: null, + newWorkspaceDraft: null, + openTaskPage: (data = {}, options = {}) => { + if (options.recordTasksInteraction !== false) { + const wasTasksPreviouslyInteracted = hasFeatureInteraction( + get().featureInteractions, + 'tasks' + ) + set((state) => ({ + contextualTourNavigationInteractionSnapshot: { + ...state.contextualTourNavigationInteractionSnapshot, + tasks: wasTasksPreviouslyInteracted + } + })) + get().recordFeatureInteraction?.('tasks') + } + if (data.openGitHubWorkItem) { + get().recordFeatureInteraction?.('github-tasks') + } + if (data.openGitLabWorkItem) { + get().recordFeatureInteraction?.('gitlab-tasks') + } + if (data.openLinearIssue) { + get().recordFeatureInteraction?.('linear-tasks') + } + if (data.openJiraIssue) { + get().recordFeatureInteraction?.('jira-tasks') + } + // Why: record a Tasks visit in shared back/forward history; all task-source variants collapse to one deduped 'tasks' entry. + const detailEntry = data.openGitHubWorkItem + ? ({ + kind: 'task-detail', + source: 'github', + workItem: data.openGitHubWorkItem, + sourceContext: data.openGitHubSourceContext, + initialTab: data.openGitHubInitialTab + } as const) + : data.openGitLabWorkItem + ? ({ + kind: 'task-detail', + source: 'gitlab', + workItem: data.openGitLabWorkItem, + sourceContext: data.openGitLabSourceContext + } as const) + : data.openLinearIssue + ? ({ + kind: 'task-detail', + source: 'linear', + issue: data.openLinearIssue, + sourceContext: data.openLinearSourceContext + } as const) + : data.openJiraIssue + ? ({ + kind: 'task-detail', + source: 'jira', + issue: data.openJiraIssue, + sourceContext: data.openJiraSourceContext + } as const) + : null + const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex] + const currentIsTaskStack = + currentEntry === 'tasks' || + (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') + if (!detailEntry || !currentIsTaskStack) { + get().recordViewVisit('tasks') + } + if (detailEntry) { + get().recordViewVisit(detailEntry) + } + set((state) => ({ + activeView: 'tasks', + previousViewBeforeTasks: + state.activeView === 'tasks' ? state.previousViewBeforeTasks : state.activeView, + taskPageData: data + })) + // Why: prefetch the work-item list during first render so the page's effect hits a warm/in-flight SWR cache (~300–800ms win). + const state = get() + const preferredVisibleTaskProviders = normalizeVisibleTaskProviders( + state.settings?.visibleTaskProviders + ) + const visibleTaskProviders = restoreAvailableDefaultTaskProvider( + preferredVisibleTaskProviders, + { + gitlabInstalled: state.preflightStatus?.glab?.installed === true, + linearConnected: state.linearStatus?.connected === true + }, + state.settings?.defaultTaskSource + ) + const resolvedSource = resolveVisibleTaskProvider( + data.taskSource ?? state.settings?.defaultTaskSource, + visibleTaskProviders + ) + const resolvedMode = state.taskResumeState?.githubMode ?? 'items' + if (resolvedSource === 'github' && resolvedMode === 'items') { + const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo) && repo.path) + const selectedRepos = (() => { + const preferred = data.preselectedRepoId + if (preferred) { + const repo = eligibleRepos.find((r) => r.id === preferred) + return repo ? [repo] : [] + } + const persisted = state.settings?.defaultRepoSelection + if (Array.isArray(persisted)) { + const selected = eligibleRepos.filter((repo) => persisted.includes(repo.id)) + if (selected.length > 0) { + return selected + } + } + return eligibleRepos + })() + + const resume = state.taskResumeState + const defaultPreset = state.settings?.defaultTaskViewPreset ?? 'all' + // Why: must match the query TaskPage's resume effect mounts with, else the warm cache key misses and prefetch is wasted. + const query = + resume?.githubItemsPreset === null + ? (resume.githubItemsQuery ?? '').trim() + : presetToQuery(resume?.githubItemsPreset ?? defaultPreset) + for (const repo of selectedRepos) { + state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query, { + sourceContext: + data.openGitHubSourceContext?.provider === 'github' && + data.openGitHubSourceContext.repoId === repo.id + ? data.openGitHubSourceContext + : null + }) + } + } + if (resolvedSource === 'linear' && typeof state.prefetchLinearIssues === 'function') { + const resume = state.taskResumeState + const query = (resume?.linearQuery ?? '').trim() + const sourceContext = + data.openLinearSourceContext?.provider === 'linear' ? data.openLinearSourceContext : null + if (query) { + state.prefetchLinearIssues( + { kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }, + { sourceContext } + ) + } else { + // Why: TaskPage no longer exposes Linear preset filters; keep prefetch aligned with the default unsearched issue list. + state.prefetchLinearIssues( + { + kind: 'list', + filter: 'all', + limit: LINEAR_TASK_PREFETCH_LIMIT + }, + { sourceContext } + ) + } + } + }, + setTaskResumeState: (updates) => + set((s) => { + const next = { ...s.taskResumeState, ...updates } + window.api.ui.set({ taskResumeState: next }).catch(console.error) + return { taskResumeState: next } + }), + setTaskListPosition: (taskListPosition) => set({ taskListPosition }), + setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }), + closeTaskPage: () => + set((state) => { + // Why: if parked on a 'tasks' entry, rewind the history index so Back/Forward aren't no-ops; keep 0 if it's the only entry. + const currentEntry = state.worktreeNavHistory[state.worktreeNavHistoryIndex] + let nextHistoryIndex = state.worktreeNavHistoryIndex + if ( + currentEntry === 'tasks' || + (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') + ) { + const prev = findPrevLiveNonTaskStackHistoryIndex(state) + if (prev !== null) { + nextHistoryIndex = prev + } else if (typeof currentEntry === 'object' && state.worktreeNavHistory[0] === 'tasks') { + nextHistoryIndex = 0 + } + } + return { + activeView: state.previousViewBeforeTasks, + taskPageData: {}, + githubTaskDrawerWorkItem: null, + worktreeNavHistoryIndex: nextHistoryIndex + } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts new file mode 100644 index 00000000000..9f26a409260 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts @@ -0,0 +1,239 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { getContextualTour } from '../../../../../shared/contextual-tours' +import { + getContextualTourRequestDecision, + hasContextualTourTarget, + getNextVisibleContextualTourStepIndex, + getPreviousVisibleContextualTourStepIndex +} from '../../../components/contextual-tours/contextual-tour-gate' +import { hasFeatureInteraction } from '../../../../../shared/feature-interactions' + +export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial { + return { + contextualToursSeenIds: [], + contextualToursAutoEligible: null, + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + contextualTourNavigationInteractionSnapshot: {}, + activeContextualTourSuppressed: false, + contextualTourShownThisSession: false, + contextualToursOnboardingVisible: false, + contextualToursBlockingSurfaceVisible: false, + lastCompletedContextualTourId: null, + setContextualToursAutoEligible: (eligible) => + set((s) => { + if (s.contextualToursAutoEligible === eligible) { + return s + } + if (typeof window !== 'undefined') { + window.api.ui.set({ contextualToursAutoEligible: eligible }).catch(console.error) + } + return { contextualToursAutoEligible: eligible } + }), + setContextualToursOnboardingVisible: (visible) => + set((s) => + s.contextualToursOnboardingVisible === visible + ? s + : { contextualToursOnboardingVisible: visible } + ), + setContextualToursBlockingSurfaceVisible: (visible) => + set((s) => + s.contextualToursBlockingSurfaceVisible === visible + ? s + : { contextualToursBlockingSurfaceVisible: visible } + ), + requestContextualTour: (id, source, wasFeaturePreviouslyInteracted, options) => + set((s) => { + const tour = getContextualTour(id) + const decision = getContextualTourRequestDecision({ + tour, + persistedUIReady: s.persistedUIReady, + autoEligible: options?.force === true || s.contextualToursAutoEligible === true, + onboardingVisible: s.contextualToursOnboardingVisible, + seenIds: options?.force === true ? [] : s.contextualToursSeenIds, + sessionConsumed: options?.force === true ? false : s.contextualTourShownThisSession, + activeTourId: s.activeContextualTourId, + activeModal: s.activeModal, + blockingSurfaceVisible: s.contextualToursBlockingSurfaceVisible, + targetExists: hasContextualTourTarget + }) + if (decision.kind !== 'start') { + if (s.contextualTourNavigationInteractionSnapshot[id] === undefined) { + return s + } + const { [id]: _consumed, ...remainingNavigationSnapshot } = + s.contextualTourNavigationInteractionSnapshot + void _consumed + return { contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot } + } + const navigationSnapshot = s.contextualTourNavigationInteractionSnapshot[id] + const { [id]: _consumed, ...remainingNavigationSnapshot } = + s.contextualTourNavigationInteractionSnapshot + void _consumed + return { + activeContextualTourId: id, + activeContextualTourStepIndex: decision.stepIndex, + activeContextualTourSource: source, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: + wasFeaturePreviouslyInteracted ?? + navigationSnapshot ?? + hasFeatureInteraction(s.featureInteractions, id), + contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot, + activeContextualTourSuppressed: false, + contextualTourShownThisSession: true, + lastCompletedContextualTourId: null + } + }), + suppressContextualTour: (id, source) => + set((s) => { + if ( + s.activeContextualTourId !== id || + s.activeContextualTourSource !== source || + s.activeContextualTourSourceDetached + ) { + return s + } + return s.activeContextualTourSuppressed ? s : { activeContextualTourSuppressed: true } + }), + detachContextualTourSource: (id, source) => + set((s) => { + if (s.activeContextualTourId !== id || s.activeContextualTourSource !== source) { + return s + } + return s.activeContextualTourSourceDetached + ? s + : { activeContextualTourSourceDetached: true } + }), + advanceContextualTour: () => + set((s) => { + if (!s.activeContextualTourId) { + return s + } + const tour = getContextualTour(s.activeContextualTourId) + const nextStepIndex = getNextVisibleContextualTourStepIndex({ + tour, + currentStepIndex: s.activeContextualTourStepIndex, + targetExists: hasContextualTourTarget + }) + if (nextStepIndex !== null) { + return { activeContextualTourStepIndex: nextStepIndex } + } + // Why: browser step 3's target lives in a closed menu until that step is active. + if ( + s.activeContextualTourId === 'browser' && + s.activeContextualTourStepIndex + 1 < tour.steps.length + ) { + return { activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 } + } + return s + }), + regressContextualTour: () => + set((s) => { + if (!s.activeContextualTourId) { + return s + } + const previousStepIndex = getPreviousVisibleContextualTourStepIndex({ + tour: getContextualTour(s.activeContextualTourId), + currentStepIndex: s.activeContextualTourStepIndex, + targetExists: hasContextualTourTarget + }) + if (previousStepIndex === null) { + return s + } + return { activeContextualTourStepIndex: previousStepIndex } + }), + dismissContextualTour: (id) => { + const activeTourId = get().activeContextualTourId + if (id && activeTourId !== id) { + return + } + const tourId = id ?? activeTourId + if (tourId) { + get().markContextualToursSeen([tourId]) + } + set((s) => { + if (id && s.activeContextualTourId !== id) { + return s + } + return { + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + activeContextualTourSuppressed: false, + lastCompletedContextualTourId: null + } + }) + }, + completeContextualTour: (id) => { + const activeTourId = get().activeContextualTourId + if (id && activeTourId !== id) { + return + } + const tourId = id ?? activeTourId + if (tourId) { + get().markContextualToursSeen([tourId]) + } + set((s) => { + if (id && s.activeContextualTourId !== id) { + return s + } + return { + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + activeContextualTourSuppressed: false, + lastCompletedContextualTourId: tourId ?? null + } + }) + }, + cancelContextualTour: (id) => + set((s) => { + const activeTourId = s.activeContextualTourId + const tourId = id ?? activeTourId + if (!tourId || (id && activeTourId !== id)) { + return s + } + const alreadyShown = s.contextualToursSeenIds.includes(tourId) + return { + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + activeContextualTourSuppressed: false, + lastCompletedContextualTourId: null, + contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false + } + }), + markContextualToursSeen: (ids) => + set((s) => { + if (ids.length === 0) { + return s + } + const current = new Set(s.contextualToursSeenIds) + let changed = false + for (const id of ids) { + if (!current.has(id)) { + current.add(id) + changed = true + } + } + if (!changed) { + return s + } + const next = [...current] + if (typeof window !== 'undefined') { + window.api.ui.set({ contextualToursSeenIds: next }).catch(console.error) + } + return { contextualToursSeenIds: next } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts new file mode 100644 index 00000000000..7d99992b9a4 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts @@ -0,0 +1,38 @@ +import type { AppState } from '../../types' +import type { FeatureInteractionId } from '../../../../../shared/feature-interactions' +import { getContextualTour } from '../../../../../shared/contextual-tours' +import { + getNextVisibleContextualTourStepIndex, + hasContextualTourTarget +} from '../../../components/contextual-tours/contextual-tour-gate' + +export function getContextualTourProgressionForFeatureInteraction( + state: AppState, + id: FeatureInteractionId +): 'advance' | 'complete' | 'reveal-sidebar-and-advance' | null { + if (!state.activeContextualTourId) { + return null + } + const tour = getContextualTour(state.activeContextualTourId) + const step = tour.steps[state.activeContextualTourStepIndex] + if (step?.advanceOnFeatureInteraction !== id) { + return null + } + const nextStepIndex = getNextVisibleContextualTourStepIndex({ + tour, + currentStepIndex: state.activeContextualTourStepIndex, + targetExists: hasContextualTourTarget + }) + if (nextStepIndex !== null) { + return 'advance' + } + if ( + state.activeContextualTourId === 'workspace-agent-sessions' && + state.activeContextualTourStepIndex === 0 && + id === 'terminal-pane-split' && + !state.sidebarOpen + ) { + return 'reveal-sidebar-and-advance' + } + return 'complete' +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts new file mode 100644 index 00000000000..8111c8f8f1f --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -0,0 +1,143 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { getSetupScriptPromptDismissalKey } from '../../../lib/setup-script-prompt' + +export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + trustedOrcaHooks: {}, + markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => + set((s) => { + const existing = s.trustedOrcaHooks[repoId] + const currentEntry = existing?.[kind] + if (currentEntry?.contentHash === contentHash) { + return s + } + const nextRepo = { + ...existing, + [kind]: { contentHash, approvedAt: Date.now() } + } + const next = { ...s.trustedOrcaHooks, [repoId]: nextRepo } + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + markOrcaHookRepoAlwaysTrusted: (repoId) => + set((s) => { + const existing = s.trustedOrcaHooks[repoId] + if (existing?.all) { + return s + } + const next = { + ...s.trustedOrcaHooks, + [repoId]: { + ...existing, + all: { approvedAt: Date.now() } + } + } + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + clearOrcaHookTrustForRepo: (repoId) => + set((s) => { + if (!(repoId in s.trustedOrcaHooks)) { + return s + } + const next = { ...s.trustedOrcaHooks } + delete next[repoId] + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + setupScriptPromptDismissedRepoIds: [], + dismissSetupScriptPrompt: (repoHostIdentity) => + set((s) => { + const dismissalKey = getSetupScriptPromptDismissalKey(repoHostIdentity) + if (!repoHostIdentity || s.setupScriptPromptDismissedRepoIds.includes(dismissalKey)) { + return s + } + const next = [...s.setupScriptPromptDismissedRepoIds, dismissalKey] + window.api.ui.set({ setupScriptPromptDismissedRepoIds: next }).catch(console.error) + return { setupScriptPromptDismissedRepoIds: next } + }), + setupGuideSidebarDismissed: false, + setSetupGuideSidebarDismissed: (dismissed) => + set((s) => { + if (s.setupGuideSidebarDismissed === dismissed) { + return s + } + window.api.ui.set({ setupGuideSidebarDismissed: dismissed }).catch(console.error) + return { setupGuideSidebarDismissed: dismissed } + }), + setupGuideBrowserMilestoneMigrated: true, + setupGuideBrowserMilestoneLegacyComplete: false, + markSetupGuideBrowserMilestoneMigrated: (legacyComplete) => + set((s) => { + if ( + s.setupGuideBrowserMilestoneMigrated && + s.setupGuideBrowserMilestoneLegacyComplete === legacyComplete + ) { + return s + } + const updates = { + setupGuideBrowserMilestoneMigrated: true, + setupGuideBrowserMilestoneLegacyComplete: legacyComplete + } + window.api.ui.set(updates).catch(console.error) + return updates + }), + browserImportHintHidden: false, + setBrowserImportHintHidden: (hidden) => + set((s) => { + if (s.browserImportHintHidden === hidden) { + return s + } + window.api.ui.set({ browserImportHintHidden: hidden }).catch(console.error) + return { browserImportHintHidden: hidden } + }), + mobileEmulatorTabIntroDismissed: false, + dismissMobileEmulatorTabIntro: () => + set((s) => { + if (s.mobileEmulatorTabIntroDismissed) { + return s + } + window.api.ui.set({ mobileEmulatorTabIntroDismissed: true }).catch(console.error) + return { mobileEmulatorTabIntroDismissed: true } + }), + mobileEmulatorAgentSetupDismissed: false, + dismissMobileEmulatorAgentSetup: () => + set((s) => { + if (s.mobileEmulatorAgentSetupDismissed) { + return s + } + window.api.ui.set({ mobileEmulatorAgentSetupDismissed: true }).catch(console.error) + return { mobileEmulatorAgentSetupDismissed: true } + }), + projectOrderManualDefaultNoticeDismissed: true, + dismissProjectOrderManualDefaultNotice: () => + set((s) => { + if (s.projectOrderManualDefaultNoticeDismissed) { + return s + } + window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) + return { projectOrderManualDefaultNoticeDismissed: true } + }), + // Why: default true so pre-hydration / new sessions never flash the change notice before persistence resolves. + usagePercentageDisplayChangeNoticeDismissed: true, + dismissUsagePercentageDisplayChangeNotice: () => + set((s) => { + if (s.usagePercentageDisplayChangeNoticeDismissed) { + return s + } + window.api.ui + .set({ usagePercentageDisplayChangeNoticeDismissed: true }) + .catch(console.error) + return { usagePercentageDisplayChangeNoticeDismissed: true } + }), + usageEmptyStateDismissed: false, + dismissUsageEmptyState: () => + set((s) => { + if (s.usageEmptyStateDismissed) { + return s + } + window.api.ui.set({ usageEmptyStateDismissed: true }).catch(console.error) + return { usageEmptyStateDismissed: true } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts new file mode 100644 index 00000000000..e4beadf4c6c --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -0,0 +1,110 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { + DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, + normalizeBrowserPageZoomLevel +} from '../../../../../shared/browser-page-zoom' +import { normalizeKagiSessionLink } from '../../../../../shared/browser-url' +export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial { + return { + updateStatus: { state: 'idle' }, + setUpdateStatus: (status) => { + const prevState = get().updateStatus.state + const update: Partial< + Pick< + UISlice, + 'updateStatus' | 'updateChangelog' | 'updateCardCollapsed' | 'updateUserInitiatedCycle' + > + > = { + updateStatus: status + } + if (status.state === 'checking') { + update.updateUserInitiatedCycle = status.userInitiated === true + } else if (status.state === 'idle') { + update.updateUserInitiatedCycle = false + } + if (status.state === 'available') { + // Why: always overwrite (even with null) so a prior version's changelog can't leak into a later simple-mode update. + update.updateChangelog = status.changelog ?? null + } else if ( + status.state === 'idle' || + status.state === 'checking' || + status.state === 'not-available' + ) { + // Why: reset on cycle-boundary states so stale rich content from a previous cycle can't resurface. + update.updateChangelog = null + } + // 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content. + if (status.state !== prevState) { + // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`. + update.updateCardCollapsed = false + } + set(update) + }, + updateChangelog: null, + updateUserInitiatedCycle: false, + dismissedUpdateVersion: null, + clearDismissedUpdateVersion: () => { + set({ dismissedUpdateVersion: null }) + }, + releaseChannelOverride: null, + setReleaseChannelOverride: (channel) => { + void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error) + set({ releaseChannelOverride: channel }) + }, + dismissUpdate: (versionOverride?: string) => + set((s) => { + // Why: the 'error' variant has no version field, so the card passes it via versionOverride. + const dismissedUpdateVersion = + versionOverride ?? ('version' in s.updateStatus ? (s.updateStatus.version ?? null) : null) + const activeNudgeId = + 'activeNudgeId' in s.updateStatus ? (s.updateStatus.activeNudgeId ?? null) : null + // Why: persist dismissal so relaunch doesn't immediately re-show the same card until a newer release. + void window.api.ui.set({ dismissedUpdateVersion }).catch(console.error) + // Why: main can't otherwise tell an offered update was abandoned, which keeps a local-build session pinned and stalls background checks. + void window.api.updater.dismissAvailableUpdate().catch(console.error) + // Why: only consume the nudge campaign for cards from a nudge cycle, not ordinary dismissals. + if (activeNudgeId) { + void window.api.updater.dismissNudge().catch(console.error) + } + return { dismissedUpdateVersion, updateUserInitiatedCycle: false } + }), + updateCardCollapsed: false, + setUpdateCardCollapsed: (collapsed) => set({ updateCardCollapsed: collapsed }), + updateReassuranceSeen: false, + markUpdateReassuranceSeen: () => { + void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) + set({ updateReassuranceSeen: true }) + }, + osc52ClipboardDefaultOnNoticePending: false, + clearOsc52ClipboardDefaultOnNotice: () => { + // Why clear locally first: a failed persist must not re-toast this session. It will + // re-arm on the next launch, which is the safe direction for a one-shot notice. + set({ osc52ClipboardDefaultOnNoticePending: false }) + void window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }).catch(console.error) + }, + isFullScreen: false, + setIsFullScreen: (v) => set({ isFullScreen: v }), + browserDefaultUrl: null, + setBrowserDefaultUrl: (url) => { + void window.api.ui.set({ browserDefaultUrl: url }).catch(console.error) + set({ browserDefaultUrl: url }) + }, + browserDefaultSearchEngine: null, + setBrowserDefaultSearchEngine: (engine) => { + void window.api.ui.set({ browserDefaultSearchEngine: engine }).catch(console.error) + set({ browserDefaultSearchEngine: engine }) + }, + browserDefaultZoomLevel: DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, + setBrowserDefaultZoomLevel: (level) => { + const normalized = normalizeBrowserPageZoomLevel(level) + void window.api.ui.set({ browserDefaultZoomLevel: normalized }).catch(console.error) + set({ browserDefaultZoomLevel: normalized }) + }, + browserKagiSessionLink: null, + setBrowserKagiSessionLink: (link) => { + const normalized = link ? normalizeKagiSessionLink(link) : null + void window.api.ui.set({ browserKagiSessionLink: normalized }).catch(console.error) + set({ browserKagiSessionLink: normalized }) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts new file mode 100644 index 00000000000..432572b799f --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts @@ -0,0 +1,111 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { rewindHistoryIndexPastView } from '../worktree-nav-history' + +export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial { + return { + openActivityPage: () => { + if (get().settings?.experimentalActivity !== true) { + return + } + set((state) => ({ + activeView: 'activity', + previousViewBeforeActivity: + state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView + })) + }, + closeActivityPage: () => + set((state) => ({ + activeView: state.previousViewBeforeActivity + })), + selectedAutomationId: null, + setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), + pendingAutomationRunNavigation: null, + setPendingAutomationRunNavigation: (navigation) => + set({ pendingAutomationRunNavigation: navigation }), + openAutomationsPage: () => { + get().recordViewVisit('automations') + set((state) => ({ + activeView: 'automations', + previousViewBeforeAutomations: + state.activeView === 'automations' + ? state.previousViewBeforeAutomations + : state.activeView + })) + }, + closeAutomationsPage: () => + set((state) => ({ + activeView: state.previousViewBeforeAutomations, + worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'automations') + })), + openSpacePage: () => { + get().recordFeatureInteraction?.('workspace-cleanup') + set((state) => ({ + activeView: 'space', + previousViewBeforeSpace: + state.activeView === 'space' ? state.previousViewBeforeSpace : state.activeView + })) + }, + closeSpacePage: () => + set((state) => ({ + activeView: state.previousViewBeforeSpace + })), + openSkillsPage: () => { + get().recordViewVisit('skills') + set((state) => ({ + activeView: 'skills', + previousViewBeforeSkills: + state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView + })) + }, + closeSkillsPage: () => + set((state) => ({ + activeView: state.previousViewBeforeSkills, + worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'skills') + })), + openSkillShare: (shareId) => { + get().recordViewVisit('skills') + set((state) => ({ + activeView: 'skills', + previousViewBeforeSkills: + state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, + pendingSkillShareId: shareId + })) + }, + clearPendingSkillShare: () => set({ pendingSkillShareId: null }), + openSkillsSharedLinks: () => { + get().recordViewVisit('skills') + set((state) => ({ + activeView: 'skills', + previousViewBeforeSkills: + state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, + pendingSkillsSharedView: true + })) + }, + clearPendingSkillsSharedView: () => set({ pendingSkillsSharedView: false }), + openArtifactsPage: () => { + get().recordViewVisit('artifacts') + set((state) => ({ + activeView: 'artifacts', + previousViewBeforeArtifacts: + state.activeView === 'artifacts' ? state.previousViewBeforeArtifacts : state.activeView + })) + }, + closeArtifactsPage: () => + set((state) => ({ + activeView: state.previousViewBeforeArtifacts, + worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'artifacts') + })), + openMobilePage: () => + set((state) => ({ + activeView: 'mobile', + previousViewBeforeMobile: + state.activeView === 'mobile' ? state.previousViewBeforeMobile : state.activeView + })), + closeMobilePage: () => + set((state) => ({ + activeView: state.previousViewBeforeMobile + })), + setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), + clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }) + } +} diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 17f48be0159..edd43350aca 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -1,2432 +1,11 @@ -/* eslint-disable max-lines -- Why: keep the shortcut registry, parser, formatter, and conflict detector in one shared module so main/renderer/browser/Settings can't drift. */ -import type { TuiAgent } from './tui-agent' -import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' - -export type KeybindingScope = - | 'global' - | 'tabs' - | 'terminal' - | 'browser' - | 'editor' - | 'fileExplorer' - | 'composer' - | 'settings' - -export type KeybindingContext = 'app' | 'terminal' | 'browser' - -export type KeybindingPlatform = 'darwin' | 'linux' | 'win32' - -export type TerminalShortcutPolicy = 'orca-first' | 'terminal-first' - -export type KeybindingMatchOptions = { - context?: KeybindingContext - terminalShortcutPolicy?: TerminalShortcutPolicy -} - -export type AgentTabActionId = `tab.newAgent.${TuiAgent}` -export type PluginKeybindingActionId = `plugin:${string}` - -export type KeybindingActionId = - | 'worktree.quickOpen' - | 'worktree.palette' - | 'worktree.navigateUp' - | 'worktree.navigateDown' - | 'app.settings' - | 'app.forceReload' - | 'workspace.create' - | 'workspace.rename' - | 'workspace.delete' - | 'workspace.openBoard' - | 'workspace.selectByIndex' - | 'voice.dictation' - | 'view.tasks' - | 'dashboard.toggle' - | 'sidebar.left.toggle' - | 'sidebar.right.toggle' - | 'sidebar.explorer.toggle' - | 'sidebar.search.toggle' - | 'sidebar.sourceControl.toggle' - | 'sidebar.checks.toggle' - | 'sidebar.ports.toggle' - | 'sidebar.sleepingWorkspaces.toggle' - | 'sidebar.focusWorktreeList' - | 'floatingTerminal.toggle' - | 'floatingWorkspace.maximize' - | 'floatingWorkspace.minimize' - | 'zoom.in' - | 'zoom.out' - | 'zoom.reset' - | 'worktree.history.back' - | 'worktree.history.forward' - | 'tab.newTerminal' - | 'tab.newAgent' - | AgentTabActionId - | 'tab.newBrowser' - | 'tab.newSimulator' - | 'tab.newMarkdown' - | 'tab.openMarkdown' - | 'tab.close' - | 'tab.closeAll' - | 'tab.rename' - | 'tab.reopenClosed' - | 'tab.nextSameType' - | 'tab.previousSameType' - | 'tab.nextAllTypes' - | 'tab.previousAllTypes' - | 'tab.previousRecent' - | 'tab.nextTerminal' - | 'tab.previousTerminal' - | 'tab.selectByIndex' - | 'tab.openQuickCommandsMenu' - | 'browser.find' - | 'browser.back' - | 'browser.forward' - | 'browser.reload' - | 'browser.hardReload' - | 'browser.focusAddressBar' - | 'browser.grabElement' - | 'editor.find' - | 'editor.replace' - | 'editor.save' - | 'editor.markdownPreview' - | 'editor.toggleWordWrap' - | 'editor.copyContext' - | 'editor.previousChange' - | 'editor.nextChange' - | 'editor.addReviewNote' - | 'sourceControl.sendReviewNotes' - | 'fileExplorer.undo' - | 'fileExplorer.redo' - | 'fileExplorer.copyPath' - | 'fileExplorer.copyRelativePath' - | 'fileExplorer.delete' - | 'settings.search' - | 'terminal.copySelection' - | 'terminal.selectAll' - | 'terminal.paste' - | 'terminal.search' - | 'terminal.clear' - | 'terminal.focusNextPane' - | 'terminal.focusPreviousPane' - | 'terminal.equalizePaneSizes' - | 'terminal.expandPane' - | 'terminal.setTitle' - | 'terminal.clearPaneTitle' - | 'terminal.closePane' - | 'terminal.splitRight' - | 'terminal.splitDown' - | 'terminal.switchInputSource' - | PluginKeybindingActionId - -export type KeybindingOverrides = Partial> - -export type KeybindingFileDiagnostic = { - severity: 'warning' | 'error' - message: string - actionId?: string - section?: string -} - -export type KeybindingFileSnapshot = { - path: string - platform: KeybindingPlatform - exists: boolean - overrides: KeybindingOverrides - commonOverrides: KeybindingOverrides - platformOverrides: Partial> - diagnostics: KeybindingFileDiagnostic[] -} - -type PlatformBindings = { - darwin: readonly string[] - linux: readonly string[] - win32: readonly string[] -} - -export type KeybindingDefinition = { - id: KeybindingActionId - title: string - group: string - scope: KeybindingScope - searchKeywords: readonly string[] - defaultBindings: PlatformBindings - allowInTerminal?: boolean - allowBareKeybindings?: boolean - allowShiftOnlyKeybindings?: boolean - conflictGroup?: string -} - -export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift' -export type PhysicalModifierToken = Exclude - -export type KeybindingInput = { - key?: string - code?: string - alt?: boolean - meta?: boolean - control?: boolean - shift?: boolean - altKey?: boolean - metaKey?: boolean - ctrlKey?: boolean - shiftKey?: boolean - // Set only by the double-tap detector; always a physical token (never 'Mod'). - doubleTapModifier?: PhysicalModifierToken -} - -type ParsedKeybinding = { - mod: boolean - meta: boolean - control: boolean - alt: boolean - shift: boolean - key: string - doubleTapModifier?: ModifierToken -} - -type NormalizeKeybindingOptions = { - allowBareKeybindings?: boolean - allowShiftOnlyKeybindings?: boolean -} - -export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } - -export type KeybindingConflict = { - binding: string - actionIds: KeybindingActionId[] -} - -export type FindKeybindingConflictOptions = { - ignoredActionIds?: Iterable - relevantActionIds?: Iterable -} - -export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ - { - id: 'worktree.quickOpen', - title: 'Go to File', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'file', 'quick open'], - defaultBindings: platformBindings(['Mod+P']) - }, - { - id: 'app.settings', - title: 'Open Settings', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'settings', 'preferences'], - defaultBindings: platformBindings(['Mod+Comma']), - conflictGroup: 'menu' - }, - { - id: 'app.forceReload', - title: 'Force Reload', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'reload', 'refresh', 'force'], - defaultBindings: platformBindings(['Mod+Shift+R']), - conflictGroup: 'menu' - }, - { - id: 'worktree.palette', - title: 'Switch worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], - defaultBindings: { - darwin: ['Mod+J'], - linux: ['Mod+Shift+J'], - win32: ['Mod+Shift+J'] - } - }, - { - id: 'worktree.navigateUp', - title: 'Previous worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'previous', 'up'], - defaultBindings: platformBindings(['Mod+Shift+ArrowUp']) - }, - { - id: 'worktree.navigateDown', - title: 'Next worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'next', 'down'], - defaultBindings: platformBindings(['Mod+Shift+ArrowDown']) - }, - { - id: 'workspace.create', - title: 'Create worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'create', 'new workspace'], - defaultBindings: platformBindings(['Mod+N', 'Mod+Shift+N']) - }, - { - id: 'workspace.rename', - title: 'Rename worktree', - group: 'Global', - scope: 'global', - conflictGroup: 'workspace-shell', - searchKeywords: ['shortcut', 'global', 'worktree', 'rename', 'workspace', 'title'], - // Why: macOS only — Windows/Linux Ctrl+Alt+R has no safe default (Ctrl+R reverse-search, Ctrl+Shift+R reload are taken). - defaultBindings: { - darwin: ['Mod+Alt+R'], - linux: [], - win32: [] - } - }, - { - id: 'workspace.delete', - title: 'Delete Workspace', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'workspace', - 'current workspace', - 'worktree', - 'delete', - 'remove', - 'trash' - ], - // Why: Backspace avoids the terminal pane's D-based split shortcuts on every platform. - defaultBindings: platformBindings(['Mod+Shift+Backspace']), - allowInTerminal: true - }, - { - id: 'workspace.openBoard', - title: 'Toggle Workspace Board', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'workspace', - 'board', - 'kanban', - 'worktree', - 'toggle', - 'open', - 'close' - ], - // Why: configurable but unbound by default, to not take a global chord from terminal/browser/editor users. - defaultBindings: platformBindings([]), - allowInTerminal: true - }, - { - id: 'dashboard.toggle', - title: 'Toggle Agent Dashboard', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'agent', - 'agents', - 'dashboard', - 'kanban', - 'board', - 'toggle', - 'open', - 'close' - ], - // Why: configurable but unbound by default, matching workspace.openBoard — an - // experimental surface must not claim a global chord from terminal users. - defaultBindings: platformBindings([]), - allowInTerminal: true - }, - { - id: 'workspace.selectByIndex', - title: 'Select Workspace 1–9', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'workspace', - 'worktree', - 'select', - 'switch', - 'number', - 'digit', - '1-9', - 'index' - ], - // Why: one remappable row covers the whole 1-9 range (stored chord is a representative; any of 1-9 fires it). - defaultBindings: platformBindings(['Mod+1']) - }, - { - id: 'voice.dictation', - title: 'Dictation', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'], - defaultBindings: platformBindings(['Mod+E']) - }, - { - id: 'view.tasks', - title: 'Open Tasks', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'tasks', 'github issues', 'linear'], - defaultBindings: platformBindings([]) - }, - { - id: 'sidebar.left.toggle', - title: 'Toggle Sidebar', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'left'], - defaultBindings: platformBindings(['Mod+B']) - }, - { - id: 'sidebar.right.toggle', - title: 'Toggle Right Sidebar', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'right'], - defaultBindings: platformBindings(['Mod+L']) - }, - { - id: 'sidebar.explorer.toggle', - title: 'Show Explorer', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'explorer', 'files'], - defaultBindings: platformBindings(['Mod+Shift+E']) - }, - { - id: 'sidebar.search.toggle', - title: 'Show Search', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'search'], - defaultBindings: platformBindings(['Mod+Shift+F']) - }, - { - id: 'sidebar.sourceControl.toggle', - title: 'Show Source Control', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'source control', 'git'], - defaultBindings: platformBindings(['Mod+Shift+G']) - }, - { - id: 'sidebar.checks.toggle', - title: 'Show Checks', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'checks', 'ci'], - defaultBindings: platformBindings([]) - }, - { - id: 'sidebar.ports.toggle', - title: 'Show Ports', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'ports'], - defaultBindings: { - darwin: ['Mod+Shift+I'], - linux: [], - win32: [] - } - }, - { - id: 'sidebar.sleepingWorkspaces.toggle', - title: 'Toggle Sleeping Workspaces', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'sidebar', - 'sleeping', - 'asleep', - 'workspaces', - 'worktree', - 'filter', - 'show', - 'hide' - ], - // Why: ship unbound (issue #5209 asks users to assign it), avoiding a claimed cross-platform chord. - defaultBindings: platformBindings([]) - }, - { - id: 'sidebar.focusWorktreeList', - title: 'Focus worktree list', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'worktree', 'focus'], - // Why: keep zoom.reset on the browser-standard Mod+0; this chord was unreachable while it shared that default (#8584). - defaultBindings: platformBindings(['Mod+Shift+0']) - }, - { - id: 'floatingTerminal.toggle', - title: 'Toggle Floating Terminal', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'floating terminal', 'terminal'], - defaultBindings: platformBindings(['Mod+Alt+A']), - allowInTerminal: true - }, - { - id: 'floatingWorkspace.maximize', - title: 'Maximize Floating Workspace Panel', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'floating', - 'workspace', - 'panel', - 'floating workspace', - 'workspace panel', - 'maximize', - 'expand' - ], - // Why: pairs with floatingTerminal.toggle (Cmd+Opt+A) so maximize stays one-handed; macOS-only, Linux/Windows unbound. - defaultBindings: { - darwin: ['Mod+Alt+Shift+A'], - linux: [], - win32: [] - }, - allowInTerminal: true - }, - { - id: 'floatingWorkspace.minimize', - title: 'Minimize Floating Workspace Panel', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'floating', - 'workspace', - 'panel', - 'floating workspace', - 'workspace panel', - 'minimize', - 'hide' - ], - // Why: unbound everywhere since floatingTerminal.toggle owns show/hide; this exists only for an explicit user-bound "hide panel" shortcut. - defaultBindings: { - darwin: [], - linux: [], - win32: [] - }, - allowInTerminal: true - }, - { - id: 'zoom.in', - title: 'Zoom In', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'zoom', 'in', 'scale'], - defaultBindings: platformBindings(['Mod+Equal', 'Mod+Shift+Plus', 'Mod+NumpadAdd']) - }, - { - id: 'zoom.out', - title: 'Zoom Out', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'zoom', 'out', 'scale'], - defaultBindings: platformBindings(['Mod+Minus', 'Mod+NumpadSubtract']) - }, - { - id: 'zoom.reset', - title: 'Reset Size', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], - defaultBindings: platformBindings(['Mod+0']) - }, - { - id: 'worktree.history.back', - title: 'Worktree History Back', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'worktree', 'history', 'back'], - defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']), - allowInTerminal: true - }, - { - id: 'worktree.history.forward', - title: 'Worktree History Forward', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'worktree', 'history', 'forward'], - defaultBindings: platformBindings(['Mod+Alt+ArrowRight']), - allowInTerminal: true - }, - { - id: 'tab.newTerminal', - title: 'New terminal tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'terminal', 'new'], - defaultBindings: platformBindings(['Mod+T']) - }, - { - id: 'tab.newAgent', - title: 'New agent tab (default agent)', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'agent', 'new', 'default', 'launch'], - // Why: macOS only — Windows Ctrl+Alt is AltGr and Linux Ctrl+Alt+T is the desktop "open terminal", so no safe default there. - defaultBindings: { - darwin: ['Mod+Alt+T'], - linux: [], - win32: [] - } - }, - { - id: 'tab.newBrowser', - title: 'New browser tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'browser', 'new'], - defaultBindings: platformBindings(['Mod+Shift+B']) - }, - { - id: 'tab.newSimulator', - title: 'New mobile emulator tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'simulator', 'emulator', 'mobile', 'ios', 'new'], - // Why: keep explorer on Mod+Shift+E (VS Code muscle memory); emulator is macOS-only and less common, so it yields to a free chord (#8533). - defaultBindings: { - darwin: ['Mod+Alt+Shift+E'], - linux: [], - win32: [] - } - }, - { - id: 'tab.newMarkdown', - title: 'New markdown tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'new'], - defaultBindings: platformBindings(['Mod+Shift+M']) - }, - { - id: 'tab.openMarkdown', - title: 'Open markdown tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'open'], - defaultBindings: platformBindings(['Mod+Shift+O']) - }, - { - id: 'tab.close', - title: 'Close active tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'close', 'tab', 'pane'], - defaultBindings: platformBindings(['Mod+W']) - }, - { - id: 'tab.closeAll', - title: 'Close all editor tabs', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'close', 'all', 'tabs', 'files', 'editors'], - defaultBindings: platformBindings(['Mod+Alt+W']) - }, - { - id: 'tab.rename', - title: 'Rename active tab', - group: 'Tabs', - scope: 'tabs', - conflictGroup: 'workspace-shell', - searchKeywords: ['shortcut', 'tab', 'rename', 'title', 'label'], - // Why: macOS only — Cmd+R is free in app/terminal focus; on Windows/Linux Ctrl+R is shell reverse-search, so left unbound. - defaultBindings: { - darwin: ['Mod+R'], - linux: [], - win32: [] - } - }, - { - id: 'tab.reopenClosed', - title: 'Reopen closed tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'reopen', 'restore', 'closed'], - defaultBindings: platformBindings(['Mod+Shift+T']) - }, - { - id: 'tab.nextSameType', - title: 'Next tab (same type)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'], - // Why: the widespread "switch tab" chord (Mod+Shift+Bracket) now drives all-types cycling; same-type moved to Mod+Alt for new installs. - defaultBindings: platformBindings(['Mod+Alt+BracketRight']) - }, - { - id: 'tab.previousSameType', - title: 'Previous tab (same type)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'], - defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) - }, - { - id: 'tab.nextAllTypes', - title: 'Next tab (all types)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'all', 'any'], - defaultBindings: platformBindings(['Mod+Shift+BracketRight']) - }, - { - id: 'tab.previousAllTypes', - title: 'Previous tab (all types)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'all', 'any'], - defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) - }, - { - id: 'tab.previousRecent', - title: 'Previous recent tab', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'recent', 'mru', 'switch', 'last used'], - defaultBindings: platformBindings(['Ctrl+Tab']), - allowInTerminal: true - }, - { - id: 'tab.nextTerminal', - title: 'Next terminal tab', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'terminal', 'next', 'switch'], - defaultBindings: platformBindings(['Ctrl+PageDown']), - allowInTerminal: true - }, - { - id: 'tab.previousTerminal', - title: 'Previous terminal tab', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'terminal', 'previous', 'switch'], - defaultBindings: platformBindings(['Ctrl+PageUp']), - allowInTerminal: true - }, - { - id: 'tab.selectByIndex', - title: 'Select Tab 1–9', - group: 'Tab Navigation', - scope: 'tabs', - // Why: no shared conflictGroup with workspace.selectByIndex so swapping their modifiers isn't a false conflict; safe because resolveWindowShortcutAction checks the workspace range first. - searchKeywords: ['shortcut', 'tab', 'select', 'switch', 'number', 'digit', '1-9', 'index'], - // Why: representative chord for the 1-9 range (see workspace.selectByIndex); each platform avoids the workspace-jump chord (Mod+1-9). - defaultBindings: { - darwin: ['Ctrl+1'], - linux: ['Alt+1'], - win32: ['Alt+1'] - } - }, - { - id: 'tab.openQuickCommandsMenu', - title: 'Toggle Quick Commands menu', - group: 'Quick Commands', - scope: 'tabs', - // Why: this tab-scoped action is also routed through the main-window allowlist, so Settings must warn when it shadows global chords. - conflictGroup: 'global', - searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'], - defaultBindings: platformBindings([]) - }, - { - id: 'browser.find', - title: 'Find in Browser', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'find', 'search'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'browser.back', - title: 'Go Back in Browser', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'history', 'back', 'previous'], - defaultBindings: { - darwin: ['Mod+BracketLeft'], - linux: ['Alt+ArrowLeft'], - win32: ['Alt+ArrowLeft'] - } - }, - { - id: 'browser.forward', - title: 'Go Forward in Browser', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'history', 'forward', 'next'], - defaultBindings: { - darwin: ['Mod+BracketRight'], - linux: ['Alt+ArrowRight'], - win32: ['Alt+ArrowRight'] - } - }, - { - id: 'browser.reload', - title: 'Reload Browser Page', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'reload', 'refresh'], - defaultBindings: platformBindings(['Mod+R']) - }, - { - id: 'browser.hardReload', - title: 'Hard Reload Browser Page', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'reload', 'refresh', 'cache'], - defaultBindings: platformBindings(['Mod+Shift+R']) - }, - { - id: 'browser.focusAddressBar', - title: 'Focus Browser Address Bar', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'address', 'url', 'location'], - defaultBindings: platformBindings(['Mod+L']) - }, - { - id: 'browser.grabElement', - title: 'Grab Page Element', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'grab', 'copy', 'element'], - defaultBindings: platformBindings(['Mod+C']) - }, - { - id: 'editor.find', - title: 'Find in editor', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'find', 'search'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'editor.replace', - title: 'Replace in editor', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'replace', 'find', 'search'], - // Why: match the source editor's native replace shortcut per platform. - defaultBindings: { - darwin: ['Mod+Alt+F'], - linux: ['Mod+H'], - win32: ['Mod+H'] - } - }, - { - id: 'editor.save', - title: 'Save File', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'save'], - defaultBindings: platformBindings(['Mod+S']) - }, - { - id: 'editor.markdownPreview', - title: 'Show Markdown Preview', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], - defaultBindings: platformBindings(['Mod+Shift+V']) - }, - { - id: 'editor.toggleWordWrap', - title: 'Toggle Word Wrap', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'word wrap', 'wrap', 'long lines', 'soft wrap'], - // Why: Alt+Z matches VS Code; bare Alt+letter is not AltGr, so it stays cross-platform (#9974). - defaultBindings: platformBindings(['Alt+Z']) - }, - { - id: 'editor.copyContext', - title: 'Copy Context', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'copy', 'context'], - defaultBindings: platformBindings(['Mod+Alt+C']) - }, - // Why: F7 / Shift+F7 mirror VS Code / JetBrains diff-change nav; function keys are safe bare/Shift, so both opt into allowBareKeybindings. - { - id: 'editor.previousChange', - title: 'Go to Previous Change', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'], - defaultBindings: platformBindings(['Shift+F7']), - allowBareKeybindings: true - }, - { - id: 'editor.nextChange', - title: 'Go to Next Change', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'], - defaultBindings: platformBindings(['F7']), - allowBareKeybindings: true - }, - { - id: 'editor.addReviewNote', - title: 'Add Review Note', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'], - // Why: Ctrl+Alt+letter is AltGr text input on Windows/Linux, so an editor default must not reserve chars like Polish `ń`. - defaultBindings: platformBindings(['Mod+Shift+A']) - }, - { - id: 'sourceControl.sendReviewNotes', - title: 'Send Review Notes to Agent', - group: 'Global', - scope: 'global', - // Why: fires from the global capture handler even while the editor is focused, so Settings must warn on collisions with editor chords (e.g. Add Review Note) too, not just global ones. - conflictGroup: 'editor', - searchKeywords: [ - 'shortcut', - 'source control', - 'diff', - 'notes', - 'send', - 'agent', - 'review', - 'annotate' - ], - // Why: unbound by default so it never collides with existing chords; users opt in via Settings. - defaultBindings: platformBindings([]) - }, - { - id: 'fileExplorer.undo', - title: 'Undo file operation', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'undo'], - defaultBindings: platformBindings(['Mod+Z']) - }, - { - id: 'fileExplorer.redo', - title: 'Redo file operation', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'redo'], - defaultBindings: { - darwin: ['Mod+Shift+Z'], - linux: ['Mod+Shift+Z', 'Ctrl+Y'], - win32: ['Mod+Shift+Z', 'Ctrl+Y'] - } - }, - { - id: 'fileExplorer.copyPath', - title: 'Copy file path', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'copy', 'path'], - defaultBindings: { - darwin: ['Mod+Alt+C'], - linux: ['Alt+Shift+C'], - win32: ['Alt+Shift+C'] - } - }, - { - id: 'fileExplorer.copyRelativePath', - title: 'Copy relative file path', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'copy', 'relative', 'path'], - defaultBindings: platformBindings(['Mod+Alt+Shift+C']) - }, - { - id: 'fileExplorer.delete', - title: 'Delete file', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'delete', 'remove', 'trash'], - defaultBindings: { - darwin: ['Mod+Backspace', 'Delete'], - linux: ['Delete'], - win32: ['Delete'] - }, - allowBareKeybindings: true - }, - { - id: 'settings.search', - title: 'Search Settings', - group: 'Settings', - scope: 'settings', - searchKeywords: ['shortcut', 'settings', 'search', 'find'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'terminal.copySelection', - title: 'Copy terminal selection', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'copy', 'selection'], - defaultBindings: { - darwin: ['Mod+C'], - linux: ['Ctrl+Shift+C', 'Ctrl+C'], - win32: ['Ctrl+Shift+C', 'Ctrl+C'] - } - }, - { - id: 'terminal.selectAll', - title: 'Select all terminal text', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'select', 'all'], - defaultBindings: { - darwin: ['Mod+A'], - linux: ['Ctrl+Shift+A'], - win32: ['Ctrl+Shift+A'] - } - }, - { - id: 'terminal.paste', - title: 'Paste into terminal', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'paste', 'clipboard'], - defaultBindings: { - darwin: ['Mod+V'], - linux: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'], - win32: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'] - } - }, - { - id: 'terminal.search', - title: 'Search active pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'search', 'find'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'terminal.clear', - title: 'Clear active pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'clear'], - defaultBindings: platformBindings(['Mod+K']) - }, - { - id: 'terminal.focusNextPane', - title: 'Focus next pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'focus', 'next'], - defaultBindings: platformBindings(['Mod+BracketRight']) - }, - { - id: 'terminal.focusPreviousPane', - title: 'Focus previous pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'focus', 'previous'], - defaultBindings: platformBindings(['Mod+BracketLeft']) - }, - { - id: 'terminal.equalizePaneSizes', - title: 'Equalize pane sizes', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'split', 'equalize', 'resize', 'balance', 'size'], - defaultBindings: platformBindings([]) - }, - { - id: 'terminal.expandPane', - title: 'Expand / collapse pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'], - defaultBindings: platformBindings(['Mod+Shift+Enter']) - }, - { - id: 'terminal.setTitle', - title: 'Set Title…', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'pane', 'set title', 'title', 'rename'], - defaultBindings: platformBindings([]) - }, - { - id: 'terminal.clearPaneTitle', - title: 'Clear Pane Title', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'pane', 'clear title', 'remove title', 'title'], - defaultBindings: platformBindings([]) - }, - { - id: 'terminal.closePane', - title: 'Close active pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'close'], - defaultBindings: platformBindings(['Mod+W']) - }, - { - id: 'terminal.splitRight', - title: 'Split terminal right', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'split', 'right'], - defaultBindings: { - darwin: ['Mod+D'], - linux: ['Mod+Shift+D'], - win32: ['Mod+Shift+D'] - } - }, - { - id: 'terminal.splitDown', - title: 'Split terminal down', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'split', 'down'], - defaultBindings: { - darwin: ['Mod+Shift+D'], - linux: ['Alt+Shift+D'], - win32: ['Alt+Shift+D'] - } - }, - { - id: 'terminal.switchInputSource', - title: 'Switch input source / language (native)', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: [ - 'shortcut', - 'input', - 'source', - 'language', - 'korean', - 'english', - 'ime', - 'switch', - 'hangul', - 'layout' - ], - defaultBindings: { - darwin: [], - linux: [], - win32: [] - }, - // Why: macOS uses Shift+Space as an input-source shortcut; Orca otherwise rejects Shift-only bindings to avoid stealing typed text. - allowShiftOnlyKeybindings: true - }, - ...buildAgentTabKeybindingDefinitions() -] - -/** Pre-swap tab-switch bindings; a one-time migration pins these for pre-release installs so upgrading users keep the shortcuts they learned. */ -export const LEGACY_TAB_SWITCH_BINDINGS: Readonly>> = { - 'tab.nextSameType': ['Mod+Shift+BracketRight'], - 'tab.previousSameType': ['Mod+Shift+BracketLeft'], - 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], - 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] -} - -export function agentTabActionId(agent: TuiAgent): AgentTabActionId { - return `tab.newAgent.${agent}` -} - -// Why: one bindable action per agent; all ship unassigned since tab.newAgent covers the default, and Settings hides disabled agents. -function buildAgentTabKeybindingDefinitions(): KeybindingDefinition[] { - return ALL_TUI_AGENTS.map((agent) => ({ - id: agentTabActionId(agent), - title: `New ${TUI_AGENT_DISPLAY_NAMES[agent]} tab`, - group: 'Agents', - scope: 'tabs', - searchKeywords: [ - 'shortcut', - 'tab', - 'agent', - 'new', - 'launch', - agent, - TUI_AGENT_DISPLAY_NAMES[agent].toLowerCase() - ], - defaultBindings: platformBindings([]) - })) -} - -const DEFINITIONS_BY_ID = new Map( - KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition]) -) - -const DEFINITION_IDS = new Set( - KEYBINDING_DEFINITIONS.map((definition) => definition.id) -) - -// Why: these ids are single remappable rows whose chord is a representative — the digit canonicalizes to 1 but the binding fires for any 1-9. -export const DIGIT_INDEX_ACTION_IDS: readonly KeybindingActionId[] = [ - 'tab.selectByIndex', - 'workspace.selectByIndex' -] - -const DIGIT_INDEX_ACTION_ID_SET = new Set(DIGIT_INDEX_ACTION_IDS) - -// The representative key for a digit-index chord is a single 1-9 number key. -const DIGIT_INDEX_KEY_PATTERN = /^[1-9]$/ - -export function isDigitIndexActionId(actionId: KeybindingActionId): boolean { - return DIGIT_INDEX_ACTION_ID_SET.has(actionId) -} - -function platformBindings(bindings: readonly string[]): PlatformBindings { - return { - darwin: bindings, - linux: bindings, - win32: bindings - } -} - -export function getKeybindingPlatform(platform: NodeJS.Platform): KeybindingPlatform { - return platform === 'darwin' ? 'darwin' : platform === 'win32' ? 'win32' : 'linux' -} - -export function isKeybindingActionId(value: string): value is KeybindingActionId { - return DEFINITION_IDS.has(value as KeybindingActionId) || isPluginKeybindingActionId(value) -} - -export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId { - return ( - value.length <= 400 && - /^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test( - value - ) - ) -} - -function hasModifier( - input: KeybindingInput, - modifier: 'alt' | 'meta' | 'control' | 'shift' -): boolean { - if (modifier === 'alt') { - return Boolean(input.alt ?? input.altKey) - } - if (modifier === 'meta') { - return Boolean(input.meta ?? input.metaKey) - } - if (modifier === 'control') { - return Boolean(input.control ?? input.ctrlKey) - } - return Boolean(input.shift ?? input.shiftKey) -} - -function isFunctionKeyToken(key: string): boolean { - return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) -} - -function normalizeKeyToken(token: string): string | null { - if (token === ' ') { - return 'Space' - } - const trimmed = token.trim() - if (!trimmed) { - return null - } - const upper = trimmed.toUpperCase() - if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { - return upper - } - if (upper.length === 1 && upper >= '0' && upper <= '9') { - return upper - } - // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). - if (isFunctionKeyToken(upper)) { - return upper - } - - const simple: Record = { - '[': 'BracketLeft', - ']': 'BracketRight', - '{': 'BracketLeft', - '}': 'BracketRight', - '-': 'Minus', - _: 'Underscore', - '=': 'Equal', - '+': 'Plus', - ',': 'Comma', - '.': 'Period', - '/': 'Slash', - '\\': 'Backslash', - ';': 'Semicolon', - "'": 'Quote', - '`': 'Backquote', - RETURN: 'Enter', - ESC: 'Escape', - SPACEBAR: 'Space', - PGUP: 'PageUp', - PGDN: 'PageDown', - PLUS: 'Plus', - MINUS: 'Minus', - EQUAL: 'Equal', - UNDERSCORE: 'Underscore', - ARROWLEFT: 'ArrowLeft', - LEFT: 'ArrowLeft', - ARROWRIGHT: 'ArrowRight', - RIGHT: 'ArrowRight', - ARROWUP: 'ArrowUp', - UP: 'ArrowUp', - ARROWDOWN: 'ArrowDown', - DOWN: 'ArrowDown', - PAGEUP: 'PageUp', - PAGEDOWN: 'PageDown', - BACKSPACE: 'Backspace', - DELETE: 'Delete', - DEL: 'Delete', - INSERT: 'Insert', - INS: 'Insert', - ENTER: 'Enter', - TAB: 'Tab', - ESCAPE: 'Escape', - SPACE: 'Space', - BRACKETLEFT: 'BracketLeft', - BRACKETRIGHT: 'BracketRight', - NUMPADADD: 'NumpadAdd', - NUMPADSUBTRACT: 'NumpadSubtract', - ADD: 'NumpadAdd', - SUBTRACT: 'NumpadSubtract', - COMMA: 'Comma', - PERIOD: 'Period', - SLASH: 'Slash', - BACKSLASH: 'Backslash', - SEMICOLON: 'Semicolon', - QUOTE: 'Quote', - BACKQUOTE: 'Backquote' - } - - return simple[upper] ?? null -} - -function parseModifierToken(rawPart: string): ModifierToken | null { - const part = rawPart.toLowerCase() - if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { - return 'Mod' - } - if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { - return 'Cmd' - } - if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { - return 'Ctrl' - } - if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { - return 'Alt' - } - if (part === 'shift' || rawPart === '⇧') { - return 'Shift' - } - return null -} - -function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void { - if (modifier === 'Mod') { - parsed.mod = true - } else if (modifier === 'Cmd') { - parsed.meta = true - } else if (modifier === 'Ctrl') { - parsed.control = true - } else if (modifier === 'Alt') { - parsed.alt = true - } else { - parsed.shift = true - } -} - -function emptyParsedKeybinding(): ParsedKeybinding { - return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' } -} - -// Why: a double-tap is a bare modifier with no key, so it can't use the normal parse path; modifier validation is deferred to normalize. -function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null { - const modifiers: ModifierToken[] = [] - let sawDoubleTap = false - for (const rawPart of rawParts) { - if (rawPart.toLowerCase() === 'doubletap') { - if (sawDoubleTap) { - return null - } - sawDoubleTap = true - continue - } - const modifier = parseModifierToken(rawPart) - if (!modifier) { - return null - } - modifiers.push(modifier) - } - if (modifiers.length === 0) { - return null - } - const parsed = emptyParsedKeybinding() - for (const modifier of modifiers) { - applyModifierToken(parsed, modifier) - } - // Keep both flags when Mod is combined with a platform modifier, so normalize emits the shared "Mod or platform-specific, not both" error. - if (parsed.mod && (parsed.meta || parsed.control)) { - parsed.doubleTapModifier = 'Mod' - return parsed - } - if (modifiers.length > 1) { - return null - } - parsed.doubleTapModifier = modifiers[0] - return parsed -} - -function parseKeybinding(binding: string): ParsedKeybinding | null { - const rawParts = binding - .split('+') - .map((part) => part.trim()) - .filter(Boolean) - if (rawParts.length === 0) { - return null - } - - if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) { - return parseDoubleTapKeybinding(rawParts) - } - - const parsed = emptyParsedKeybinding() - for (const rawPart of rawParts) { - const modifier = parseModifierToken(rawPart) - if (modifier) { - applyModifierToken(parsed, modifier) - continue - } - if (parsed.key) { - return null - } - const key = normalizeKeyToken(rawPart) - if (!key) { - return null - } - parsed.key = key - } - - return parsed.key ? parsed : null -} - -function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { - if (parsed.doubleTapModifier) { - return `DoubleTap+${parsed.doubleTapModifier}` - } - const parts: string[] = [] - if (parsed.mod) { - parts.push('Mod') - } - if (parsed.meta) { - parts.push('Cmd') - } - if (parsed.control) { - parts.push('Ctrl') - } - if (parsed.alt) { - parts.push('Alt') - } - if (parsed.shift) { - parts.push('Shift') - } - parts.push(parsed.key) - return parts.join('+') -} - -function isSafeBareKey(parsed: ParsedKeybinding): boolean { - if (parsed.mod || parsed.meta || parsed.control || parsed.alt) { - return false - } - // Function keys produce no text, so they're safe bare or with Shift (Shift+letter stays unsafe). - if (parsed.shift) { - return isFunctionKeyToken(parsed.key) - } - return ( - isFunctionKeyToken(parsed.key) || - [ - 'Backspace', - 'Delete', - 'Enter', - 'Escape', - 'Tab', - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - 'PageUp', - 'PageDown' - ].includes(parsed.key) - ) -} - -function normalizeKeybindingWithOptions( - binding: string, - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult { - const parsed = parseKeybinding(binding) - if (!parsed) { - return { ok: false, error: 'Use a shortcut like Ctrl+Shift+P or Cmd+K.' } - } - if (parsed.mod && (parsed.meta || parsed.control)) { - return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' } - } - if (parsed.doubleTapModifier) { - return { ok: true, value: canonicalizeParsedKeybinding(parsed) } - } - const isShiftInsert = parsed.shift && parsed.key === 'Insert' - const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) - const isShiftOnlyAllowed = - options.allowShiftOnlyKeybindings === true && - parsed.shift && - !parsed.mod && - !parsed.meta && - !parsed.control && - !parsed.alt - if ( - !parsed.mod && - !parsed.meta && - !parsed.control && - !parsed.alt && - !isShiftInsert && - !isBareAllowed && - !isShiftOnlyAllowed - ) { - return { ok: false, error: 'Include at least one modifier key.' } - } - return { ok: true, value: canonicalizeParsedKeybinding(parsed) } -} - -export function normalizeKeybinding(binding: string): KeybindingValidationResult { - return normalizeKeybindingWithOptions(binding) -} - -export function isDoubleTapBinding(binding: string): boolean { - return Boolean(parseKeybinding(binding)?.doubleTapModifier) -} - -function normalizeKeybindingListWithOptions( - input: string, - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult | string[] { - const trimmed = input.trim() - if (!trimmed) { - return [] - } - const normalized: string[] = [] - for (const piece of trimmed.split(',')) { - const result = normalizeKeybindingWithOptions(piece, options) - if (!result.ok) { - return result - } - if (!normalized.includes(result.value)) { - normalized.push(result.value) - } - } - return normalized -} - -export function normalizeKeybindingList(input: string): KeybindingValidationResult | string[] { - return normalizeKeybindingListWithOptions(input) -} - -function normalizeKeybindingArrayWithOptions( - input: readonly string[], - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult | string[] { - const normalized: string[] = [] - for (const binding of input) { - const piece = normalizeKeybindingListWithOptions(binding, options) - if (!Array.isArray(piece)) { - return piece - } - for (const normalizedBinding of piece) { - if (!normalized.includes(normalizedBinding)) { - normalized.push(normalizedBinding) - } - } - } - return normalized -} - -function normalizeOptionsForAction(actionId: KeybindingActionId): NormalizeKeybindingOptions { - const definition = DEFINITIONS_BY_ID.get(actionId) - return { - allowBareKeybindings: definition?.allowBareKeybindings === true, - allowShiftOnlyKeybindings: definition?.allowShiftOnlyKeybindings === true - } -} - -// Why: rewrite a digit-index chord's key to 1 so display and conflict detection stay stable across the 1-9 range; reject any non 1-9 key. -function canonicalizeDigitIndexBinding(binding: string): KeybindingValidationResult { - const parsed = parseKeybinding(binding) - if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { - return { - ok: false, - error: 'Pick a number key 1–9 with a modifier, like Cmd+1 or Ctrl+1.' - } - } - return { ok: true, value: canonicalizeParsedKeybinding({ ...parsed, key: '1' }) } -} - -function finalizeDigitIndexBindings( - actionId: KeybindingActionId, - result: KeybindingValidationResult | string[] -): KeybindingValidationResult | string[] { - if (!isDigitIndexActionId(actionId) || !Array.isArray(result)) { - return result - } - const canonical: string[] = [] - for (const binding of result) { - const normalized = canonicalizeDigitIndexBinding(binding) - if (!normalized.ok) { - return normalized - } - if (!canonical.includes(normalized.value)) { - canonical.push(normalized.value) - } - } - return canonical -} - -export function normalizeKeybindingListForAction( - actionId: KeybindingActionId, - input: string -): KeybindingValidationResult | string[] { - return finalizeDigitIndexBindings( - actionId, - normalizeKeybindingListWithOptions(input, normalizeOptionsForAction(actionId)) - ) -} - -export function normalizeKeybindingArrayForAction( - actionId: KeybindingActionId, - input: readonly string[] -): KeybindingValidationResult | string[] { - return finalizeDigitIndexBindings( - actionId, - normalizeKeybindingArrayWithOptions(input, normalizeOptionsForAction(actionId)) - ) -} - -const MODIFIER_KEYS = new Set([ - 'Alt', - 'AltGraph', - 'Control', - 'Meta', - 'Shift', - 'OS', - 'Fn', - 'FnLock', - 'Hyper', - 'Super', - 'Symbol', - 'SymbolLock' -]) - -const PUNCTUATION_KEY_TOKENS = new Set([ - 'BracketLeft', - 'BracketRight', - 'Minus', - 'Underscore', - 'Equal', - 'Plus', - 'Comma', - 'Period', - 'Slash', - 'Backslash', - 'Semicolon', - 'Quote', - 'Backquote' -]) - -const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified']) - -const SHIFTED_PUNCTUATION_KEY_TOKENS: Record = { - '<': 'Comma', - '>': 'Period', - '?': 'Slash', - '|': 'Backslash', - ':': 'Semicolon', - '"': 'Quote', - '~': 'Backquote' -} - -function logicalKeyTokenFromInput(input: KeybindingInput): string | null { - const key = input.key ?? '' - if (MODIFIER_KEYS.has(key)) { - return null - } - const normalizedKey = normalizeKeyToken(key) - if (normalizedKey) { - return normalizedKey - } - if (hasModifier(input, 'shift')) { - return SHIFTED_PUNCTUATION_KEY_TOKENS[key] ?? null - } - return null -} - -function canUsePhysicalCodeFallback(input: KeybindingInput): boolean { - // Why: layout-aware shortcuts trust real logical keys; physical code is only a fallback when the platform can't report the produced key. - return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '') -} - -function isLatinShortcutKey(key: string): boolean { - // Why: A-Z / 0-9 are the only chars a Latin shortcut names; a non-Latin char (Cyrillic с, Greek π) is never a Latin remap, so physical-code fallback is safe. - if (key.length !== 1) { - return false - } - const upper = key.toUpperCase() - return (upper >= 'A' && upper <= 'Z') || (key >= '0' && key <= '9') -} - -function shouldUseNonLatinShortcutPhysicalFallback( - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: non-Latin layouts report non-Latin logical keys for physical letters (#6274), breaking Ctrl/Meta shortcuts; fall back to the physical code. - if (getKeybindingPlatform(platform) === 'darwin') { - return false - } - const hasPrimaryModifier = hasModifier(input, 'control') || hasModifier(input, 'meta') - if (!hasPrimaryModifier) { - return false - } - // AltGr surfaces as Ctrl+Alt on Windows/Linux; treat it as text, not a chord. - if (hasModifier(input, 'control') && hasModifier(input, 'alt')) { - return false - } - if (logicalKeyTokenFromInput(input) !== null) { - return false - } - const key = input.key ?? '' - return key !== '' && !MODIFIER_KEYS.has(key) && !isLatinShortcutKey(key) -} - -function canFallBackToPhysicalCode(input: KeybindingInput, platform: NodeJS.Platform): boolean { - return ( - canUsePhysicalCodeFallback(input) || shouldUseNonLatinShortcutPhysicalFallback(input, platform) - ) -} - -function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null { - const code = input.code ?? '' - if (code.startsWith('Key') && code.length === 4) { - return code.slice(3).toUpperCase() - } - if (code.startsWith('Digit') && code.length === 6) { - return code.slice(5) - } - - return normalizeKeyToken(code) -} - -function numpadCodeKeyTokenFromInput(input: KeybindingInput): string | null { - const code = input.code ?? '' - return code === 'NumpadAdd' || code === 'NumpadSubtract' ? normalizeKeyToken(code) : null -} - -function shouldUseMacOptionComposedCaptureFallback( - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: macOS Option+key reports composed characters (Option+C -> ç), so capturing Alt shortcuts needs the physical-code fallback. - if ( - getKeybindingPlatform(platform) !== 'darwin' || - !hasModifier(input, 'alt') || - MODIFIER_KEYS.has(input.key ?? '') - ) { - return false - } - const physicalToken = physicalCodeKeyTokenFromInput(input) - if (!physicalToken) { - return false - } - return ( - (physicalToken.length === 1 && physicalToken >= 'A' && physicalToken <= 'Z') || - isPunctuationKeyToken(physicalToken) - ) -} - -function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { - const numpadKey = numpadCodeKeyTokenFromInput(input) - if (numpadKey) { - return numpadKey - } - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey) { - return logicalKey - } - if ( - !canUsePhysicalCodeFallback(input) && - !shouldUseMacOptionComposedCaptureFallback(input, platform) && - !shouldUseNonLatinShortcutPhysicalFallback(input, platform) - ) { - return null - } - return physicalCodeKeyTokenFromInput(input) -} - -// Why: the platform primary modifier canonicalizes to Mod (Cmd on macOS / Ctrl elsewhere), mirroring normal capture. -function canonicalDoubleTapToken( - modifier: PhysicalModifierToken, - platform: NodeJS.Platform -): ModifierToken { - const isMac = platform === 'darwin' - if (modifier === 'Cmd' && isMac) { - return 'Mod' - } - if (modifier === 'Ctrl' && !isMac) { - return 'Mod' - } - return modifier -} - -function keybindingFromInputWithOptions( - input: KeybindingInput, - platform: NodeJS.Platform, - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult { - if (input.doubleTapModifier) { - return normalizeKeybindingWithOptions( - `DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`, - options - ) - } - const key = keyTokenFromInput(input, platform) - if (!key) { - return { ok: false, error: 'Press a key, not only a modifier.' } - } - - const isMac = getKeybindingPlatform(platform) === 'darwin' - const parts: string[] = [] - const primaryModifierPressed = isMac ? hasModifier(input, 'meta') : hasModifier(input, 'control') - if (primaryModifierPressed) { - parts.push('Mod') - } - if (isMac && hasModifier(input, 'control')) { - parts.push('Ctrl') - } - if (!isMac && hasModifier(input, 'meta')) { - parts.push('Cmd') - } - if (hasModifier(input, 'alt')) { - parts.push('Alt') - } - if (hasModifier(input, 'shift')) { - parts.push('Shift') - } - parts.push(key) - - return normalizeKeybindingWithOptions(parts.join('+'), options) -} - -export function keybindingFromInput( - input: KeybindingInput, - platform: NodeJS.Platform -): KeybindingValidationResult { - return keybindingFromInputWithOptions(input, platform) -} - -export function keybindingFromInputForAction( - actionId: KeybindingActionId, - input: KeybindingInput, - platform: NodeJS.Platform -): KeybindingValidationResult { - const result = keybindingFromInputWithOptions( - input, - platform, - normalizeOptionsForAction(actionId) - ) - if (!result.ok || !isDigitIndexActionId(actionId)) { - return result - } - return canonicalizeDigitIndexBinding(result.value) -} - -function getDefaultBindings(definition: KeybindingDefinition, platform: NodeJS.Platform): string[] { - return definition.defaultBindings[getKeybindingPlatform(platform)].map((binding) => { - const normalized = normalizeKeybindingWithOptions(binding, { - allowBareKeybindings: definition.allowBareKeybindings === true, - allowShiftOnlyKeybindings: definition.allowShiftOnlyKeybindings === true - }) - return normalized.ok ? normalized.value : binding - }) -} - -export function getEffectiveKeybindingsForAction( - actionId: KeybindingActionId, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides -): string[] { - const definition = DEFINITIONS_BY_ID.get(actionId) - const override = overrides?.[actionId] - if (Array.isArray(override)) { - // Why: canonicalize digit-index overrides to +1 so display/conflict stay consistent even if a hand-edited file stored a different digit. - if (isDigitIndexActionId(actionId)) { - const canonical: string[] = [] - for (const binding of override) { - const normalized = canonicalizeDigitIndexBinding(binding) - if (normalized.ok && !canonical.includes(normalized.value)) { - canonical.push(normalized.value) - } - } - return canonical - } - return override.flatMap((binding) => { - const normalized = normalizeKeybindingWithOptions( - binding, - normalizeOptionsForAction(actionId) - ) - return normalized.ok ? [normalized.value] : [] - }) - } - return definition ? getDefaultBindings(definition, platform) : [] -} - -export function getEffectiveKeybindingsForDefinition( - definition: KeybindingDefinition, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides -): string[] { - const override = overrides?.[definition.id] - if (Array.isArray(override)) { - return getEffectiveKeybindingsForAction(definition.id, platform, overrides) - } - return getDefaultBindings(definition, platform) -} - -export function getKeybindingDefinition(actionId: KeybindingActionId): KeybindingDefinition | null { - return DEFINITIONS_BY_ID.get(actionId) ?? null -} - -export function normalizeTerminalShortcutPolicy( - policy: TerminalShortcutPolicy | null | undefined -): TerminalShortcutPolicy { - return policy === 'terminal-first' ? 'terminal-first' : 'orca-first' -} - -export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean { - return definition.scope === 'terminal' || definition.allowInTerminal === true -} - -export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean { - return definition.scope !== 'terminal' && definition.allowInTerminal !== true -} - -export function keybindingIsActiveInContext( - definition: KeybindingDefinition, - options: KeybindingMatchOptions = {} -): boolean { - if (options.context !== 'terminal') { - return true - } - // Why: Orca-first keeps app shortcuts inside terminals; terminal-first is the escape hatch for shells and TUIs. - if (normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first') { - return true - } - return isKeybindingAllowedInTerminal(definition) -} - -function platformModifiers( - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): { meta: boolean; control: boolean; alt: boolean; shift: boolean } { - const isMac = platform === 'darwin' - return { - meta: parsed.meta || (parsed.mod && isMac), - control: parsed.control || (parsed.mod && !isMac), - alt: parsed.alt, - shift: parsed.shift - } -} - -function modifierStateMatches( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - const expected = platformModifiers(parsed, platform) - return ( - hasModifier(input, 'meta') === expected.meta && - hasModifier(input, 'control') === expected.control && - hasModifier(input, 'alt') === expected.alt && - hasModifier(input, 'shift') === expected.shift - ) -} - -function shouldUseMacOptionLetterPhysicalFallback( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: macOS Option+letter reports composed characters (Option+A -> å), leaving no logical Latin key for Alt shortcuts. - return ( - getKeybindingPlatform(platform) === 'darwin' && - parsed.alt && - hasModifier(input, 'alt') && - logicalKeyTokenFromInput(input) === null - ) -} - -function shouldUseMacOptionPunctuationPhysicalFallback( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: macOS Option+punctuation reports composed dead-key values, leaving no logical bracket token for Alt shortcuts. - return ( - getKeybindingPlatform(platform) === 'darwin' && - parsed.alt && - hasModifier(input, 'alt') && - logicalKeyTokenFromInput(input) === null - ) -} - -function letterKeyMatches( - input: KeybindingInput, - letter: string, - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): boolean { - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey && logicalKey.length === 1 && logicalKey >= 'A' && logicalKey <= 'Z') { - return logicalKey === letter.toUpperCase() - } - return ( - (canFallBackToPhysicalCode(input, platform) || - shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) && - input.code === `Key${letter.toUpperCase()}` - ) -} - -function digitKeyMatches( - input: KeybindingInput, - digit: string, - platform: NodeJS.Platform -): boolean { - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') { - return logicalKey === digit - } - return canFallBackToPhysicalCode(input, platform) && input.code === `Digit${digit}` -} - -function isPunctuationKeyToken(token: string | null): token is string { - return token !== null && PUNCTUATION_KEY_TOKENS.has(token) -} - -function semanticPunctuationKey(input: KeybindingInput): string | null { - const logicalKey = logicalKeyTokenFromInput(input) - return isPunctuationKeyToken(logicalKey) ? logicalKey : null -} - -function physicalPunctuationKey(input: KeybindingInput): string | null { - const physicalKey = physicalCodeKeyTokenFromInput(input) - return isPunctuationKeyToken(physicalKey) ? physicalKey : null -} - -function shouldUseSemanticPunctuation( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: Windows/Linux expose AltGr as Ctrl+Alt; don't turn international text input into Mod+Alt app shortcuts. - if ( - getKeybindingPlatform(platform) !== 'darwin' && - parsed.mod && - parsed.alt && - hasModifier(input, 'control') && - hasModifier(input, 'alt') && - !hasModifier(input, 'meta') && - physicalPunctuationKey(input) === null - ) { - return false - } - return true -} - -function keyMatches( - parsedKey: string, - input: KeybindingInput, - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): boolean { - if (parsedKey.length === 1 && parsedKey >= 'A' && parsedKey <= 'Z') { - return letterKeyMatches(input, parsedKey, parsed, platform) - } - if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') { - return digitKeyMatches(input, parsedKey, platform) - } - - if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') { - return ( - numpadCodeKeyTokenFromInput(input) === parsedKey || - logicalKeyTokenFromInput(input) === parsedKey - ) - } - - if (isPunctuationKeyToken(parsedKey)) { - // Why: shortcut labels name logical punctuation, but international layouts can report it from different physical codes. - const semanticKey = semanticPunctuationKey(input) - if (semanticKey !== null) { - if (!shouldUseSemanticPunctuation(parsed, input, platform)) { - return false - } - return semanticKey === parsedKey - } - return ( - (canFallBackToPhysicalCode(input, platform) || - shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) && - physicalPunctuationKey(input) === parsedKey - ) - } - - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey !== null) { - return logicalKey === parsedKey - } - return ( - canFallBackToPhysicalCode(input, platform) && physicalCodeKeyTokenFromInput(input) === parsedKey - ) -} - -function resolveModifierToken( - modifier: ModifierToken, - platform: NodeJS.Platform -): 'meta' | 'control' | 'alt' | 'shift' { - switch (modifier) { - case 'Mod': - return platform === 'darwin' ? 'meta' : 'control' - case 'Cmd': - return 'meta' - case 'Ctrl': - return 'control' - case 'Alt': - return 'alt' - case 'Shift': - return 'shift' - } -} - -export function keybindingMatchesInput( - binding: string, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - const parsed = parseKeybinding(binding) - if (!parsed) { - return false - } - // A double-tap binding matches only a synthetic double-tap input (and vice-versa), resolved per platform. - if (parsed.doubleTapModifier) { - return ( - input.doubleTapModifier !== undefined && - resolveModifierToken(parsed.doubleTapModifier, platform) === - resolveModifierToken(input.doubleTapModifier, platform) - ) - } - if (input.doubleTapModifier !== undefined) { - return false - } - return ( - modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform) - ) -} - -function keybindingConflictIdentityForParsed( - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): string { - if (parsed.doubleTapModifier) { - return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}` - } - const modifiers = platformModifiers(parsed, platform) - return [ - modifiers.meta ? 'Meta' : '', - modifiers.control ? 'Control' : '', - modifiers.alt ? 'Alt' : '', - modifiers.shift ? 'Shift' : '', - parsed.key - ].join('+') -} - -export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string { - const parsed = parseKeybinding(binding) - return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding -} - -function keybindingConflictIdentities( - actionId: KeybindingActionId, - binding: string, - platform: NodeJS.Platform -): readonly string[] { - const exact = getKeybindingConflictIdentity(binding, platform) - if (!isDigitIndexActionId(actionId)) { - return [exact] - } - const parsed = parseKeybinding(binding) - if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { - return [exact] - } - return Array.from({ length: 9 }, (_, index) => - keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform) - ) -} - -export function keybindingMatchesAction( - actionId: KeybindingActionId, - input: KeybindingInput, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: KeybindingMatchOptions = {} -): boolean { - const definition = DEFINITIONS_BY_ID.get(actionId) - if (!definition) { - return false - } - if (!keybindingIsActiveInContext(definition, options)) { - return false - } - return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) => - keybindingMatchesInput(binding, input, platform) - ) -} - -function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { - for (let value = 1; value <= 9; value++) { - const digit = String(value) - if (digitKeyMatches(input, digit, platform)) { - return digit - } - } - return null -} - -// Why: a digit-index row's representative chord fires for any 1-9 — reuse its modifiers with the pressed digit via the normal matcher. -export function matchKeybindingDigitIndex( - actionId: KeybindingActionId, - input: KeybindingInput, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: KeybindingMatchOptions = {} -): number | null { - const definition = DEFINITIONS_BY_ID.get(actionId) - if (!definition || !keybindingIsActiveInContext(definition, options)) { - return null - } - const digit = digitFromInput(input, platform) - if (!digit) { - return null - } - for (const binding of getEffectiveKeybindingsForAction(actionId, platform, overrides)) { - const parsed = parseKeybinding(binding) - if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { - continue - } - const candidate = canonicalizeParsedKeybinding({ ...parsed, key: digit }) - if (keybindingMatchesInput(candidate, input, platform)) { - return Number(digit) - 1 - } - } - return null -} - -function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { - switch (modifier) { - case 'Mod': - return isMac ? '⌘' : 'Ctrl' - case 'Cmd': - return isMac ? '⌘' : 'Cmd' - case 'Ctrl': - return isMac ? '⌃' : 'Ctrl' - case 'Alt': - return isMac ? '⌥' : 'Alt' - case 'Shift': - return isMac ? '⇧' : 'Shift' - } -} - -export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] { - const parsed = parseKeybinding(binding) - if (!parsed) { - return [binding] - } - const isMac = platform === 'darwin' - if (parsed.doubleTapModifier) { - const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac) - return [glyph, glyph] - } - const parts: string[] = [] - if (parsed.mod) { - parts.push(isMac ? '⌘' : 'Ctrl') - } - if (parsed.meta) { - parts.push(isMac ? '⌘' : 'Cmd') - } - if (parsed.control) { - parts.push(isMac ? '⌃' : 'Ctrl') - } - if (parsed.alt) { - parts.push(isMac ? '⌥' : 'Alt') - } - if (parsed.shift) { - parts.push(isMac ? '⇧' : 'Shift') - } - parts.push(formatKeyToken(parsed.key, isMac)) - return parts -} - -export function formatKeybindingList( - bindings: readonly string[], - platform: NodeJS.Platform -): string { - if (bindings.length === 0) { - return 'Unassigned' - } - return bindings - .map((binding) => { - const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+' - return formatKeybinding(binding, platform).join(separator) - }) - .join(', ') -} - -export function findKeybindingActionsForBinding( - binding: string, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - scopes: readonly KeybindingScope[] = ['global', 'tabs'] -): KeybindingActionId[] { - const identity = getKeybindingConflictIdentity(binding, platform) - const allowedScopes = new Set(scopes) - return KEYBINDING_DEFINITIONS.filter( - (definition) => - allowedScopes.has(definition.scope) && - getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) => - keybindingConflictIdentities(definition.id, candidate, platform).includes(identity) - ) - ).map((definition) => definition.id) -} - -function formatKeyToken(token: string, isMac: boolean): string { - const labels: Record = { - BracketLeft: '[', - BracketRight: ']', - Minus: '-', - Underscore: '_', - Equal: '=', - Plus: '+', - ArrowLeft: '←', - ArrowRight: '→', - ArrowUp: '↑', - ArrowDown: '↓', - PageUp: 'PageUp', - PageDown: 'PageDown', - NumpadAdd: 'Numpad +', - NumpadSubtract: 'Numpad -', - Comma: ',', - Period: '.', - Slash: '/', - Backslash: '\\', - Semicolon: ';', - Quote: "'", - Backquote: '`', - Enter: 'Enter', - Backspace: isMac ? '⌫' : 'Backspace', - Delete: 'Delete', - Insert: 'Insert', - Tab: 'Tab', - Escape: 'Esc', - Space: 'Space' - } - return labels[token] ?? token -} - -export function findKeybindingConflicts( - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: FindKeybindingConflictOptions = {} -): KeybindingConflict[] { - return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options) -} - -export function findKeybindingConflictsForDefinitions( - definitions: readonly KeybindingDefinition[], - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: FindKeybindingConflictOptions = {} -): KeybindingConflict[] { - const owners = new Map }>() - const ignoredActionIds = new Set(options.ignoredActionIds ?? []) - const customizedActions = new Set( - Object.keys(overrides ?? {}).filter( - (actionId): actionId is KeybindingActionId => - isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId) - ) - ) - for (const actionId of options.relevantActionIds ?? []) { - if (!ignoredActionIds.has(actionId)) { - customizedActions.add(actionId) - } - } - for (const definition of definitions) { - if (ignoredActionIds.has(definition.id)) { - continue - } - for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) { - const groups = new Set([definition.conflictGroup ?? definition.scope]) - if (definition.conflictGroup) { - // Why: native menu accelerators can consume global chords, so check custom bindings against both the menu bucket and scope. - groups.add(definition.scope) - } - for (const group of groups) { - for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) { - const conflictKey = `${group}\u0000${identity}` - const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() } - if ( - !isDigitIndexActionId(definition.id) && - Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId)) - ) { - current.binding = binding - } - current.actionIds.add(definition.id) - owners.set(conflictKey, current) - } - } - } - } - - const seenConflictKeys = new Set() - return Array.from(owners.values()) - .filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions)) - .map(({ binding, actionIds }) => ({ - binding, - actionIds: Array.from(actionIds) - })) - .filter((conflict) => { - const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}` - if (seenConflictKeys.has(key)) { - return false - } - seenConflictKeys.add(key) - return true - }) -} - -function setIntersects(left: ReadonlySet, right: ReadonlySet): boolean { - for (const value of left) { - if (right.has(value)) { - return true - } - } - return false -} +// Public compatibility surface for the keybinding domain. +// Implementations live beside this facade so consumers can keep the stable import path. +export * from './keybindings/types' +export * from './keybindings/definitions' +export * from './keybindings/parser' +export * from './keybindings/normalization' +export * from './keybindings/input' +export * from './keybindings/effective' +export * from './keybindings/matching-key' +export * from './keybindings/matching' +export * from './keybindings/formatting' diff --git a/src/shared/keybindings/definitions-core-1.ts b/src/shared/keybindings/definitions-core-1.ts new file mode 100644 index 00000000000..b757a4127e4 --- /dev/null +++ b/src/shared/keybindings/definitions-core-1.ts @@ -0,0 +1,298 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_1: readonly KeybindingDefinition[] = [ + { + id: 'worktree.quickOpen', + title: 'Go to File', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'file', 'quick open'], + defaultBindings: platformBindings(['Mod+P']) + }, + { + id: 'app.settings', + title: 'Open Settings', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'settings', 'preferences'], + defaultBindings: platformBindings(['Mod+Comma']), + conflictGroup: 'menu' + }, + { + id: 'app.forceReload', + title: 'Force Reload', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'reload', 'refresh', 'force'], + defaultBindings: platformBindings(['Mod+Shift+R']), + conflictGroup: 'menu' + }, + { + id: 'worktree.palette', + title: 'Switch worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], + defaultBindings: { + darwin: ['Mod+J'], + linux: ['Mod+Shift+J'], + win32: ['Mod+Shift+J'] + } + }, + { + id: 'worktree.navigateUp', + title: 'Previous worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'previous', 'up'], + defaultBindings: platformBindings(['Mod+Shift+ArrowUp']) + }, + { + id: 'worktree.navigateDown', + title: 'Next worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'next', 'down'], + defaultBindings: platformBindings(['Mod+Shift+ArrowDown']) + }, + { + id: 'workspace.create', + title: 'Create worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'create', 'new workspace'], + defaultBindings: platformBindings(['Mod+N', 'Mod+Shift+N']) + }, + { + id: 'workspace.rename', + title: 'Rename worktree', + group: 'Global', + scope: 'global', + conflictGroup: 'workspace-shell', + searchKeywords: ['shortcut', 'global', 'worktree', 'rename', 'workspace', 'title'], + // Why: macOS only — Windows/Linux Ctrl+Alt+R has no safe default (Ctrl+R reverse-search, Ctrl+Shift+R reload are taken). + defaultBindings: { + darwin: ['Mod+Alt+R'], + linux: [], + win32: [] + } + }, + { + id: 'workspace.delete', + title: 'Delete Workspace', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'workspace', + 'current workspace', + 'worktree', + 'delete', + 'remove', + 'trash' + ], + // Why: Backspace avoids the terminal pane's D-based split shortcuts on every platform. + defaultBindings: platformBindings(['Mod+Shift+Backspace']), + allowInTerminal: true + }, + { + id: 'workspace.openBoard', + title: 'Toggle Workspace Board', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'workspace', + 'board', + 'kanban', + 'worktree', + 'toggle', + 'open', + 'close' + ], + // Why: configurable but unbound by default, to not take a global chord from terminal/browser/editor users. + defaultBindings: platformBindings([]), + allowInTerminal: true + }, + { + id: 'dashboard.toggle', + title: 'Toggle Agent Dashboard', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'agent', + 'agents', + 'dashboard', + 'kanban', + 'board', + 'toggle', + 'open', + 'close' + ], + // Why: configurable but unbound by default, matching workspace.openBoard — an + // experimental surface must not claim a global chord from terminal users. + defaultBindings: platformBindings([]), + allowInTerminal: true + }, + { + id: 'workspace.selectByIndex', + title: 'Select Workspace 1–9', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'workspace', + 'worktree', + 'select', + 'switch', + 'number', + 'digit', + '1-9', + 'index' + ], + // Why: one remappable row covers the whole 1-9 range (stored chord is a representative; any of 1-9 fires it). + defaultBindings: platformBindings(['Mod+1']) + }, + { + id: 'voice.dictation', + title: 'Dictation', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'], + defaultBindings: platformBindings(['Mod+E']) + }, + { + id: 'view.tasks', + title: 'Open Tasks', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'tasks', 'github issues', 'linear'], + defaultBindings: platformBindings([]) + }, + { + id: 'sidebar.left.toggle', + title: 'Toggle Sidebar', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'left'], + defaultBindings: platformBindings(['Mod+B']) + }, + { + id: 'sidebar.right.toggle', + title: 'Toggle Right Sidebar', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'right'], + defaultBindings: platformBindings(['Mod+L']) + }, + { + id: 'sidebar.explorer.toggle', + title: 'Show Explorer', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'explorer', 'files'], + defaultBindings: platformBindings(['Mod+Shift+E']) + }, + { + id: 'sidebar.search.toggle', + title: 'Show Search', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'search'], + defaultBindings: platformBindings(['Mod+Shift+F']) + }, + { + id: 'sidebar.sourceControl.toggle', + title: 'Show Source Control', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'source control', 'git'], + defaultBindings: platformBindings(['Mod+Shift+G']) + }, + { + id: 'sidebar.checks.toggle', + title: 'Show Checks', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'checks', 'ci'], + defaultBindings: platformBindings([]) + }, + { + id: 'sidebar.ports.toggle', + title: 'Show Ports', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'ports'], + defaultBindings: { + darwin: ['Mod+Shift+I'], + linux: [], + win32: [] + } + }, + { + id: 'sidebar.sleepingWorkspaces.toggle', + title: 'Toggle Sleeping Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'sleeping', + 'asleep', + 'workspaces', + 'worktree', + 'filter', + 'show', + 'hide' + ], + // Why: ship unbound (issue #5209 asks users to assign it), avoiding a claimed cross-platform chord. + defaultBindings: platformBindings([]) + }, + { + id: 'sidebar.focusWorktreeList', + title: 'Focus worktree list', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'worktree', 'focus'], + // Why: keep zoom.reset on the browser-standard Mod+0; this chord was unreachable while it shared that default (#8584). + defaultBindings: platformBindings(['Mod+Shift+0']) + }, + { + id: 'floatingTerminal.toggle', + title: 'Toggle Floating Terminal', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'floating terminal', 'terminal'], + defaultBindings: platformBindings(['Mod+Alt+A']), + allowInTerminal: true + }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with floatingTerminal.toggle (Cmd+Opt+A) so maximize stays one-handed; macOS-only, Linux/Windows unbound. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + } +] diff --git a/src/shared/keybindings/definitions-core-2.ts b/src/shared/keybindings/definitions-core-2.ts new file mode 100644 index 00000000000..d8fd6ad3227 --- /dev/null +++ b/src/shared/keybindings/definitions-core-2.ts @@ -0,0 +1,282 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [ + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: unbound everywhere since floatingTerminal.toggle owns show/hide; this exists only for an explicit user-bound "hide panel" shortcut. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'zoom.in', + title: 'Zoom In', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'zoom', 'in', 'scale'], + defaultBindings: platformBindings(['Mod+Equal', 'Mod+Shift+Plus', 'Mod+NumpadAdd']) + }, + { + id: 'zoom.out', + title: 'Zoom Out', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'zoom', 'out', 'scale'], + defaultBindings: platformBindings(['Mod+Minus', 'Mod+NumpadSubtract']) + }, + { + id: 'zoom.reset', + title: 'Reset Size', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], + defaultBindings: platformBindings(['Mod+0']) + }, + { + id: 'worktree.history.back', + title: 'Worktree History Back', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'worktree', 'history', 'back'], + defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']), + allowInTerminal: true + }, + { + id: 'worktree.history.forward', + title: 'Worktree History Forward', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'worktree', 'history', 'forward'], + defaultBindings: platformBindings(['Mod+Alt+ArrowRight']), + allowInTerminal: true + }, + { + id: 'tab.newTerminal', + title: 'New terminal tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'terminal', 'new'], + defaultBindings: platformBindings(['Mod+T']) + }, + { + id: 'tab.newAgent', + title: 'New agent tab (default agent)', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'agent', 'new', 'default', 'launch'], + // Why: macOS only — Windows Ctrl+Alt is AltGr and Linux Ctrl+Alt+T is the desktop "open terminal", so no safe default there. + defaultBindings: { + darwin: ['Mod+Alt+T'], + linux: [], + win32: [] + } + }, + { + id: 'tab.newBrowser', + title: 'New browser tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'browser', 'new'], + defaultBindings: platformBindings(['Mod+Shift+B']) + }, + { + id: 'tab.newSimulator', + title: 'New mobile emulator tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'simulator', 'emulator', 'mobile', 'ios', 'new'], + // Why: keep explorer on Mod+Shift+E (VS Code muscle memory); emulator is macOS-only and less common, so it yields to a free chord (#8533). + defaultBindings: { + darwin: ['Mod+Alt+Shift+E'], + linux: [], + win32: [] + } + }, + { + id: 'tab.newMarkdown', + title: 'New markdown tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'new'], + defaultBindings: platformBindings(['Mod+Shift+M']) + }, + { + id: 'tab.openMarkdown', + title: 'Open markdown tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'open'], + defaultBindings: platformBindings(['Mod+Shift+O']) + }, + { + id: 'tab.close', + title: 'Close active tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'close', 'tab', 'pane'], + defaultBindings: platformBindings(['Mod+W']) + }, + { + id: 'tab.closeAll', + title: 'Close all editor tabs', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'close', 'all', 'tabs', 'files', 'editors'], + defaultBindings: platformBindings(['Mod+Alt+W']) + }, + { + id: 'tab.rename', + title: 'Rename active tab', + group: 'Tabs', + scope: 'tabs', + conflictGroup: 'workspace-shell', + searchKeywords: ['shortcut', 'tab', 'rename', 'title', 'label'], + // Why: macOS only — Cmd+R is free in app/terminal focus; on Windows/Linux Ctrl+R is shell reverse-search, so left unbound. + defaultBindings: { + darwin: ['Mod+R'], + linux: [], + win32: [] + } + }, + { + id: 'tab.reopenClosed', + title: 'Reopen closed tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'reopen', 'restore', 'closed'], + defaultBindings: platformBindings(['Mod+Shift+T']) + }, + { + id: 'tab.nextSameType', + title: 'Next tab (same type)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'], + // Why: the widespread "switch tab" chord (Mod+Shift+Bracket) now drives all-types cycling; same-type moved to Mod+Alt for new installs. + defaultBindings: platformBindings(['Mod+Alt+BracketRight']) + }, + { + id: 'tab.previousSameType', + title: 'Previous tab (same type)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'], + defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) + }, + { + id: 'tab.nextAllTypes', + title: 'Next tab (all types)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'all', 'any'], + defaultBindings: platformBindings(['Mod+Shift+BracketRight']) + }, + { + id: 'tab.previousAllTypes', + title: 'Previous tab (all types)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'all', 'any'], + defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) + }, + { + id: 'tab.previousRecent', + title: 'Previous recent tab', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'recent', 'mru', 'switch', 'last used'], + defaultBindings: platformBindings(['Ctrl+Tab']), + allowInTerminal: true + }, + { + id: 'tab.nextTerminal', + title: 'Next terminal tab', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'terminal', 'next', 'switch'], + defaultBindings: platformBindings(['Ctrl+PageDown']), + allowInTerminal: true + }, + { + id: 'tab.previousTerminal', + title: 'Previous terminal tab', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'terminal', 'previous', 'switch'], + defaultBindings: platformBindings(['Ctrl+PageUp']), + allowInTerminal: true + }, + { + id: 'tab.selectByIndex', + title: 'Select Tab 1–9', + group: 'Tab Navigation', + scope: 'tabs', + // Why: no shared conflictGroup with workspace.selectByIndex so swapping their modifiers isn't a false conflict; safe because resolveWindowShortcutAction checks the workspace range first. + searchKeywords: ['shortcut', 'tab', 'select', 'switch', 'number', 'digit', '1-9', 'index'], + // Why: representative chord for the 1-9 range (see workspace.selectByIndex); each platform avoids the workspace-jump chord (Mod+1-9). + defaultBindings: { + darwin: ['Ctrl+1'], + linux: ['Alt+1'], + win32: ['Alt+1'] + } + }, + { + id: 'tab.openQuickCommandsMenu', + title: 'Toggle Quick Commands menu', + group: 'Quick Commands', + scope: 'tabs', + // Why: this tab-scoped action is also routed through the main-window allowlist, so Settings must warn when it shadows global chords. + conflictGroup: 'global', + searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'], + defaultBindings: platformBindings([]) + }, + { + id: 'browser.find', + title: 'Find in Browser', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'find', 'search'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'browser.back', + title: 'Go Back in Browser', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'history', 'back', 'previous'], + defaultBindings: { + darwin: ['Mod+BracketLeft'], + linux: ['Alt+ArrowLeft'], + win32: ['Alt+ArrowLeft'] + } + }, + { + id: 'browser.forward', + title: 'Go Forward in Browser', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'history', 'forward', 'next'], + defaultBindings: { + darwin: ['Mod+BracketRight'], + linux: ['Alt+ArrowRight'], + win32: ['Alt+ArrowRight'] + } + } +] diff --git a/src/shared/keybindings/definitions-core-3.ts b/src/shared/keybindings/definitions-core-3.ts new file mode 100644 index 00000000000..c6439949622 --- /dev/null +++ b/src/shared/keybindings/definitions-core-3.ts @@ -0,0 +1,292 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_3: readonly KeybindingDefinition[] = [ + { + id: 'browser.reload', + title: 'Reload Browser Page', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'reload', 'refresh'], + defaultBindings: platformBindings(['Mod+R']) + }, + { + id: 'browser.hardReload', + title: 'Hard Reload Browser Page', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'reload', 'refresh', 'cache'], + defaultBindings: platformBindings(['Mod+Shift+R']) + }, + { + id: 'browser.focusAddressBar', + title: 'Focus Browser Address Bar', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'address', 'url', 'location'], + defaultBindings: platformBindings(['Mod+L']) + }, + { + id: 'browser.grabElement', + title: 'Grab Page Element', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'grab', 'copy', 'element'], + defaultBindings: platformBindings(['Mod+C']) + }, + { + id: 'editor.find', + title: 'Find in editor', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'find', 'search'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'editor.replace', + title: 'Replace in editor', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'replace', 'find', 'search'], + // Why: match the source editor's native replace shortcut per platform. + defaultBindings: { + darwin: ['Mod+Alt+F'], + linux: ['Mod+H'], + win32: ['Mod+H'] + } + }, + { + id: 'editor.save', + title: 'Save File', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'save'], + defaultBindings: platformBindings(['Mod+S']) + }, + { + id: 'editor.markdownPreview', + title: 'Show Markdown Preview', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], + defaultBindings: platformBindings(['Mod+Shift+V']) + }, + { + id: 'editor.toggleWordWrap', + title: 'Toggle Word Wrap', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'word wrap', 'wrap', 'long lines', 'soft wrap'], + // Why: Alt+Z matches VS Code; bare Alt+letter is not AltGr, so it stays cross-platform (#9974). + defaultBindings: platformBindings(['Alt+Z']) + }, + { + id: 'editor.copyContext', + title: 'Copy Context', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'copy', 'context'], + defaultBindings: platformBindings(['Mod+Alt+C']) + }, + // Why: F7 / Shift+F7 mirror VS Code / JetBrains diff-change nav; function keys are safe bare/Shift, so both opt into allowBareKeybindings. + { + id: 'editor.previousChange', + title: 'Go to Previous Change', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'], + defaultBindings: platformBindings(['Shift+F7']), + allowBareKeybindings: true + }, + { + id: 'editor.nextChange', + title: 'Go to Next Change', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'], + defaultBindings: platformBindings(['F7']), + allowBareKeybindings: true + }, + { + id: 'editor.addReviewNote', + title: 'Add Review Note', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'], + // Why: Ctrl+Alt+letter is AltGr text input on Windows/Linux, so an editor default must not reserve chars like Polish `ń`. + defaultBindings: platformBindings(['Mod+Shift+A']) + }, + { + id: 'sourceControl.sendReviewNotes', + title: 'Send Review Notes to Agent', + group: 'Global', + scope: 'global', + // Why: fires from the global capture handler even while the editor is focused, so Settings must warn on collisions with editor chords (e.g. Add Review Note) too, not just global ones. + conflictGroup: 'editor', + searchKeywords: [ + 'shortcut', + 'source control', + 'diff', + 'notes', + 'send', + 'agent', + 'review', + 'annotate' + ], + // Why: unbound by default so it never collides with existing chords; users opt in via Settings. + defaultBindings: platformBindings([]) + }, + { + id: 'fileExplorer.undo', + title: 'Undo file operation', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'undo'], + defaultBindings: platformBindings(['Mod+Z']) + }, + { + id: 'fileExplorer.redo', + title: 'Redo file operation', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'redo'], + defaultBindings: { + darwin: ['Mod+Shift+Z'], + linux: ['Mod+Shift+Z', 'Ctrl+Y'], + win32: ['Mod+Shift+Z', 'Ctrl+Y'] + } + }, + { + id: 'fileExplorer.copyPath', + title: 'Copy file path', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'copy', 'path'], + defaultBindings: { + darwin: ['Mod+Alt+C'], + linux: ['Alt+Shift+C'], + win32: ['Alt+Shift+C'] + } + }, + { + id: 'fileExplorer.copyRelativePath', + title: 'Copy relative file path', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'copy', 'relative', 'path'], + defaultBindings: platformBindings(['Mod+Alt+Shift+C']) + }, + { + id: 'fileExplorer.delete', + title: 'Delete file', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'delete', 'remove', 'trash'], + defaultBindings: { + darwin: ['Mod+Backspace', 'Delete'], + linux: ['Delete'], + win32: ['Delete'] + }, + allowBareKeybindings: true + }, + { + id: 'settings.search', + title: 'Search Settings', + group: 'Settings', + scope: 'settings', + searchKeywords: ['shortcut', 'settings', 'search', 'find'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'terminal.copySelection', + title: 'Copy terminal selection', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'copy', 'selection'], + defaultBindings: { + darwin: ['Mod+C'], + linux: ['Ctrl+Shift+C', 'Ctrl+C'], + win32: ['Ctrl+Shift+C', 'Ctrl+C'] + } + }, + { + id: 'terminal.selectAll', + title: 'Select all terminal text', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'select', 'all'], + defaultBindings: { + darwin: ['Mod+A'], + linux: ['Ctrl+Shift+A'], + win32: ['Ctrl+Shift+A'] + } + }, + { + id: 'terminal.paste', + title: 'Paste into terminal', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'paste', 'clipboard'], + defaultBindings: { + darwin: ['Mod+V'], + linux: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'], + win32: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'] + } + }, + { + id: 'terminal.search', + title: 'Search active pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'search', 'find'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'terminal.clear', + title: 'Clear active pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'clear'], + defaultBindings: platformBindings(['Mod+K']) + }, + { + id: 'terminal.focusNextPane', + title: 'Focus next pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'focus', 'next'], + defaultBindings: platformBindings(['Mod+BracketRight']) + }, + { + id: 'terminal.focusPreviousPane', + title: 'Focus previous pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'focus', 'previous'], + defaultBindings: platformBindings(['Mod+BracketLeft']) + }, + { + id: 'terminal.equalizePaneSizes', + title: 'Equalize pane sizes', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'split', 'equalize', 'resize', 'balance', 'size'], + defaultBindings: platformBindings([]) + }, + { + id: 'terminal.expandPane', + title: 'Expand / collapse pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'], + defaultBindings: platformBindings(['Mod+Shift+Enter']) + }, + { + id: 'terminal.setTitle', + title: 'Set Title…', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'pane', 'set title', 'title', 'rename'], + defaultBindings: platformBindings([]) + } +] diff --git a/src/shared/keybindings/definitions-core-4.ts b/src/shared/keybindings/definitions-core-4.ts new file mode 100644 index 00000000000..9ed02c02d86 --- /dev/null +++ b/src/shared/keybindings/definitions-core-4.ts @@ -0,0 +1,70 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [ + { + id: 'terminal.clearPaneTitle', + title: 'Clear Pane Title', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'pane', 'clear title', 'remove title', 'title'], + defaultBindings: platformBindings([]) + }, + { + id: 'terminal.closePane', + title: 'Close active pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'close'], + defaultBindings: platformBindings(['Mod+W']) + }, + { + id: 'terminal.splitRight', + title: 'Split terminal right', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'split', 'right'], + defaultBindings: { + darwin: ['Mod+D'], + linux: ['Mod+Shift+D'], + win32: ['Mod+Shift+D'] + } + }, + { + id: 'terminal.splitDown', + title: 'Split terminal down', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'split', 'down'], + defaultBindings: { + darwin: ['Mod+Shift+D'], + linux: ['Alt+Shift+D'], + win32: ['Alt+Shift+D'] + } + }, + { + id: 'terminal.switchInputSource', + title: 'Switch input source / language (native)', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: [ + 'shortcut', + 'input', + 'source', + 'language', + 'korean', + 'english', + 'ime', + 'switch', + 'hangul', + 'layout' + ], + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + // Why: macOS uses Shift+Space as an input-source shortcut; Orca otherwise rejects Shift-only bindings to avoid stealing typed text. + allowShiftOnlyKeybindings: true + } +] diff --git a/src/shared/keybindings/definitions-support.ts b/src/shared/keybindings/definitions-support.ts new file mode 100644 index 00000000000..1ae8c683dce --- /dev/null +++ b/src/shared/keybindings/definitions-support.ts @@ -0,0 +1,9 @@ +import type { PlatformBindings } from './types' + +export function platformBindings(bindings: readonly string[]): PlatformBindings { + return { + darwin: bindings, + linux: bindings, + win32: bindings + } +} diff --git a/src/shared/keybindings/definitions.ts b/src/shared/keybindings/definitions.ts new file mode 100644 index 00000000000..842f77a6389 --- /dev/null +++ b/src/shared/keybindings/definitions.ts @@ -0,0 +1,92 @@ +import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from '../tui-agent-display-names' +import type { + AgentTabActionId, + KeybindingActionId, + KeybindingDefinition, + PluginKeybindingActionId +} from './types' +import type { TuiAgent } from '../tui-agent' +import { KEYBINDING_DEFINITION_CORE_1 } from './definitions-core-1' +import { KEYBINDING_DEFINITION_CORE_2 } from './definitions-core-2' +import { KEYBINDING_DEFINITION_CORE_3 } from './definitions-core-3' +import { KEYBINDING_DEFINITION_CORE_4 } from './definitions-core-4' + +export function agentTabActionId(agent: TuiAgent): AgentTabActionId { + return `tab.newAgent.${agent}` +} + +function buildAgentTabKeybindingDefinitions(): KeybindingDefinition[] { + return ALL_TUI_AGENTS.map((agent) => ({ + id: agentTabActionId(agent), + title: `New ${TUI_AGENT_DISPLAY_NAMES[agent]} tab`, + group: 'Agents', + scope: 'tabs', + searchKeywords: [ + 'shortcut', + 'tab', + 'agent', + 'new', + 'launch', + agent, + TUI_AGENT_DISPLAY_NAMES[agent].toLowerCase() + ], + defaultBindings: { darwin: [], linux: [], win32: [] } + })) +} + +export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ + ...KEYBINDING_DEFINITION_CORE_1, + ...KEYBINDING_DEFINITION_CORE_2, + ...KEYBINDING_DEFINITION_CORE_3, + ...KEYBINDING_DEFINITION_CORE_4, + ...buildAgentTabKeybindingDefinitions() +] + +/** Pre-swap tab-switch bindings; a one-time migration pins these for pre-release installs so upgrading users keep the shortcuts they learned. */ +export const LEGACY_TAB_SWITCH_BINDINGS: Readonly>> = { + 'tab.nextSameType': ['Mod+Shift+BracketRight'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'], + 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], + 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] +} + +export const DEFINITIONS_BY_ID = new Map( + KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition]) +) + +const DEFINITION_IDS = new Set( + KEYBINDING_DEFINITIONS.map((definition) => definition.id) +) + +// Why: these ids are single remappable rows whose chord is a representative — the digit canonicalizes to 1 but the binding fires for any 1-9. +export const DIGIT_INDEX_ACTION_IDS: readonly KeybindingActionId[] = [ + 'tab.selectByIndex', + 'workspace.selectByIndex' +] + +export const DIGIT_INDEX_KEY_PATTERN = /^[1-9]$/ + +export function isDigitIndexActionId(actionId: KeybindingActionId): boolean { + return DIGIT_INDEX_ACTION_IDS.includes(actionId) +} + +export function isKeybindingActionId(value: string): value is KeybindingActionId { + return DEFINITION_IDS.has(value as KeybindingActionId) || isPluginKeybindingActionId(value) +} + +export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId { + return ( + value.length <= 400 && + /^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test( + value + ) + ) +} + +export function getKeybindingDefinition(actionId: KeybindingActionId): KeybindingDefinition | null { + return DEFINITIONS_BY_ID.get(actionId) ?? null +} + +export function getKeybindingPlatform(platform: NodeJS.Platform): 'darwin' | 'linux' | 'win32' { + return platform === 'darwin' ? 'darwin' : platform === 'win32' ? 'win32' : 'linux' +} diff --git a/src/shared/keybindings/effective.ts b/src/shared/keybindings/effective.ts new file mode 100644 index 00000000000..24835d672b7 --- /dev/null +++ b/src/shared/keybindings/effective.ts @@ -0,0 +1,97 @@ +import type { + KeybindingActionId, + KeybindingDefinition, + KeybindingMatchOptions, + KeybindingOverrides, + TerminalShortcutPolicy +} from './types' +import { DEFINITIONS_BY_ID, getKeybindingPlatform } from './definitions' +import { + normalizeKeybindingWithOptions, + normalizeOptionsForAction, + canonicalizeDigitIndexBinding +} from './normalization' +import { isDigitIndexActionId } from './definitions' + +export function getDefaultBindings( + definition: KeybindingDefinition, + platform: NodeJS.Platform +): string[] { + return definition.defaultBindings[getKeybindingPlatform(platform)].map((binding) => { + const normalized = normalizeKeybindingWithOptions(binding, { + allowBareKeybindings: definition.allowBareKeybindings === true, + allowShiftOnlyKeybindings: definition.allowShiftOnlyKeybindings === true + }) + return normalized.ok ? normalized.value : binding + }) +} + +export function getEffectiveKeybindingsForAction( + actionId: KeybindingActionId, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides +): string[] { + const definition = DEFINITIONS_BY_ID.get(actionId) + const override = overrides?.[actionId] + if (Array.isArray(override)) { + // Why: canonicalize digit-index overrides to +1 so display/conflict stay consistent even if a hand-edited file stored a different digit. + if (isDigitIndexActionId(actionId)) { + const canonical: string[] = [] + for (const binding of override) { + const normalized = canonicalizeDigitIndexBinding(binding) + if (normalized.ok && !canonical.includes(normalized.value)) { + canonical.push(normalized.value) + } + } + return canonical + } + return override.flatMap((binding) => { + const normalized = normalizeKeybindingWithOptions( + binding, + normalizeOptionsForAction(actionId) + ) + return normalized.ok ? [normalized.value] : [] + }) + } + return definition ? getDefaultBindings(definition, platform) : [] +} + +export function getEffectiveKeybindingsForDefinition( + definition: KeybindingDefinition, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides +): string[] { + const override = overrides?.[definition.id] + if (Array.isArray(override)) { + return getEffectiveKeybindingsForAction(definition.id, platform, overrides) + } + return getDefaultBindings(definition, platform) +} + +export function normalizeTerminalShortcutPolicy( + policy: TerminalShortcutPolicy | null | undefined +): TerminalShortcutPolicy { + return policy === 'terminal-first' ? 'terminal-first' : 'orca-first' +} + +export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean { + return definition.scope === 'terminal' || definition.allowInTerminal === true +} + +export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean { + return definition.scope !== 'terminal' && definition.allowInTerminal !== true +} + +export function keybindingIsActiveInContext( + definition: KeybindingDefinition, + options: KeybindingMatchOptions = {} +): boolean { + if (options.context !== 'terminal') { + return true + } + // Why: Orca-first keeps app shortcuts inside terminals; terminal-first is the escape hatch for shells and TUIs. + if (normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first') { + return true + } + return isKeybindingAllowedInTerminal(definition) +} diff --git a/src/shared/keybindings/formatting.ts b/src/shared/keybindings/formatting.ts new file mode 100644 index 00000000000..68d1ef6e5a8 --- /dev/null +++ b/src/shared/keybindings/formatting.ts @@ -0,0 +1,206 @@ +import type { + FindKeybindingConflictOptions, + KeybindingActionId, + KeybindingConflict, + KeybindingDefinition, + KeybindingOverrides, + ModifierToken, + KeybindingScope +} from './types' +import { KEYBINDING_DEFINITIONS, isDigitIndexActionId } from './definitions' +import { parseKeybinding } from './parser' +import { getEffectiveKeybindingsForAction, getEffectiveKeybindingsForDefinition } from './effective' +import { isDoubleTapBinding } from './normalization' +import { isKeybindingActionId } from './definitions' +import { getKeybindingConflictIdentity, keybindingConflictIdentities } from './matching' + +function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { + switch (modifier) { + case 'Mod': + return isMac ? '⌘' : 'Ctrl' + case 'Cmd': + return isMac ? '⌘' : 'Cmd' + case 'Ctrl': + return isMac ? '⌃' : 'Ctrl' + case 'Alt': + return isMac ? '⌥' : 'Alt' + case 'Shift': + return isMac ? '⇧' : 'Shift' + } +} + +export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] { + const parsed = parseKeybinding(binding) + if (!parsed) { + return [binding] + } + const isMac = platform === 'darwin' + if (parsed.doubleTapModifier) { + const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac) + return [glyph, glyph] + } + const parts: string[] = [] + if (parsed.mod) { + parts.push(isMac ? '⌘' : 'Ctrl') + } + if (parsed.meta) { + parts.push(isMac ? '⌘' : 'Cmd') + } + if (parsed.control) { + parts.push(isMac ? '⌃' : 'Ctrl') + } + if (parsed.alt) { + parts.push(isMac ? '⌥' : 'Alt') + } + if (parsed.shift) { + parts.push(isMac ? '⇧' : 'Shift') + } + parts.push(formatKeyToken(parsed.key, isMac)) + return parts +} + +export function formatKeybindingList( + bindings: readonly string[], + platform: NodeJS.Platform +): string { + if (bindings.length === 0) { + return 'Unassigned' + } + return bindings + .map((binding) => { + const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+' + return formatKeybinding(binding, platform).join(separator) + }) + .join(', ') +} + +export function findKeybindingActionsForBinding( + binding: string, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + scopes: readonly KeybindingScope[] = ['global', 'tabs'] +): KeybindingActionId[] { + const identity = getKeybindingConflictIdentity(binding, platform) + const allowedScopes = new Set(scopes) + return KEYBINDING_DEFINITIONS.filter( + (definition) => + allowedScopes.has(definition.scope) && + getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) => + keybindingConflictIdentities(definition.id, candidate, platform).includes(identity) + ) + ).map((definition) => definition.id) +} + +function formatKeyToken(token: string, isMac: boolean): string { + const labels: Record = { + BracketLeft: '[', + BracketRight: ']', + Minus: '-', + Underscore: '_', + Equal: '=', + Plus: '+', + ArrowLeft: '←', + ArrowRight: '→', + ArrowUp: '↑', + ArrowDown: '↓', + PageUp: 'PageUp', + PageDown: 'PageDown', + NumpadAdd: 'Numpad +', + NumpadSubtract: 'Numpad -', + Comma: ',', + Period: '.', + Slash: '/', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Enter: 'Enter', + Backspace: isMac ? '⌫' : 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Tab: 'Tab', + Escape: 'Esc', + Space: 'Space' + } + return labels[token] ?? token +} + +export function findKeybindingConflicts( + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: FindKeybindingConflictOptions = {} +): KeybindingConflict[] { + return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options) +} + +export function findKeybindingConflictsForDefinitions( + definitions: readonly KeybindingDefinition[], + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: FindKeybindingConflictOptions = {} +): KeybindingConflict[] { + const owners = new Map }>() + const ignoredActionIds = new Set(options.ignoredActionIds ?? []) + const customizedActions = new Set( + Object.keys(overrides ?? {}).filter( + (actionId): actionId is KeybindingActionId => + isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId) + ) + ) + for (const actionId of options.relevantActionIds ?? []) { + if (!ignoredActionIds.has(actionId)) { + customizedActions.add(actionId) + } + } + for (const definition of definitions) { + if (ignoredActionIds.has(definition.id)) { + continue + } + for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) { + const groups = new Set([definition.conflictGroup ?? definition.scope]) + if (definition.conflictGroup) { + // Why: native menu accelerators can consume global chords, so check custom bindings against both the menu bucket and scope. + groups.add(definition.scope) + } + for (const group of groups) { + for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) { + const conflictKey = `${group}\u0000${identity}` + const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() } + if ( + !isDigitIndexActionId(definition.id) && + Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId)) + ) { + current.binding = binding + } + current.actionIds.add(definition.id) + owners.set(conflictKey, current) + } + } + } + } + + const seenConflictKeys = new Set() + return Array.from(owners.values()) + .filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions)) + .map(({ binding, actionIds }) => ({ + binding, + actionIds: Array.from(actionIds) + })) + .filter((conflict) => { + const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}` + if (seenConflictKeys.has(key)) { + return false + } + seenConflictKeys.add(key) + return true + }) +} + +function setIntersects(left: ReadonlySet, right: ReadonlySet): boolean { + for (const value of left) { + if (right.has(value)) { + return true + } + } + return false +} diff --git a/src/shared/keybindings/input.ts b/src/shared/keybindings/input.ts new file mode 100644 index 00000000000..5857cd80011 --- /dev/null +++ b/src/shared/keybindings/input.ts @@ -0,0 +1,261 @@ +import type { + KeybindingActionId, + KeybindingInput, + KeybindingValidationResult, + ModifierToken, + NormalizeKeybindingOptions, + PhysicalModifierToken +} from './types' +import { getKeybindingPlatform, isDigitIndexActionId } from './definitions' +import { + canonicalizeDigitIndexBinding, + normalizeKeybindingWithOptions, + normalizeOptionsForAction +} from './normalization' +import { hasModifier, normalizeKeyToken } from './parser' + +const MODIFIER_KEYS = new Set([ + 'Alt', + 'AltGraph', + 'Control', + 'Meta', + 'Shift', + 'OS', + 'Fn', + 'FnLock', + 'Hyper', + 'Super', + 'Symbol', + 'SymbolLock' +]) + +export const PUNCTUATION_KEY_TOKENS = new Set([ + 'BracketLeft', + 'BracketRight', + 'Minus', + 'Underscore', + 'Equal', + 'Plus', + 'Comma', + 'Period', + 'Slash', + 'Backslash', + 'Semicolon', + 'Quote', + 'Backquote' +]) + +export function isPunctuationKeyToken(token: string | null): token is string { + return token !== null && PUNCTUATION_KEY_TOKENS.has(token) +} + +const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified']) + +const SHIFTED_PUNCTUATION_KEY_TOKENS: Record = { + '<': 'Comma', + '>': 'Period', + '?': 'Slash', + '|': 'Backslash', + ':': 'Semicolon', + '"': 'Quote', + '~': 'Backquote' +} + +export function logicalKeyTokenFromInput(input: KeybindingInput): string | null { + const key = input.key ?? '' + if (MODIFIER_KEYS.has(key)) { + return null + } + const normalizedKey = normalizeKeyToken(key) + if (normalizedKey) { + return normalizedKey + } + if (hasModifier(input, 'shift')) { + return SHIFTED_PUNCTUATION_KEY_TOKENS[key] ?? null + } + return null +} + +export function canUsePhysicalCodeFallback(input: KeybindingInput): boolean { + // Why: layout-aware shortcuts trust real logical keys; physical code is only a fallback when the platform can't report the produced key. + return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '') +} + +export function isLatinShortcutKey(key: string): boolean { + // Why: A-Z / 0-9 are the only chars a Latin shortcut names; a non-Latin char (Cyrillic с, Greek π) is never a Latin remap, so physical-code fallback is safe. + if (key.length !== 1) { + return false + } + const upper = key.toUpperCase() + return (upper >= 'A' && upper <= 'Z') || (key >= '0' && key <= '9') +} + +export function shouldUseNonLatinShortcutPhysicalFallback( + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: non-Latin layouts report non-Latin logical keys for physical letters (#6274), breaking Ctrl/Meta shortcuts; fall back to the physical code. + if (getKeybindingPlatform(platform) === 'darwin') { + return false + } + const hasPrimaryModifier = hasModifier(input, 'control') || hasModifier(input, 'meta') + if (!hasPrimaryModifier) { + return false + } + // AltGr surfaces as Ctrl+Alt on Windows/Linux; treat it as text, not a chord. + if (hasModifier(input, 'control') && hasModifier(input, 'alt')) { + return false + } + if (logicalKeyTokenFromInput(input) !== null) { + return false + } + const key = input.key ?? '' + return key !== '' && !MODIFIER_KEYS.has(key) && !isLatinShortcutKey(key) +} + +export function canFallBackToPhysicalCode( + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + return ( + canUsePhysicalCodeFallback(input) || shouldUseNonLatinShortcutPhysicalFallback(input, platform) + ) +} + +export function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null { + const code = input.code ?? '' + if (code.startsWith('Key') && code.length === 4) { + return code.slice(3).toUpperCase() + } + if (code.startsWith('Digit') && code.length === 6) { + return code.slice(5) + } + + return normalizeKeyToken(code) +} + +export function numpadCodeKeyTokenFromInput(input: KeybindingInput): string | null { + const code = input.code ?? '' + return code === 'NumpadAdd' || code === 'NumpadSubtract' ? normalizeKeyToken(code) : null +} + +export function shouldUseMacOptionComposedCaptureFallback( + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: macOS Option+key reports composed characters (Option+C -> ç), so capturing Alt shortcuts needs the physical-code fallback. + if ( + getKeybindingPlatform(platform) !== 'darwin' || + !hasModifier(input, 'alt') || + MODIFIER_KEYS.has(input.key ?? '') + ) { + return false + } + const physicalToken = physicalCodeKeyTokenFromInput(input) + if (!physicalToken) { + return false + } + return ( + (physicalToken.length === 1 && physicalToken >= 'A' && physicalToken <= 'Z') || + isPunctuationKeyToken(physicalToken) + ) +} + +export function keyTokenFromInput( + input: KeybindingInput, + platform: NodeJS.Platform +): string | null { + const numpadKey = numpadCodeKeyTokenFromInput(input) + if (numpadKey) { + return numpadKey + } + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey) { + return logicalKey + } + if ( + !canUsePhysicalCodeFallback(input) && + !shouldUseMacOptionComposedCaptureFallback(input, platform) && + !shouldUseNonLatinShortcutPhysicalFallback(input, platform) + ) { + return null + } + return physicalCodeKeyTokenFromInput(input) +} + +// Why: the platform primary modifier canonicalizes to Mod (Cmd on macOS / Ctrl elsewhere), mirroring normal capture. +export function canonicalDoubleTapToken( + modifier: PhysicalModifierToken, + platform: NodeJS.Platform +): ModifierToken { + const isMac = platform === 'darwin' + if (modifier === 'Cmd' && isMac) { + return 'Mod' + } + if (modifier === 'Ctrl' && !isMac) { + return 'Mod' + } + return modifier +} + +export function keybindingFromInputWithOptions( + input: KeybindingInput, + platform: NodeJS.Platform, + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult { + if (input.doubleTapModifier) { + return normalizeKeybindingWithOptions( + `DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`, + options + ) + } + const key = keyTokenFromInput(input, platform) + if (!key) { + return { ok: false, error: 'Press a key, not only a modifier.' } + } + + const isMac = getKeybindingPlatform(platform) === 'darwin' + const parts: string[] = [] + const primaryModifierPressed = isMac ? hasModifier(input, 'meta') : hasModifier(input, 'control') + if (primaryModifierPressed) { + parts.push('Mod') + } + if (isMac && hasModifier(input, 'control')) { + parts.push('Ctrl') + } + if (!isMac && hasModifier(input, 'meta')) { + parts.push('Cmd') + } + if (hasModifier(input, 'alt')) { + parts.push('Alt') + } + if (hasModifier(input, 'shift')) { + parts.push('Shift') + } + parts.push(key) + + return normalizeKeybindingWithOptions(parts.join('+'), options) +} + +export function keybindingFromInput( + input: KeybindingInput, + platform: NodeJS.Platform +): KeybindingValidationResult { + return keybindingFromInputWithOptions(input, platform) +} + +export function keybindingFromInputForAction( + actionId: KeybindingActionId, + input: KeybindingInput, + platform: NodeJS.Platform +): KeybindingValidationResult { + const result = keybindingFromInputWithOptions( + input, + platform, + normalizeOptionsForAction(actionId) + ) + if (!result.ok || !isDigitIndexActionId(actionId)) { + return result + } + return canonicalizeDigitIndexBinding(result.value) +} diff --git a/src/shared/keybindings/matching-key.ts b/src/shared/keybindings/matching-key.ts new file mode 100644 index 00000000000..dd7a5c4bd14 --- /dev/null +++ b/src/shared/keybindings/matching-key.ts @@ -0,0 +1,187 @@ +import type { KeybindingInput, ModifierToken, ParsedKeybinding } from './types' +import { getKeybindingPlatform } from './definitions' +import { hasModifier } from './parser' +import { + canFallBackToPhysicalCode, + logicalKeyTokenFromInput, + physicalCodeKeyTokenFromInput, + numpadCodeKeyTokenFromInput, + isPunctuationKeyToken +} from './input' + +export function platformModifiers( + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): { meta: boolean; control: boolean; alt: boolean; shift: boolean } { + const isMac = platform === 'darwin' + return { + meta: parsed.meta || (parsed.mod && isMac), + control: parsed.control || (parsed.mod && !isMac), + alt: parsed.alt, + shift: parsed.shift + } +} + +export function modifierStateMatches( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + const expected = platformModifiers(parsed, platform) + return ( + hasModifier(input, 'meta') === expected.meta && + hasModifier(input, 'control') === expected.control && + hasModifier(input, 'alt') === expected.alt && + hasModifier(input, 'shift') === expected.shift + ) +} + +export function shouldUseMacOptionLetterPhysicalFallback( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: macOS Option+letter reports composed characters (Option+A -> å), leaving no logical Latin key for Alt shortcuts. + return ( + getKeybindingPlatform(platform) === 'darwin' && + parsed.alt && + hasModifier(input, 'alt') && + logicalKeyTokenFromInput(input) === null + ) +} + +export function shouldUseMacOptionPunctuationPhysicalFallback( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: macOS Option+punctuation reports composed dead-key values, leaving no logical bracket token for Alt shortcuts. + return ( + getKeybindingPlatform(platform) === 'darwin' && + parsed.alt && + hasModifier(input, 'alt') && + logicalKeyTokenFromInput(input) === null + ) +} + +export function letterKeyMatches( + input: KeybindingInput, + letter: string, + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): boolean { + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey && logicalKey.length === 1 && logicalKey >= 'A' && logicalKey <= 'Z') { + return logicalKey === letter.toUpperCase() + } + return ( + (canFallBackToPhysicalCode(input, platform) || + shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) && + input.code === `Key${letter.toUpperCase()}` + ) +} + +export function digitKeyMatches( + input: KeybindingInput, + digit: string, + platform: NodeJS.Platform +): boolean { + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') { + return logicalKey === digit + } + return canFallBackToPhysicalCode(input, platform) && input.code === `Digit${digit}` +} + +export function semanticPunctuationKey(input: KeybindingInput): string | null { + const logicalKey = logicalKeyTokenFromInput(input) + return isPunctuationKeyToken(logicalKey) ? logicalKey : null +} + +export function physicalPunctuationKey(input: KeybindingInput): string | null { + const physicalKey = physicalCodeKeyTokenFromInput(input) + return isPunctuationKeyToken(physicalKey) ? physicalKey : null +} + +export function shouldUseSemanticPunctuation( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: Windows/Linux expose AltGr as Ctrl+Alt; don't turn international text input into Mod+Alt app shortcuts. + if ( + getKeybindingPlatform(platform) !== 'darwin' && + parsed.mod && + parsed.alt && + hasModifier(input, 'control') && + hasModifier(input, 'alt') && + !hasModifier(input, 'meta') && + physicalPunctuationKey(input) === null + ) { + return false + } + return true +} + +export function keyMatches( + parsedKey: string, + input: KeybindingInput, + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): boolean { + if (parsedKey.length === 1 && parsedKey >= 'A' && parsedKey <= 'Z') { + return letterKeyMatches(input, parsedKey, parsed, platform) + } + if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') { + return digitKeyMatches(input, parsedKey, platform) + } + + if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') { + return ( + numpadCodeKeyTokenFromInput(input) === parsedKey || + logicalKeyTokenFromInput(input) === parsedKey + ) + } + + if (isPunctuationKeyToken(parsedKey)) { + // Why: shortcut labels name logical punctuation, but international layouts can report it from different physical codes. + const semanticKey = semanticPunctuationKey(input) + if (semanticKey !== null) { + if (!shouldUseSemanticPunctuation(parsed, input, platform)) { + return false + } + return semanticKey === parsedKey + } + return ( + (canFallBackToPhysicalCode(input, platform) || + shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) && + physicalPunctuationKey(input) === parsedKey + ) + } + + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey !== null) { + return logicalKey === parsedKey + } + return ( + canFallBackToPhysicalCode(input, platform) && physicalCodeKeyTokenFromInput(input) === parsedKey + ) +} + +export function resolveModifierToken( + modifier: ModifierToken, + platform: NodeJS.Platform +): 'meta' | 'control' | 'alt' | 'shift' { + switch (modifier) { + case 'Mod': + return platform === 'darwin' ? 'meta' : 'control' + case 'Cmd': + return 'meta' + case 'Ctrl': + return 'control' + case 'Alt': + return 'alt' + case 'Shift': + return 'shift' + } +} diff --git a/src/shared/keybindings/matching.ts b/src/shared/keybindings/matching.ts new file mode 100644 index 00000000000..eb825724e55 --- /dev/null +++ b/src/shared/keybindings/matching.ts @@ -0,0 +1,140 @@ +import type { + KeybindingActionId, + KeybindingInput, + KeybindingOverrides, + KeybindingMatchOptions, + ParsedKeybinding +} from './types' +import { DEFINITIONS_BY_ID, DIGIT_INDEX_KEY_PATTERN, isDigitIndexActionId } from './definitions' +import { canonicalizeParsedKeybinding, parseKeybinding } from './parser' +import { + platformModifiers, + modifierStateMatches, + keyMatches, + digitKeyMatches +} from './matching-key' +import { resolveModifierToken } from './matching-key' +import { getEffectiveKeybindingsForAction, keybindingIsActiveInContext } from './effective' + +export function keybindingMatchesInput( + binding: string, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + const parsed = parseKeybinding(binding) + if (!parsed) { + return false + } + // A double-tap binding matches only a synthetic double-tap input (and vice-versa), resolved per platform. + if (parsed.doubleTapModifier) { + return ( + input.doubleTapModifier !== undefined && + resolveModifierToken(parsed.doubleTapModifier, platform) === + resolveModifierToken(input.doubleTapModifier, platform) + ) + } + if (input.doubleTapModifier !== undefined) { + return false + } + return ( + modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform) + ) +} + +export function keybindingConflictIdentityForParsed( + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): string { + if (parsed.doubleTapModifier) { + return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}` + } + const modifiers = platformModifiers(parsed, platform) + return [ + modifiers.meta ? 'Meta' : '', + modifiers.control ? 'Control' : '', + modifiers.alt ? 'Alt' : '', + modifiers.shift ? 'Shift' : '', + parsed.key + ].join('+') +} + +export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string { + const parsed = parseKeybinding(binding) + return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding +} + +export function keybindingConflictIdentities( + actionId: KeybindingActionId, + binding: string, + platform: NodeJS.Platform +): readonly string[] { + const exact = getKeybindingConflictIdentity(binding, platform) + if (!isDigitIndexActionId(actionId)) { + return [exact] + } + const parsed = parseKeybinding(binding) + if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { + return [exact] + } + return Array.from({ length: 9 }, (_, index) => + keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform) + ) +} + +export function keybindingMatchesAction( + actionId: KeybindingActionId, + input: KeybindingInput, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: KeybindingMatchOptions = {} +): boolean { + const definition = DEFINITIONS_BY_ID.get(actionId) + if (!definition) { + return false + } + if (!keybindingIsActiveInContext(definition, options)) { + return false + } + return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) => + keybindingMatchesInput(binding, input, platform) + ) +} + +export function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { + for (let value = 1; value <= 9; value++) { + const digit = String(value) + if (digitKeyMatches(input, digit, platform)) { + return digit + } + } + return null +} + +// Why: a digit-index row's representative chord fires for any 1-9 — reuse its modifiers with the pressed digit via the normal matcher. +export function matchKeybindingDigitIndex( + actionId: KeybindingActionId, + input: KeybindingInput, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: KeybindingMatchOptions = {} +): number | null { + const definition = DEFINITIONS_BY_ID.get(actionId) + if (!definition || !keybindingIsActiveInContext(definition, options)) { + return null + } + const digit = digitFromInput(input, platform) + if (!digit) { + return null + } + for (const binding of getEffectiveKeybindingsForAction(actionId, platform, overrides)) { + const parsed = parseKeybinding(binding) + if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { + continue + } + const candidate = canonicalizeParsedKeybinding({ ...parsed, key: digit }) + if (keybindingMatchesInput(candidate, input, platform)) { + return Number(digit) - 1 + } + } + return null +} diff --git a/src/shared/keybindings/normalization.ts b/src/shared/keybindings/normalization.ts new file mode 100644 index 00000000000..bb069603424 --- /dev/null +++ b/src/shared/keybindings/normalization.ts @@ -0,0 +1,158 @@ +import type { + KeybindingActionId, + KeybindingValidationResult, + NormalizeKeybindingOptions +} from './types' +import { DEFINITIONS_BY_ID, DIGIT_INDEX_KEY_PATTERN, isDigitIndexActionId } from './definitions' +import { canonicalizeParsedKeybinding, isSafeBareKey, parseKeybinding } from './parser' + +export function normalizeKeybindingWithOptions( + binding: string, + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult { + const parsed = parseKeybinding(binding) + if (!parsed) { + return { ok: false, error: 'Use a shortcut like Ctrl+Shift+P or Cmd+K.' } + } + if (parsed.mod && (parsed.meta || parsed.control)) { + return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' } + } + if (parsed.doubleTapModifier) { + return { ok: true, value: canonicalizeParsedKeybinding(parsed) } + } + const isShiftInsert = parsed.shift && parsed.key === 'Insert' + const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) + const isShiftOnlyAllowed = + options.allowShiftOnlyKeybindings === true && + parsed.shift && + !parsed.mod && + !parsed.meta && + !parsed.control && + !parsed.alt + if ( + !parsed.mod && + !parsed.meta && + !parsed.control && + !parsed.alt && + !isShiftInsert && + !isBareAllowed && + !isShiftOnlyAllowed + ) { + return { ok: false, error: 'Include at least one modifier key.' } + } + return { ok: true, value: canonicalizeParsedKeybinding(parsed) } +} + +export function normalizeKeybinding(binding: string): KeybindingValidationResult { + return normalizeKeybindingWithOptions(binding) +} + +export function isDoubleTapBinding(binding: string): boolean { + return Boolean(parseKeybinding(binding)?.doubleTapModifier) +} + +export function normalizeKeybindingListWithOptions( + input: string, + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult | string[] { + const trimmed = input.trim() + if (!trimmed) { + return [] + } + const normalized: string[] = [] + for (const piece of trimmed.split(',')) { + const result = normalizeKeybindingWithOptions(piece, options) + if (!result.ok) { + return result + } + if (!normalized.includes(result.value)) { + normalized.push(result.value) + } + } + return normalized +} + +export function normalizeKeybindingList(input: string): KeybindingValidationResult | string[] { + return normalizeKeybindingListWithOptions(input) +} + +export function normalizeKeybindingArrayWithOptions( + input: readonly string[], + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult | string[] { + const normalized: string[] = [] + for (const binding of input) { + const piece = normalizeKeybindingListWithOptions(binding, options) + if (!Array.isArray(piece)) { + return piece + } + for (const normalizedBinding of piece) { + if (!normalized.includes(normalizedBinding)) { + normalized.push(normalizedBinding) + } + } + } + return normalized +} + +export function normalizeOptionsForAction( + actionId: KeybindingActionId +): NormalizeKeybindingOptions { + const definition = DEFINITIONS_BY_ID.get(actionId) + return { + allowBareKeybindings: definition?.allowBareKeybindings === true, + allowShiftOnlyKeybindings: definition?.allowShiftOnlyKeybindings === true + } +} + +// Why: rewrite a digit-index chord's key to 1 so display and conflict detection stay stable across the 1-9 range; reject any non 1-9 key. +export function canonicalizeDigitIndexBinding(binding: string): KeybindingValidationResult { + const parsed = parseKeybinding(binding) + if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { + return { + ok: false, + error: 'Pick a number key 1–9 with a modifier, like Cmd+1 or Ctrl+1.' + } + } + return { ok: true, value: canonicalizeParsedKeybinding({ ...parsed, key: '1' }) } +} + +export function finalizeDigitIndexBindings( + actionId: KeybindingActionId, + result: KeybindingValidationResult | string[] +): KeybindingValidationResult | string[] { + if (!isDigitIndexActionId(actionId) || !Array.isArray(result)) { + return result + } + const canonical: string[] = [] + for (const binding of result) { + const normalized = canonicalizeDigitIndexBinding(binding) + if (!normalized.ok) { + return normalized + } + if (!canonical.includes(normalized.value)) { + canonical.push(normalized.value) + } + } + return canonical +} + +export function normalizeKeybindingListForAction( + actionId: KeybindingActionId, + input: string +): KeybindingValidationResult | string[] { + return finalizeDigitIndexBindings( + actionId, + normalizeKeybindingListWithOptions(input, normalizeOptionsForAction(actionId)) + ) +} + +export function normalizeKeybindingArrayForAction( + actionId: KeybindingActionId, + input: readonly string[] +): KeybindingValidationResult | string[] { + return finalizeDigitIndexBindings( + actionId, + normalizeKeybindingArrayWithOptions(input, normalizeOptionsForAction(actionId)) + ) +} diff --git a/src/shared/keybindings/parser.ts b/src/shared/keybindings/parser.ts new file mode 100644 index 00000000000..022aa4bec29 --- /dev/null +++ b/src/shared/keybindings/parser.ts @@ -0,0 +1,261 @@ +import type { KeybindingInput, ModifierToken, ParsedKeybinding } from './types' + +export function hasModifier( + input: KeybindingInput, + modifier: 'alt' | 'meta' | 'control' | 'shift' +): boolean { + if (modifier === 'alt') { + return Boolean(input.alt ?? input.altKey) + } + if (modifier === 'meta') { + return Boolean(input.meta ?? input.metaKey) + } + if (modifier === 'control') { + return Boolean(input.control ?? input.ctrlKey) + } + return Boolean(input.shift ?? input.shiftKey) +} + +function isFunctionKeyToken(key: string): boolean { + return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) +} + +export function normalizeKeyToken(token: string): string | null { + if (token === ' ') { + return 'Space' + } + const trimmed = token.trim() + if (!trimmed) { + return null + } + const upper = trimmed.toUpperCase() + if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { + return upper + } + if (upper.length === 1 && upper >= '0' && upper <= '9') { + return upper + } + // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). + if (isFunctionKeyToken(upper)) { + return upper + } + + const simple: Record = { + '[': 'BracketLeft', + ']': 'BracketRight', + '{': 'BracketLeft', + '}': 'BracketRight', + '-': 'Minus', + _: 'Underscore', + '=': 'Equal', + '+': 'Plus', + ',': 'Comma', + '.': 'Period', + '/': 'Slash', + '\\': 'Backslash', + ';': 'Semicolon', + "'": 'Quote', + '`': 'Backquote', + RETURN: 'Enter', + ESC: 'Escape', + SPACEBAR: 'Space', + PGUP: 'PageUp', + PGDN: 'PageDown', + PLUS: 'Plus', + MINUS: 'Minus', + EQUAL: 'Equal', + UNDERSCORE: 'Underscore', + ARROWLEFT: 'ArrowLeft', + LEFT: 'ArrowLeft', + ARROWRIGHT: 'ArrowRight', + RIGHT: 'ArrowRight', + ARROWUP: 'ArrowUp', + UP: 'ArrowUp', + ARROWDOWN: 'ArrowDown', + DOWN: 'ArrowDown', + PAGEUP: 'PageUp', + PAGEDOWN: 'PageDown', + BACKSPACE: 'Backspace', + DELETE: 'Delete', + DEL: 'Delete', + INSERT: 'Insert', + INS: 'Insert', + ENTER: 'Enter', + TAB: 'Tab', + ESCAPE: 'Escape', + SPACE: 'Space', + BRACKETLEFT: 'BracketLeft', + BRACKETRIGHT: 'BracketRight', + NUMPADADD: 'NumpadAdd', + NUMPADSUBTRACT: 'NumpadSubtract', + ADD: 'NumpadAdd', + SUBTRACT: 'NumpadSubtract', + COMMA: 'Comma', + PERIOD: 'Period', + SLASH: 'Slash', + BACKSLASH: 'Backslash', + SEMICOLON: 'Semicolon', + QUOTE: 'Quote', + BACKQUOTE: 'Backquote' + } + + return simple[upper] ?? null +} + +export function parseModifierToken(rawPart: string): ModifierToken | null { + const part = rawPart.toLowerCase() + if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { + return 'Mod' + } + if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { + return 'Cmd' + } + if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { + return 'Ctrl' + } + if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { + return 'Alt' + } + if (part === 'shift' || rawPart === '⇧') { + return 'Shift' + } + return null +} + +export function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void { + if (modifier === 'Mod') { + parsed.mod = true + } else if (modifier === 'Cmd') { + parsed.meta = true + } else if (modifier === 'Ctrl') { + parsed.control = true + } else if (modifier === 'Alt') { + parsed.alt = true + } else { + parsed.shift = true + } +} + +export function emptyParsedKeybinding(): ParsedKeybinding { + return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' } +} + +// Why: a double-tap is a bare modifier with no key, so it can't use the normal parse path; modifier validation is deferred to normalize. +export function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null { + const modifiers: ModifierToken[] = [] + let sawDoubleTap = false + for (const rawPart of rawParts) { + if (rawPart.toLowerCase() === 'doubletap') { + if (sawDoubleTap) { + return null + } + sawDoubleTap = true + continue + } + const modifier = parseModifierToken(rawPart) + if (!modifier) { + return null + } + modifiers.push(modifier) + } + if (modifiers.length === 0) { + return null + } + const parsed = emptyParsedKeybinding() + for (const modifier of modifiers) { + applyModifierToken(parsed, modifier) + } + // Keep both flags when Mod is combined with a platform modifier, so normalize emits the shared "Mod or platform-specific, not both" error. + if (parsed.mod && (parsed.meta || parsed.control)) { + parsed.doubleTapModifier = 'Mod' + return parsed + } + if (modifiers.length > 1) { + return null + } + parsed.doubleTapModifier = modifiers[0] + return parsed +} + +export function parseKeybinding(binding: string): ParsedKeybinding | null { + const rawParts = binding + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + if (rawParts.length === 0) { + return null + } + + if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) { + return parseDoubleTapKeybinding(rawParts) + } + + const parsed = emptyParsedKeybinding() + for (const rawPart of rawParts) { + const modifier = parseModifierToken(rawPart) + if (modifier) { + applyModifierToken(parsed, modifier) + continue + } + if (parsed.key) { + return null + } + const key = normalizeKeyToken(rawPart) + if (!key) { + return null + } + parsed.key = key + } + + return parsed.key ? parsed : null +} + +export function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { + if (parsed.doubleTapModifier) { + return `DoubleTap+${parsed.doubleTapModifier}` + } + const parts: string[] = [] + if (parsed.mod) { + parts.push('Mod') + } + if (parsed.meta) { + parts.push('Cmd') + } + if (parsed.control) { + parts.push('Ctrl') + } + if (parsed.alt) { + parts.push('Alt') + } + if (parsed.shift) { + parts.push('Shift') + } + parts.push(parsed.key) + return parts.join('+') +} + +export function isSafeBareKey(parsed: ParsedKeybinding): boolean { + if (parsed.mod || parsed.meta || parsed.control || parsed.alt) { + return false + } + // Function keys produce no text, so they're safe bare or with Shift (Shift+letter stays unsafe). + if (parsed.shift) { + return isFunctionKeyToken(parsed.key) + } + return ( + isFunctionKeyToken(parsed.key) || + [ + 'Backspace', + 'Delete', + 'Enter', + 'Escape', + 'Tab', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown' + ].includes(parsed.key) + ) +} diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts new file mode 100644 index 00000000000..b42d311f0f0 --- /dev/null +++ b/src/shared/keybindings/types.ts @@ -0,0 +1,200 @@ +import type { TuiAgent } from '../tui-agent' + +export type KeybindingScope = + | 'global' + | 'tabs' + | 'terminal' + | 'browser' + | 'editor' + | 'fileExplorer' + | 'composer' + | 'settings' + +export type KeybindingContext = 'app' | 'terminal' | 'browser' + +export type KeybindingPlatform = 'darwin' | 'linux' | 'win32' + +export type TerminalShortcutPolicy = 'orca-first' | 'terminal-first' + +export type KeybindingMatchOptions = { + context?: KeybindingContext + terminalShortcutPolicy?: TerminalShortcutPolicy +} + +export type AgentTabActionId = `tab.newAgent.${TuiAgent}` +export type PluginKeybindingActionId = `plugin:${string}` + +export type KeybindingActionId = + | 'worktree.quickOpen' + | 'worktree.palette' + | 'worktree.navigateUp' + | 'worktree.navigateDown' + | 'app.settings' + | 'app.forceReload' + | 'workspace.create' + | 'workspace.rename' + | 'workspace.delete' + | 'workspace.openBoard' + | 'workspace.selectByIndex' + | 'voice.dictation' + | 'view.tasks' + | 'dashboard.toggle' + | 'sidebar.left.toggle' + | 'sidebar.right.toggle' + | 'sidebar.explorer.toggle' + | 'sidebar.search.toggle' + | 'sidebar.sourceControl.toggle' + | 'sidebar.checks.toggle' + | 'sidebar.ports.toggle' + | 'sidebar.sleepingWorkspaces.toggle' + | 'sidebar.focusWorktreeList' + | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' + | 'zoom.in' + | 'zoom.out' + | 'zoom.reset' + | 'worktree.history.back' + | 'worktree.history.forward' + | 'tab.newTerminal' + | 'tab.newAgent' + | AgentTabActionId + | 'tab.newBrowser' + | 'tab.newSimulator' + | 'tab.newMarkdown' + | 'tab.openMarkdown' + | 'tab.close' + | 'tab.closeAll' + | 'tab.rename' + | 'tab.reopenClosed' + | 'tab.nextSameType' + | 'tab.previousSameType' + | 'tab.nextAllTypes' + | 'tab.previousAllTypes' + | 'tab.previousRecent' + | 'tab.nextTerminal' + | 'tab.previousTerminal' + | 'tab.selectByIndex' + | 'tab.openQuickCommandsMenu' + | 'browser.find' + | 'browser.back' + | 'browser.forward' + | 'browser.reload' + | 'browser.hardReload' + | 'browser.focusAddressBar' + | 'browser.grabElement' + | 'editor.find' + | 'editor.replace' + | 'editor.save' + | 'editor.markdownPreview' + | 'editor.toggleWordWrap' + | 'editor.copyContext' + | 'editor.previousChange' + | 'editor.nextChange' + | 'editor.addReviewNote' + | 'sourceControl.sendReviewNotes' + | 'fileExplorer.undo' + | 'fileExplorer.redo' + | 'fileExplorer.copyPath' + | 'fileExplorer.copyRelativePath' + | 'fileExplorer.delete' + | 'settings.search' + | 'terminal.copySelection' + | 'terminal.selectAll' + | 'terminal.paste' + | 'terminal.search' + | 'terminal.clear' + | 'terminal.focusNextPane' + | 'terminal.focusPreviousPane' + | 'terminal.equalizePaneSizes' + | 'terminal.expandPane' + | 'terminal.setTitle' + | 'terminal.clearPaneTitle' + | 'terminal.closePane' + | 'terminal.splitRight' + | 'terminal.splitDown' + | 'terminal.switchInputSource' + | PluginKeybindingActionId + +export type KeybindingOverrides = Partial> + +export type KeybindingFileDiagnostic = { + severity: 'warning' | 'error' + message: string + actionId?: string + section?: string +} + +export type KeybindingFileSnapshot = { + path: string + platform: KeybindingPlatform + exists: boolean + overrides: KeybindingOverrides + commonOverrides: KeybindingOverrides + platformOverrides: Partial> + diagnostics: KeybindingFileDiagnostic[] +} + +export type PlatformBindings = { + darwin: readonly string[] + linux: readonly string[] + win32: readonly string[] +} + +export type KeybindingDefinition = { + id: KeybindingActionId + title: string + group: string + scope: KeybindingScope + searchKeywords: readonly string[] + defaultBindings: PlatformBindings + allowInTerminal?: boolean + allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean + conflictGroup?: string +} + +export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift' +export type PhysicalModifierToken = Exclude + +export type KeybindingInput = { + key?: string + code?: string + alt?: boolean + meta?: boolean + control?: boolean + shift?: boolean + altKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean + // Set only by the double-tap detector; always a physical token (never 'Mod'). + doubleTapModifier?: PhysicalModifierToken +} + +export type ParsedKeybinding = { + mod: boolean + meta: boolean + control: boolean + alt: boolean + shift: boolean + key: string + doubleTapModifier?: ModifierToken +} + +export type NormalizeKeybindingOptions = { + allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean +} + +export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } + +export type KeybindingConflict = { + binding: string + actionIds: KeybindingActionId[] +} + +export type FindKeybindingConflictOptions = { + ignoredActionIds?: Iterable + relevantActionIds?: Iterable +} From 08b8f271e6b03c6dc61a8ac1831acbea57d33e00 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:48:01 -0700 Subject: [PATCH 02/93] fix(renderer): merge split runtime imports (cherry picked from commit 90bf505afe919f6c2cd96b45e24db7c9d878c3ca) --- .../apply-preparation-groups.ts | 6 ++++-- .../src/runtime/web-session-tabs-sync/tracking.ts | 5 +++-- .../slices/agent-status-live-entry-builder.ts | 2 +- .../slices/browser/browser-tab-focus-actions.ts | 8 ++++++-- .../slices/ui/ui-slice-contract-preferences.ts | 8 +++----- .../store/slices/ui/ui-slice-feature-actions.ts | 6 ++++-- .../store/slices/ui/ui-slice-hydration-actions.ts | 14 +++++++------- .../slices/ui/ui-slice-hydration-sanitizers.ts | 4 ++-- src/shared/keybindings/effective.ts | 3 +-- src/shared/keybindings/formatting.ts | 3 +-- src/shared/keybindings/matching.ts | 4 ++-- 11 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts index db5904fe9e6..d47d119646c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts @@ -1,9 +1,11 @@ import type { TabGroup } from '../../../../shared/tab-types' import type { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified' import { toWebTerminalSurfaceTabId } from '../web-runtime-session' -import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' +import { + isWebSessionBrowserPlacementGroupReserved, + peekWebSessionBrowserPlacementGroup +} from '../web-session-browser-placement' import { peekWebSessionTerminalPlacementGroup } from '../web-session-terminal-placement' -import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' import { reconcileClientOwnedTabPlacement } from '../web-session-client-owned-tab-placement' import { buildMirroredHostGroups, retainClientPlacedMirroredTabs } from './layout-groups' import { sanitizeRecentTabIds, pushRecentTabId } from './state-equality-core' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts index 9b9c8cfb03f..ecbd20bba7c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -2,8 +2,8 @@ import type { RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' -import { VISIBILITY_INVENTORY_REMOVAL_EPOCH, type SessionTabsListAllResult } from './state' import { + VISIBILITY_INVENTORY_REMOVAL_EPOCH, latestReceivedSessionTabsInventoryFrameByEnvironment, latestReceivedSessionTabsSnapshotByWorktree, latestSessionTabsRemovalFenceByWorktree, @@ -13,10 +13,11 @@ import { sessionTabsPublicationEpochHistoryByWorktree, sessionTabsRecoveryStateByWorktree, trackedSessionTabsWorktreeIdsByEnvironment, + nextReceivedSessionTabsFrame, type SnapshotFreshness, + type SessionTabsListAllResult, type TrackedWebSessionTabsWorktree } from './state' -import { nextReceivedSessionTabsFrame } from './state' import { acceptSessionTabsRuntimeId, isCurrentSessionTabsRuntimeId, diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 871728f0d81..14c74b8d23f 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -10,6 +10,7 @@ import { agentProviderSessionsEqual, getAgentResumeArgv, isResumableTuiAgent, + type AgentProviderSessionMetadata, type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' @@ -24,7 +25,6 @@ import type { AgentStatusRouting, AgentStatusTiming } from './agent-status-contract' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { registryEntryMatchesStatus } from './agent-status-launch-config' import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' diff --git a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts index 53ba2faae2e..0d6f3cdd1cc 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts @@ -1,5 +1,9 @@ -import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' -import type { ClosedBrowserWorkspaceSnapshot } from './browser-slice-contract' +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + ClosedBrowserWorkspaceSnapshot +} from './browser-slice-contract' import { findWorkspace } from '../browser-page-records' import { restoreRecentlyClosedTabPosition } from '../recently-closed-tabs' import { isLocalBrowserPageOwner } from './browser-host-state' diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index 8d7561b7642..97ab0cb484b 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -6,7 +6,9 @@ import type { StatusBarItem, WorktreeCardMode, WorktreeCardProperty, - WorkspaceHostOrder + WorkspaceHostOrder, + WorkspaceHostScope, + VisibleWorkspaceHostIds } from '../../../../../shared/ui-chrome-types' import type { UsagePercentageDisplay } from '../../../../../shared/usage-percentage-display' import type { AutomationHostFilter } from '../../../../../shared/automation-host-filter' @@ -17,10 +19,6 @@ import type { ReleaseChannel } from '../../../../../shared/release-channel' import type { ChangelogData, UpdateStatus } from '../../../../../shared/update-status-types' import type { StatusBarUsageMode } from '../../../../../shared/status-bar-usage-mode' import type { PersistedUIWriteBaseline } from '../persisted-ui-write-baseline' -import type { - WorkspaceHostScope, - VisibleWorkspaceHostIds -} from '../../../../../shared/ui-chrome-types' import type { UISliceCore } from './ui-slice-contract-core' export type UISlicePreferences = { diff --git a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts index ee96ec86d46..3cc57ce8361 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts @@ -1,6 +1,8 @@ import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' -import { mergeFeatureInteractionState } from './ui-slice-hydration-values' -import { mergeContextualTourSeenIds } from './ui-slice-hydration-values' +import { + mergeFeatureInteractionState, + mergeContextualTourSeenIds +} from './ui-slice-hydration-values' import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-tour-helpers' import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 41dfbaa079c..7902d1bbba8 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -12,16 +12,16 @@ import { import { normalizeFeatureInteractions } from '../../../../../shared/feature-interactions' import { normalizeContextualTourIds } from '../../../../../shared/contextual-tours' import { normalizeFeatureTipIds } from '../../../../../shared/feature-tips' -import { DEFAULT_HIDE_SLEEPING_WORKSPACES } from '../../../../../shared/constants' +import { + DEFAULT_HIDE_SLEEPING_WORKSPACES, + normalizeWorktreeCardProperties, + normalizeAgentActivityDisplayMode +} from '../../../../../shared/constants' import { clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, normalizeWorkspaceStatuses } from '../../../../../shared/workspace-statuses' -import { - normalizeWorktreeCardProperties, - normalizeAgentActivityDisplayMode -} from '../../../../../shared/constants' import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../../shared/pet-types' import { clampMarkdownTocPanelWidth } from '../../../../../shared/markdown-toc-panel-width' import { clampCombinedDiffFileTreeWidth } from '../../../../../shared/combined-diff-file-tree-width' @@ -53,10 +53,10 @@ import { sanitizeWorkspaceCleanupDismissals, sanitizePersistedSidebarWidth, hydratedUIPartialMatchesState, - migrateStatusBarItems + migrateStatusBarItems, + clampPetSize } from './ui-slice-hydration-sanitizers' import { sanitizeTaskResumeState } from './ui-slice-hydration-values' -import { clampPetSize } from './ui-slice-hydration-sanitizers' const MAX_LEFT_SIDEBAR_WIDTH = 500 const MAX_RIGHT_SIDEBAR_WIDTH = 4000 diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index c557a75f246..8d81fdd4d53 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -4,7 +4,8 @@ import type { PersistedUIState } from '../../../../../shared/persisted-ui-state- import type { TaskViewPresetId, TopLevelView, - VisibleWorkspaceHostIds + VisibleWorkspaceHostIds, + StatusBarItem } from '../../../../../shared/ui-chrome-types' import type { WorkspaceCleanupDismissal } from '../../../../../shared/workspace-cleanup' import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION } from '../../../../../shared/workspace-cleanup' @@ -15,7 +16,6 @@ import { } from '../../../../../shared/execution-host' import { persistedUIValuesEqual } from '../../../../../shared/persisted-ui-equality' import { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants' -import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' import type { UISlice } from './ui-slice-contract' const MIN_SIDEBAR_WIDTH = 220 diff --git a/src/shared/keybindings/effective.ts b/src/shared/keybindings/effective.ts index 24835d672b7..23489eb296e 100644 --- a/src/shared/keybindings/effective.ts +++ b/src/shared/keybindings/effective.ts @@ -5,13 +5,12 @@ import type { KeybindingOverrides, TerminalShortcutPolicy } from './types' -import { DEFINITIONS_BY_ID, getKeybindingPlatform } from './definitions' +import { DEFINITIONS_BY_ID, getKeybindingPlatform, isDigitIndexActionId } from './definitions' import { normalizeKeybindingWithOptions, normalizeOptionsForAction, canonicalizeDigitIndexBinding } from './normalization' -import { isDigitIndexActionId } from './definitions' export function getDefaultBindings( definition: KeybindingDefinition, diff --git a/src/shared/keybindings/formatting.ts b/src/shared/keybindings/formatting.ts index 68d1ef6e5a8..1c3c75ba8fd 100644 --- a/src/shared/keybindings/formatting.ts +++ b/src/shared/keybindings/formatting.ts @@ -7,11 +7,10 @@ import type { ModifierToken, KeybindingScope } from './types' -import { KEYBINDING_DEFINITIONS, isDigitIndexActionId } from './definitions' +import { KEYBINDING_DEFINITIONS, isDigitIndexActionId, isKeybindingActionId } from './definitions' import { parseKeybinding } from './parser' import { getEffectiveKeybindingsForAction, getEffectiveKeybindingsForDefinition } from './effective' import { isDoubleTapBinding } from './normalization' -import { isKeybindingActionId } from './definitions' import { getKeybindingConflictIdentity, keybindingConflictIdentities } from './matching' function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { diff --git a/src/shared/keybindings/matching.ts b/src/shared/keybindings/matching.ts index eb825724e55..872569a292d 100644 --- a/src/shared/keybindings/matching.ts +++ b/src/shared/keybindings/matching.ts @@ -11,9 +11,9 @@ import { platformModifiers, modifierStateMatches, keyMatches, - digitKeyMatches + digitKeyMatches, + resolveModifierToken } from './matching-key' -import { resolveModifierToken } from './matching-key' import { getEffectiveKeybindingsForAction, keybindingIsActiveInContext } from './effective' export function keybindingMatchesInput( From ca12be19217b51fdca157a0d4bfc9b52f303ec40 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:29:33 -0700 Subject: [PATCH 03/93] chore(max-lines): prune runtime baseline entries (cherry picked from commit 08820a4386f8f308883f0ededde501c61c51298d) --- config/max-lines-baseline.txt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9ff781a6068..78762850ef1 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -20,14 +20,6 @@ inline src/main/updater.ts inline src/preload/index.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts -inline src/renderer/src/runtime/sync-runtime-graph.ts -inline src/renderer/src/runtime/web-session-tabs-sync.ts -inline src/renderer/src/store/slices/agent-status.ts -inline src/renderer/src/store/slices/browser.ts -inline src/renderer/src/store/slices/linear.ts -inline src/renderer/src/store/slices/tabs.ts -inline src/renderer/src/store/slices/ui.ts -inline src/shared/keybindings.ts mobile-config app/h/*/files/*.tsx mobile-config app/h/*/source-control/*.tsx mobile-config app/index.tsx From 488f66c56ae44b46f986ef21e9b3f989a5292450 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:15:22 -0700 Subject: [PATCH 04/93] fix(renderer): preserve accepted visibility snapshots (cherry picked from commit bc5f8a5ac5b8e8ad3fc59250795842e663bad0ee) --- .../active-session-subscription.ts | 6 +++--- .../global-session-subscription.ts | 6 +++--- .../web-session-tabs-sync/use-web-session-tabs-sync.ts | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 8c7850b2885..6c4580bde39 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -63,7 +63,7 @@ export type ActiveSubscriptionArgs = { runtimeId?: string ) => boolean > - visibilitySnapshotRecord: Ref< + visibilitySnapshotAccepted: Ref< ( environmentId: string, snapshot: RuntimeMobileSessionTabsResult, @@ -82,7 +82,7 @@ export function installActiveSessionTabsSubscription({ workspaceSessionReady, visibilitySnapshotReceipt, visibilitySnapshotApply, - visibilitySnapshotRecord + visibilitySnapshotAccepted }: ActiveSubscriptionArgs): (() => void) | undefined { const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() if ( @@ -182,7 +182,7 @@ export function installActiveSessionTabsSubscription({ recovered, event.type === 'updated' && !replayed ) - visibilitySnapshotRecord.current(environmentId, recovered, receivedFrame, runtimeId) + visibilitySnapshotAccepted.current(environmentId, recovered, receivedFrame, runtimeId) } try { if (isCurrent() && bootstrap) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts index 52de44f7300..f0483c0407f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts @@ -41,7 +41,7 @@ export type GlobalSubscriptionRefs = { runtimeId?: string ) => boolean > - snapshotRecord: Ref< + snapshotAccepted: Ref< ( environmentId: string, snapshot: RuntimeMobileSessionTabsResult, @@ -122,7 +122,7 @@ export function installGlobalSessionTabsSubscriptions({ }) refs.snapshotReceipt.current = coordinator.recordSnapshotReceipt.bind(coordinator) refs.snapshotApply.current = coordinator.shouldApplySnapshot.bind(coordinator) - refs.snapshotRecord.current = coordinator.recordSnapshot.bind(coordinator) + refs.snapshotAccepted.current = coordinator.recordSnapshot.bind(coordinator) for (const environment of environments) { const { @@ -215,7 +215,7 @@ export function installGlobalSessionTabsSubscriptions({ return () => { refs.snapshotReceipt.current = () => {} refs.snapshotApply.current = () => true - refs.snapshotRecord.current = () => {} + refs.snapshotAccepted.current = () => {} dispose() for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { const owner = { environmentId, pairingRevision: expectedEnvironmentPairingRevision } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts index bb3da4fa56f..d11e259538d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts @@ -24,9 +24,9 @@ export function useWebSessionTabsSync(): void { const visibilitySnapshotApplyRef = useRef( () => true ) - const visibilitySnapshotRecordRef = useRef( - () => {} - ) + const visibilitySnapshotAcceptedRef = useRef< + GlobalSubscriptionRefs['snapshotAccepted']['current'] + >(() => {}) const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) @@ -86,7 +86,7 @@ export function useWebSessionTabsSync(): void { visibilityResumeOmissions: visibilityResumeOmissionsRef, snapshotReceipt: visibilitySnapshotReceiptRef, snapshotApply: visibilitySnapshotApplyRef, - snapshotRecord: visibilitySnapshotRecordRef, + snapshotAccepted: visibilitySnapshotAcceptedRef, ownerRevisions: ownerRevisionsRef } }) @@ -101,7 +101,7 @@ export function useWebSessionTabsSync(): void { workspaceSessionReady, visibilitySnapshotReceipt: visibilitySnapshotReceiptRef, visibilitySnapshotApply: visibilitySnapshotApplyRef, - visibilitySnapshotRecord: visibilitySnapshotRecordRef + visibilitySnapshotAccepted: visibilitySnapshotAcceptedRef }) }, [ activeWorktreeId, From d1abe28471effc4f3a190811b8aaa312abb86852 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:28:18 -0700 Subject: [PATCH 05/93] refactor(preload): split bridge API modules (cherry picked from commit b77e31873bc8bc5683703be1bfdc90b35f13f9f0) --- config/tsconfig.tc.web.json | 11 + src/preload/api/agent-awake-bridge.ts | 13 + src/preload/api/agent-status-bridge.ts | 88 + src/preload/api/agent-trust-bridge.ts | 9 + src/preload/api/ai-vault-bridge.ts | 34 + src/preload/api/app-bridge.ts | 76 + src/preload/api/automations-bridge.ts | 59 + src/preload/api/bitbucket-bridge.ts | 16 + src/preload/api/browser-bridge-part-1.ts | 197 + src/preload/api/browser-bridge-part-2.ts | 165 + src/preload/api/browser-bridge.ts | 4 + src/preload/api/cache-bridge.ts | 7 + src/preload/api/claude-accounts-bridge.ts | 18 + src/preload/api/claude-usage-bridge.ts | 4 + src/preload/api/cli-bridge.ts | 14 + src/preload/api/codex-accounts-bridge.ts | 32 + src/preload/api/codex-config-sync-bridge.ts | 6 + src/preload/api/codex-usage-bridge.ts | 4 + .../api/computer-use-permissions-bridge.ts | 8 + src/preload/api/crash-reports-bridge.ts | 31 + src/preload/api/dashboard-bridge.ts | 74 + .../api/developer-permissions-bridge.ts | 11 + src/preload/api/diagnostics-bridge.ts | 15 + src/preload/api/doc-preview-bridge.ts | 31 + src/preload/api/e2e-bridge.ts | 5 + src/preload/api/emulator-bridge.ts | 98 + src/preload/api/ephemeral-vm-bridge.ts | 25 + src/preload/api/export-bridge.ts | 10 + src/preload/api/feedback-bridge.ts | 13 + src/preload/api/folder-workspaces-bridge.ts | 10 + src/preload/api/fs-bridge.ts | 219 + src/preload/api/gh-bridge-part-1.ts | 193 + src/preload/api/gh-bridge-part-2.ts | 202 + src/preload/api/gh-bridge.ts | 4 + src/preload/api/git-bash-bridge.ts | 5 + src/preload/api/git-bridge.ts | 200 + src/preload/api/gl-bridge.ts | 3 + src/preload/api/grok-accounts-bridge.ts | 6 + src/preload/api/hooks-bridge.ts | 44 + src/preload/api/hosted-review-bridge.ts | 12 + src/preload/api/jira-bridge.ts | 110 + src/preload/api/keybindings-bridge.ts | 20 + src/preload/api/linear-bridge.ts | 143 + .../api/localhost-worktree-labels-bridge.ts | 11 + src/preload/api/macos-tcc-prompts-bridge.ts | 17 + src/preload/api/memory-bridge.ts | 6 + src/preload/api/minimax-credentials-bridge.ts | 10 + src/preload/api/mobile-bridge.ts | 95 + src/preload/api/native-chat-bridge.ts | 40 + src/preload/api/notebook-bridge.ts | 11 + src/preload/api/notifications-bridge.ts | 120 + src/preload/api/onboarding-bridge.ts | 11 + src/preload/api/open-code-usage-bridge.ts | 4 + src/preload/api/orca-profiles-bridge.ts | 21 + src/preload/api/pet-bridge.ts | 11 + src/preload/api/platform-bridge.ts | 17 + src/preload/api/plugins-bridge.ts | 63 + src/preload/api/preflight-bridge.ts | 43 + src/preload/api/project-groups-bridge.ts | 22 + src/preload/api/projects-bridge.ts | 12 + src/preload/api/pty-bridge-part-1.ts | 207 + src/preload/api/pty-bridge-part-2.ts | 141 + src/preload/api/pty-bridge.ts | 4 + src/preload/api/pwsh-bridge.ts | 5 + src/preload/api/rate-limits-bridge.ts | 30 + src/preload/api/remote-workspace-bridge.ts | 19 + src/preload/api/repos-bridge.ts | 87 + src/preload/api/runtime-bridge.ts | 144 + .../api/runtime-environments-bridge.ts | 93 + src/preload/api/session-bridge.ts | 16 + src/preload/api/settings-bridge.ts | 39 + src/preload/api/shell-bridge.ts | 41 + src/preload/api/skills-bridge.ts | 129 + src/preload/api/sparse-presets-bridge.ts | 16 + src/preload/api/speech-bridge.ts | 81 + src/preload/api/ssh-bridge.ts | 183 + src/preload/api/star-nag-bridge.ts | 30 + src/preload/api/stats-bridge.ts | 10 + src/preload/api/terminal-preview-bridge.ts | 33 + src/preload/api/ui-bridge-part-1.ts | 167 + src/preload/api/ui-bridge-part-2.ts | 203 + src/preload/api/ui-bridge-part-3.ts | 214 + src/preload/api/ui-bridge-part-4.ts | 195 + src/preload/api/ui-bridge-part-5.ts | 7 + src/preload/api/ui-bridge.ts | 14 + src/preload/api/updater-bridge.ts | 36 + src/preload/api/workspace-cleanup-bridge.ts | 36 + src/preload/api/workspace-ports-bridge.ts | 16 + src/preload/api/workspace-space-bridge.ts | 17 + src/preload/api/worktrees-bridge.ts | 117 + src/preload/api/wsl-bridge.ts | 6 + src/preload/index.ts | 5518 +---------------- src/preload/preload-runtime-support.ts | 165 + 93 files changed, 5442 insertions(+), 5340 deletions(-) create mode 100644 src/preload/api/agent-awake-bridge.ts create mode 100644 src/preload/api/agent-status-bridge.ts create mode 100644 src/preload/api/agent-trust-bridge.ts create mode 100644 src/preload/api/ai-vault-bridge.ts create mode 100644 src/preload/api/app-bridge.ts create mode 100644 src/preload/api/automations-bridge.ts create mode 100644 src/preload/api/bitbucket-bridge.ts create mode 100644 src/preload/api/browser-bridge-part-1.ts create mode 100644 src/preload/api/browser-bridge-part-2.ts create mode 100644 src/preload/api/browser-bridge.ts create mode 100644 src/preload/api/cache-bridge.ts create mode 100644 src/preload/api/claude-accounts-bridge.ts create mode 100644 src/preload/api/claude-usage-bridge.ts create mode 100644 src/preload/api/cli-bridge.ts create mode 100644 src/preload/api/codex-accounts-bridge.ts create mode 100644 src/preload/api/codex-config-sync-bridge.ts create mode 100644 src/preload/api/codex-usage-bridge.ts create mode 100644 src/preload/api/computer-use-permissions-bridge.ts create mode 100644 src/preload/api/crash-reports-bridge.ts create mode 100644 src/preload/api/dashboard-bridge.ts create mode 100644 src/preload/api/developer-permissions-bridge.ts create mode 100644 src/preload/api/diagnostics-bridge.ts create mode 100644 src/preload/api/doc-preview-bridge.ts create mode 100644 src/preload/api/e2e-bridge.ts create mode 100644 src/preload/api/emulator-bridge.ts create mode 100644 src/preload/api/ephemeral-vm-bridge.ts create mode 100644 src/preload/api/export-bridge.ts create mode 100644 src/preload/api/feedback-bridge.ts create mode 100644 src/preload/api/folder-workspaces-bridge.ts create mode 100644 src/preload/api/fs-bridge.ts create mode 100644 src/preload/api/gh-bridge-part-1.ts create mode 100644 src/preload/api/gh-bridge-part-2.ts create mode 100644 src/preload/api/gh-bridge.ts create mode 100644 src/preload/api/git-bash-bridge.ts create mode 100644 src/preload/api/git-bridge.ts create mode 100644 src/preload/api/gl-bridge.ts create mode 100644 src/preload/api/grok-accounts-bridge.ts create mode 100644 src/preload/api/hooks-bridge.ts create mode 100644 src/preload/api/hosted-review-bridge.ts create mode 100644 src/preload/api/jira-bridge.ts create mode 100644 src/preload/api/keybindings-bridge.ts create mode 100644 src/preload/api/linear-bridge.ts create mode 100644 src/preload/api/localhost-worktree-labels-bridge.ts create mode 100644 src/preload/api/macos-tcc-prompts-bridge.ts create mode 100644 src/preload/api/memory-bridge.ts create mode 100644 src/preload/api/minimax-credentials-bridge.ts create mode 100644 src/preload/api/mobile-bridge.ts create mode 100644 src/preload/api/native-chat-bridge.ts create mode 100644 src/preload/api/notebook-bridge.ts create mode 100644 src/preload/api/notifications-bridge.ts create mode 100644 src/preload/api/onboarding-bridge.ts create mode 100644 src/preload/api/open-code-usage-bridge.ts create mode 100644 src/preload/api/orca-profiles-bridge.ts create mode 100644 src/preload/api/pet-bridge.ts create mode 100644 src/preload/api/platform-bridge.ts create mode 100644 src/preload/api/plugins-bridge.ts create mode 100644 src/preload/api/preflight-bridge.ts create mode 100644 src/preload/api/project-groups-bridge.ts create mode 100644 src/preload/api/projects-bridge.ts create mode 100644 src/preload/api/pty-bridge-part-1.ts create mode 100644 src/preload/api/pty-bridge-part-2.ts create mode 100644 src/preload/api/pty-bridge.ts create mode 100644 src/preload/api/pwsh-bridge.ts create mode 100644 src/preload/api/rate-limits-bridge.ts create mode 100644 src/preload/api/remote-workspace-bridge.ts create mode 100644 src/preload/api/repos-bridge.ts create mode 100644 src/preload/api/runtime-bridge.ts create mode 100644 src/preload/api/runtime-environments-bridge.ts create mode 100644 src/preload/api/session-bridge.ts create mode 100644 src/preload/api/settings-bridge.ts create mode 100644 src/preload/api/shell-bridge.ts create mode 100644 src/preload/api/skills-bridge.ts create mode 100644 src/preload/api/sparse-presets-bridge.ts create mode 100644 src/preload/api/speech-bridge.ts create mode 100644 src/preload/api/ssh-bridge.ts create mode 100644 src/preload/api/star-nag-bridge.ts create mode 100644 src/preload/api/stats-bridge.ts create mode 100644 src/preload/api/terminal-preview-bridge.ts create mode 100644 src/preload/api/ui-bridge-part-1.ts create mode 100644 src/preload/api/ui-bridge-part-2.ts create mode 100644 src/preload/api/ui-bridge-part-3.ts create mode 100644 src/preload/api/ui-bridge-part-4.ts create mode 100644 src/preload/api/ui-bridge-part-5.ts create mode 100644 src/preload/api/ui-bridge.ts create mode 100644 src/preload/api/updater-bridge.ts create mode 100644 src/preload/api/workspace-cleanup-bridge.ts create mode 100644 src/preload/api/workspace-ports-bridge.ts create mode 100644 src/preload/api/workspace-space-bridge.ts create mode 100644 src/preload/api/worktrees-bridge.ts create mode 100644 src/preload/api/wsl-bridge.ts create mode 100644 src/preload/preload-runtime-support.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index ea5b5f31a5c..afe5e83024c 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -6,6 +6,17 @@ "../src/renderer/src/**/*.tsx", "../src/preload/api-types.ts", "../src/preload/api/**/*", + "../src/preload/browser-client-page-renderer-requests.ts", + "../src/preload/browser-find-subscriptions.ts", + "../src/preload/close-active-tab-payload-admission.ts", + "../src/preload/e2e-config.ts", + "../src/preload/gitlab.ts", + "../src/preload/preload-runtime-support.ts", + "../src/preload/renderer-heap-statistics-reader.ts", + "../src/preload/renderer-process-memory-reader.ts", + "../src/preload/renderer-restart-wiring.ts", + "../src/preload/runtime-environment-subscriptions.ts", + "../src/preload/usage-provider-api.ts", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", "../src/main/ipc/worktree-branch-name.ts", diff --git a/src/preload/api/agent-awake-bridge.ts b/src/preload/api/agent-awake-bridge.ts new file mode 100644 index 00000000000..eff4263398e --- /dev/null +++ b/src/preload/api/agent-awake-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' +import type { ComputerAwakeStatus } from '../../shared/computer-awake-mode' +import type { PreloadApi } from '../api-types' + +export const agentAwakeApi = { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } +} satisfies PreloadApi['agentAwake'] diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts new file mode 100644 index 00000000000..5bc0757c5ca --- /dev/null +++ b/src/preload/api/agent-status-bridge.ts @@ -0,0 +1,88 @@ +import { ipcRenderer } from 'electron' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' + +export const agentStatusApi = { + /** Listen for agent status updates forwarded from native hook receivers. */ + onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => + callback(data) + ipcRenderer.on('agentStatus:set', listener) + return () => ipcRenderer.removeListener('agentStatus:set', listener) + }, + onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => + callback(data) + ipcRenderer.on('agentStatus:clear', listener) + return () => ipcRenderer.removeListener('agentStatus:clear', listener) + }, + /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ + getSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getSnapshot'), + inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferInterrupt', request), + inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), + onMigrationUnsupported: ( + callback: (entry: MigrationUnsupportedPtyEntry) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => + callback(entry) + ipcRenderer.on('agentStatus:migrationUnsupported', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) + }, + onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) + }, + onLegacyWorkerTerminalRecovery: ( + callback: (data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + } + ) => callback(data) + ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) + return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) + }, + getMigrationUnsupportedSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), + /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ + drop: (paneKey: string): void => { + ipcRenderer.send('agentStatus:drop', paneKey) + }, + reconcileEndedProcess: (paneKey: string): void => { + ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) + }, + /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ + dropByTabPrefix: (tabId: string): void => { + ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) + }, + retirePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) + }, + restorePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) + }, + transferPaneAuthority: (args: { + fromPaneKey: string + toPaneKey: string + ptyId?: string + }): void => { + ipcRenderer.send('agentStatus:transferPaneAuthority', args) + } +} diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts new file mode 100644 index 00000000000..f27146d9cd3 --- /dev/null +++ b/src/preload/api/agent-trust-bridge.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' + +export const agentTrustApi = { + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) +} diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts new file mode 100644 index 00000000000..ea9b2e1be14 --- /dev/null +++ b/src/preload/api/ai-vault-bridge.ts @@ -0,0 +1,34 @@ +import { ipcRenderer } from 'electron' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../../shared/ai-vault-session-deletion' +import type { + AiVaultFirstUserPromptArgs, + AiVaultListArgs, + AiVaultSubagentListArgs +} from '../../shared/ai-vault-types' +import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' + +export const aiVaultApi = { + listSessions: (args?: AiVaultListArgs): Promise => + ipcRenderer.invoke('aiVault:listSessions', args), + resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => + ipcRenderer.invoke('aiVault:resolveSessionTitles', args), + cancelListSessions: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('aiVault:cancelListSessions', args), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + ipcRenderer.invoke('aiVault:prepareSessionResume', args), + listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => + ipcRenderer.invoke('aiVault:listSubagentSessions', args), + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + deleteSession: (args: AiVaultDeleteSessionArgs): Promise => + ipcRenderer.invoke('aiVault:deleteSession', args), + onWindowFocused: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('aiVault:windowFocused', listener) + return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) + } +} diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts new file mode 100644 index 00000000000..705d24e4bda --- /dev/null +++ b/src/preload/api/app-bridge.ts @@ -0,0 +1,76 @@ +import { ipcRenderer } from 'electron' +import type { AppIdentity } from '../../shared/app-identity' +import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' +import type { MacCapturedDigitRowChord } from '../../shared/macos-symbolic-hotkeys' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { WriteTerminalRenderDesyncEvidenceArgs } from '../../shared/terminal-render-desync-evidence' +import { + KEYBOARD_LAYOUT_CHANGED_CHANNEL, + type KeyboardLayoutChangeEvent +} from '../../shared/keyboard-layout-events' +import { prepareAndInvokeAppRestart } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint, startupDiagnosticsEnabled } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const appApi = { + getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), + getFeatureWallAssetBaseUrl: (): Promise => + ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), + relaunch: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:relaunch'), + awaitBeforeUnloadCheckpoint + ), + restart: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:restart'), + awaitBeforeUnloadCheckpoint + ), + reload: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:reload'), + awaitBeforeUnloadCheckpoint + ), + stageBeforeUnloadSync: (args: Parameters[0]) => { + const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown } + if (result?.ok !== true) { + throw new Error('Failed to stage renderer state before unload.') + } + }, + awaitFirstWindowStartupServices: (): Promise => + ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + prepareTerminalStartupRestoration: (): Promise => + ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), + recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => + ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), + startupDiagnostic: (event: string, details?: Record): Promise => + startupDiagnosticsEnabled + ? ipcRenderer.invoke('app:startupDiagnostic', event, details) + : Promise.resolve(), + getKeyboardInputSourceId: (): Promise => + ipcRenderer.invoke('app:getKeyboardInputSourceId'), + getMacCapturedDigitRowChords: (): Promise => + ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), + getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), + onKeyboardLayoutChanged: (callback: (event: KeyboardLayoutChangeEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: KeyboardLayoutChangeEvent): void => + callback(event) + ipcRenderer.on(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) + return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) + }, + setUnreadDockBadgeCount: (count: number): Promise => + ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), + getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => + ipcRenderer.invoke('app:getFloatingTerminalCwd', args), + getFloatingMarkdownDirectory: (): Promise => + ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), + pickFloatingMarkdownDocument: (): Promise => + ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), + pickFloatingWorkspaceDirectory: (): Promise => + ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), + writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => + ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) +} diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts new file mode 100644 index 00000000000..87bec12a8e9 --- /dev/null +++ b/src/preload/api/automations-bridge.ts @@ -0,0 +1,59 @@ +import { ipcRenderer } from 'electron' +import type { ExternalAutomationManagerResult } from '../api-types' +import type { + AutomationDispatchRequest, + AutomationDispatchResult, + ExternalAutomationRunsPage, + AutomationRun, + AutomationPrecheckResult +} from '../../shared/automations-types' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import type { + ScopedExternalManagerActionRequest, + ScopedExternalManagerCreateRequest, + ScopedExternalManagerListRequest, + ScopedExternalManagerRunsRequest, + ScopedExternalManagerUpdateRequest +} from '../../shared/external-automation-scope' +import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' + +export const automationsApi = { + listExternalManagerForOwner: ( + request: ScopedExternalManagerListRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalManagerForOwner', request), + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalRunsForOwner', request), + createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => + ipcRenderer.invoke('automations:createExternalForOwner', request), + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => + ipcRenderer.invoke('automations:updateExternalForOwner', request), + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => + ipcRenderer.invoke('automations:runExternalActionForOwner', request), + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => + ipcRenderer.invoke('automations:retainExternalScopes', request), + runPrecheck: (args: { + automationId: string + runId: string + }): Promise => + ipcRenderer.invoke('automations:runPrecheck', args), + markDispatchResult: (result: AutomationDispatchResult): Promise => + ipcRenderer.invoke('automations:markDispatchResult', result), + snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => + ipcRenderer.invoke('automations:snapshotWorkspaceName', args), + rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), + onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => + callback(request) + ipcRenderer.on('automations:dispatchRequested', listener) + return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) + }, + onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => + callback(payload) + ipcRenderer.on('automations:changed', listener) + return () => ipcRenderer.removeListener('automations:changed', listener) + } +} diff --git a/src/preload/api/bitbucket-bridge.ts b/src/preload/api/bitbucket-bridge.ts new file mode 100644 index 00000000000..cf51ce453df --- /dev/null +++ b/src/preload/api/bitbucket-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' + +export const bitbucketApi = { + connect: (args: { + authMode: 'token' | 'basic' + accessToken?: string | null + email?: string | null + apiToken?: string | null + baseUrl?: string | null + }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => + ipcRenderer.invoke('bitbucket:connect', args), + + disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), + + status: (): Promise => ipcRenderer.invoke('bitbucket:status') +} diff --git a/src/preload/api/browser-bridge-part-1.ts b/src/preload/api/browser-bridge-part-1.ts new file mode 100644 index 00000000000..47883976338 --- /dev/null +++ b/src/preload/api/browser-bridge-part-1.ts @@ -0,0 +1,197 @@ +import { ipcRenderer } from 'electron' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' +import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' +import { browserClientPageRendererRequests } from '../preload-runtime-support' + +export const browserApiPart1 = { + onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, + readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), + registerGuest: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => + ipcRenderer.invoke('browser:isGuestRegistered', args), + repairGuestRegistration: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), + unregisterGuest: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:unregisterGuest', args), + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: BrowserWebAuthnAccountRequest + ): void => callback(request) + ipcRenderer.on('browser:webauthn-account-requested', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) + }, + onWebAuthnAccountRequestClosed: ( + callback: (event: { requestId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => + callback(data) + ipcRenderer.on('browser:webauthn-account-request-closed', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) + }, + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => + ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + openDevTools: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:openDevTools', args), + setViewportOverride: (args: { + browserPageId: string + override: BrowserViewportOverride | null + }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + reportViewportScrollState: (args: { + browserPageId: string + state: { + scrollLeft: number + scrollTop: number + maxScrollLeft: number + maxScrollTop: number + } + }): void => ipcRenderer.send('browser:reportViewportScrollState', args), + setAnnotationViewportBridge: (args): Promise => + ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), + publishClientPageMetadata: (args) => + ipcRenderer.invoke('browser:publishClientPageMetadata', args), + onGuestLoadFailed: ( + callback: (args: { + browserPageId: string + loadError: { code: number; description: string; validatedUrl: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + loadError: { code: number; description: string; validatedUrl: string } + } + ) => callback(data) + ipcRenderer.on('browser:guest-load-failed', listener) + return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) + }, + onCertificateFailureChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:certificate-failure-changed', listener) + return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) + }, + proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + onPermissionDenied: ( + callback: (event: { browserPageId: string; permission: string; origin: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; permission: string; origin: string } + ) => callback(data) + ipcRenderer.on('browser:permission-denied', listener) + return () => ipcRenderer.removeListener('browser:permission-denied', listener) + }, + onPopup: ( + callback: (event: { + browserPageId: string + origin: string + action: 'opened-in-orca' | 'opened-external' | 'blocked' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + origin: string + action: 'opened-in-orca' | 'opened-external' | 'blocked' + } + ) => callback(data) + ipcRenderer.on('browser:popup', listener) + return () => ipcRenderer.removeListener('browser:popup', listener) + }, + onDownloadRequested: ( + callback: (event: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + savePath: string + status: 'downloading' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + savePath: string + status: 'downloading' + } + ) => callback(data) + ipcRenderer.on('browser:download-requested', listener) + return () => ipcRenderer.removeListener('browser:download-requested', listener) + }, + onDownloadProgress: ( + callback: (event: { + browserPageId?: string + downloadId: string + receivedBytes: number + totalBytes: number | null + state: 'progressing' | 'interrupted' | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId?: string + downloadId: string + receivedBytes: number + totalBytes: number | null + state: 'progressing' | 'interrupted' | null + } + ) => callback(data) + ipcRenderer.on('browser:download-progress', listener) + return () => ipcRenderer.removeListener('browser:download-progress', listener) + }, + onDownloadFinished: ( + callback: (event: { + browserPageId?: string + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + remoteDestination?: { workspaceRelativePath: string; hostLabel: string } + error: string | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId?: string + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + remoteDestination?: { workspaceRelativePath: string; hostLabel: string } + error: string | null + } + ) => callback(data) + ipcRenderer.on('browser:download-finished', listener) + return () => ipcRenderer.removeListener('browser:download-finished', listener) + } +} diff --git a/src/preload/api/browser-bridge-part-2.ts b/src/preload/api/browser-bridge-part-2.ts new file mode 100644 index 00000000000..07788c59d2e --- /dev/null +++ b/src/preload/api/browser-bridge-part-2.ts @@ -0,0 +1,165 @@ +import { ipcRenderer } from 'electron' + +export const browserApiPart2 = { + onContextMenuRequested: ( + callback: (event: { + browserPageId: string + x: number + y: number + screenX: number + screenY: number + pageUrl: string + linkUrl: string | null + selectionText: string + canGoBack: boolean + canGoForward: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + x: number + y: number + screenX: number + screenY: number + pageUrl: string + linkUrl: string | null + selectionText: string + canGoBack: boolean + canGoForward: boolean + } + ) => callback(data) + ipcRenderer.on('browser:context-menu-requested', listener) + return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) + }, + onContextMenuDismissed: (callback: (event: { browserPageId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => + callback(data) + ipcRenderer.on('browser:context-menu-dismissed', listener) + return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) + }, + onNavigationUpdate: ( + callback: (event: { browserPageId: string; url: string; title: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string; title: string } + ) => callback(data) + ipcRenderer.on('browser:navigation-update', listener) + return () => ipcRenderer.removeListener('browser:navigation-update', listener) + }, + onActivateView: ( + callback: (data: { worktreeId?: string; browserPageId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId?: string; browserPageId?: string } + ) => callback(data) + ipcRenderer.on('browser:activateView', listener) + return () => ipcRenderer.removeListener('browser:activateView', listener) + }, + onPaneFocus: ( + callback: (data: { worktreeId: string | null; browserPageId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string | null; browserPageId: string } + ) => callback(data) + ipcRenderer.on('browser:pane-focus', listener) + return () => ipcRenderer.removeListener('browser:pane-focus', listener) + }, + onOpenLinkInOrcaTab: ( + callback: (event: { browserPageId: string; url: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string } + ) => callback(data) + ipcRenderer.on('browser:open-link-in-orca-tab', listener) + return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) + }, + cancelDownload: (args: { downloadId: string }): Promise => + ipcRenderer.invoke('browser:cancelDownload', args), + setGrabMode: (args: { + browserPageId: string + enabled: boolean + }): Promise<{ ok: true } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:setGrabMode', args), + awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => + ipcRenderer.invoke('browser:awaitGrabSelection', args), + cancelGrab: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:cancelGrab', args), + captureSelectionScreenshot: (args: { + browserPageId: string + rect: { x: number; y: number; width: number; height: number } + }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + extractHoverPayload: (args: { + browserPageId: string + }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:extractHoverPayload', args), + onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => + callback(browserPageId) + ipcRenderer.on('browser:grabModeToggle', listener) + return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) + }, + onGrabActionShortcut: ( + callback: (args: { browserPageId: string; key: 'c' | 's' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; key: 'c' | 's' } + ) => callback(data) + ipcRenderer.on('browser:grabActionShortcut', listener) + return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) + }, + sessionListProfiles: (): Promise => ipcRenderer.invoke('browser:session:listProfiles'), + prepareSshWorkspacePartition: (args: { + targetId: string + browserProfileId?: string + skipProbe?: boolean + }): Promise<{ partition: string }> => + ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), + sessionCreateProfile: (args: { + scope: 'default' | 'isolated' | 'imported' + label: string + userAgentMode?: 'clean' | 'native' + }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + sessionDeleteProfile: (args: { profileId: string }): Promise => + ipcRenderer.invoke('browser:session:deleteProfile', args), + sessionImportCookies: (args: { + profileId: string + }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:session:importCookies', args), + sessionResolvePartition: (args: { profileId: string | null }): Promise => + ipcRenderer.invoke('browser:session:resolvePartition', args), + sessionDetectBrowsers: (): Promise => + ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsersForClientHost: (args: { + environmentId: string + }): Promise => + ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), + sessionImportFromBrowser: (args: { + profileId: string + browserFamily: string + }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:session:importFromBrowser', args), + sessionImportFromBrowserForClientHost: (args: { + environmentId: string + profileId: string + browserFamily: string + browserProfile?: string + }): Promise< + { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null + > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + sessionClientRouteImportSources: (args: { + environmentId: string + }): Promise> => + ipcRenderer.invoke('browser:session:clientRouteImportSources', args), + sessionClearDefaultCookies: (): Promise => + ipcRenderer.invoke('browser:session:clearDefaultCookies'), + notifyActiveTabChanged: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:activeTabChanged', args) +} diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts new file mode 100644 index 00000000000..9b72b1658d3 --- /dev/null +++ b/src/preload/api/browser-bridge.ts @@ -0,0 +1,4 @@ +import { browserApiPart1 } from './browser-bridge-part-1' +import { browserApiPart2 } from './browser-bridge-part-2' + +export const browserApi = { ...browserApiPart1, ...browserApiPart2 } diff --git a/src/preload/api/cache-bridge.ts b/src/preload/api/cache-bridge.ts new file mode 100644 index 00000000000..73fd9db88c0 --- /dev/null +++ b/src/preload/api/cache-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const cacheApi = { + getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), + setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) +} satisfies PreloadApi['cache'] diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts new file mode 100644 index 00000000000..8200c17791d --- /dev/null +++ b/src/preload/api/claude-accounts-bridge.ts @@ -0,0 +1,18 @@ +import { ipcRenderer } from 'electron' + +export const claudeAccountsApi = { + list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('claudeAccounts:add', args), + cancelPendingLogin: (): Promise => + ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), + reauthenticate: (args: { accountId: string }): Promise => + ipcRenderer.invoke('claudeAccounts:reauthenticate', args), + remove: (args: { accountId: string }): Promise => + ipcRenderer.invoke('claudeAccounts:remove', args), + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) +} diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts new file mode 100644 index 00000000000..0c81e35e3e8 --- /dev/null +++ b/src/preload/api/claude-usage-bridge.ts @@ -0,0 +1,4 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' + +export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage') diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts new file mode 100644 index 00000000000..8f811cbdd40 --- /dev/null +++ b/src/preload/api/cli-bridge.ts @@ -0,0 +1,14 @@ +import { ipcRenderer } from 'electron' +import type { CliInstallStatus } from '../../shared/cli-install-types' + +export const cliApi = { + getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), + install: (): Promise => ipcRenderer.invoke('cli:install'), + remove: (): Promise => ipcRenderer.invoke('cli:remove'), + getWslInstallStatus: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:getWslInstallStatus', args), + installWsl: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:installWsl', args), + removeWsl: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:removeWsl', args) +} diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts new file mode 100644 index 00000000000..ecd32e4b923 --- /dev/null +++ b/src/preload/api/codex-accounts-bridge.ts @@ -0,0 +1,32 @@ +import { ipcRenderer } from 'electron' + +export const codexAccountsApi = { + list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('codexAccounts:add', args), + reauthenticate: (args: { + accountId: string + activateIfSelectionWasEmpty?: boolean + }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }): Promise => + ipcRenderer.invoke('codexAccounts:remove', args), + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('codexAccounts:select', args), + listStalePanes: (args: { + ptyIds: string[] + }): Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + reason?: 'account-change' | 'home-route-change' + }[] + > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), + listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => + ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), + forgetStalePanes: (args: { ptyIds: string[] }): Promise => + ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) +} diff --git a/src/preload/api/codex-config-sync-bridge.ts b/src/preload/api/codex-config-sync-bridge.ts new file mode 100644 index 00000000000..e6c8903a698 --- /dev/null +++ b/src/preload/api/codex-config-sync-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' + +export const codexConfigSyncApi = { + status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') +} diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts new file mode 100644 index 00000000000..9dba4b72f82 --- /dev/null +++ b/src/preload/api/codex-usage-bridge.ts @@ -0,0 +1,4 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' + +export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage') diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts new file mode 100644 index 00000000000..be441a8682e --- /dev/null +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' + +export const computerUsePermissionsApi = { + getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }): Promise => + ipcRenderer.invoke('computerUsePermissions:openSetup', args), + reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') +} diff --git a/src/preload/api/crash-reports-bridge.ts b/src/preload/api/crash-reports-bridge.ts new file mode 100644 index 00000000000..19d7044601d --- /dev/null +++ b/src/preload/api/crash-reports-bridge.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from 'electron' +import type { + CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, + CrashReportSubmitArgs, + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult +} from '../../shared/crash-reporting' +import type { RendererHeapStatistics } from '../../shared/renderer-heap-statistics' +import type { RendererProcessMemory } from '../../shared/renderer-process-memory' +import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader' +import { readRendererProcessMemory } from '../renderer-process-memory-reader' + +export const crashReportsApi = { + getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), + getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), + dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ): Promise => + ipcRenderer.invoke('crashReports:recordRendererError', args), + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => + ipcRenderer.send('crashReports:recordBreadcrumb', args), + submit: (args: CrashReportSubmitArgs): Promise => + ipcRenderer.invoke('crashReports:submit', args), + copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), + readProcessMemory: (): Promise => readRendererProcessMemory() +} diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts new file mode 100644 index 00000000000..17241630857 --- /dev/null +++ b/src/preload/api/dashboard-bridge.ts @@ -0,0 +1,74 @@ +import { ipcRenderer } from 'electron' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../../shared/dashboard-snapshot' + +export const dashboardApi = { + // Open the pop-out dashboard window, or focus it if already open. + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), + + // ── Producer side (main window) ────────────────────────────────────── + publishSnapshot: (snapshot: DashboardSnapshot): Promise => + ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), + getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), + onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) + ipcRenderer.on('dashboard:popoutOpenChanged', listener) + return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) + }, + onSnapshotRequested: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('dashboard:snapshotRequested', listener) + return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) + }, + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => + callback(args) + ipcRenderer.on('ui:revealDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) + }, + onAckAgent: (callback: (paneKey: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => callback(paneKey) + ipcRenderer.on('ui:ackDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) + }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSleepWorkspaceArgs): void => + callback(args) + ipcRenderer.on('ui:sleepDashboardWorkspace', listener) + return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) + }, + + // ── Consumer side (pop-out window) ─────────────────────────────────── + requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => + callback(snapshot) + ipcRenderer.on('dashboard:snapshot', listener) + return () => ipcRenderer.removeListener('dashboard:snapshot', listener) + }, + onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => + callback(view) + ipcRenderer.on('dashboard:viewRequested', listener) + return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) + }, + revealAgent: (args: DashboardRevealAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:revealAgent', args), + ackAgent: (paneKey: string): Promise => + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => + ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) +} diff --git a/src/preload/api/developer-permissions-bridge.ts b/src/preload/api/developer-permissions-bridge.ts new file mode 100644 index 00000000000..1aaa51deed3 --- /dev/null +++ b/src/preload/api/developer-permissions-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +export const developerPermissionsApi = { + getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:request', args), + openSettings: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:openSettings', args), + testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) +} diff --git a/src/preload/api/diagnostics-bridge.ts b/src/preload/api/diagnostics-bridge.ts new file mode 100644 index 00000000000..6d274f9b08a --- /dev/null +++ b/src/preload/api/diagnostics-bridge.ts @@ -0,0 +1,15 @@ +import { ipcRenderer } from 'electron' + +export const diagnosticsApi = { + getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), + collectBundle: (lookbackMinutes?: number): Promise => + ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), + openBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), + discardBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), + uploadBundle: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), + deleteBundle: (ticketId: string): Promise => + ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) +} diff --git a/src/preload/api/doc-preview-bridge.ts b/src/preload/api/doc-preview-bridge.ts new file mode 100644 index 00000000000..68a099d5ed2 --- /dev/null +++ b/src/preload/api/doc-preview-bridge.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from 'electron' +import { + DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, + DOC_PREVIEW_MINT_GRANT_CHANNEL, + DOC_PREVIEW_REVOKE_GRANT_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' +import type { DocPreviewGrantRequest } from '../api/doc-preview-api' + +export const docPreviewApi = { + mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => + ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), + revokeGrant: (grantId: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), + authorizeDirectory: (grantId: string, relativePath: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), + onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => + callback(payload) + ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) + return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) + }, + onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void => + callback(payload) + ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) + return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) + } +} diff --git a/src/preload/api/e2e-bridge.ts b/src/preload/api/e2e-bridge.ts new file mode 100644 index 00000000000..2876b72a265 --- /dev/null +++ b/src/preload/api/e2e-bridge.ts @@ -0,0 +1,5 @@ +import { preloadE2EConfig } from '../e2e-config' + +export const e2eApi = { + getConfig: () => preloadE2EConfig +} diff --git a/src/preload/api/emulator-bridge.ts b/src/preload/api/emulator-bridge.ts new file mode 100644 index 00000000000..f13e99fc52a --- /dev/null +++ b/src/preload/api/emulator-bridge.ts @@ -0,0 +1,98 @@ +import { ipcRenderer } from 'electron' + +export const emulatorApi = { + startFrameStream: (args: { + streamUrl: string + streamKey?: string + }): Promise<{ + streamId: string + }> => ipcRenderer.invoke('emulator:frameStreamStart', args), + stopFrameStream: (args: { streamId: string }): Promise => + ipcRenderer.invoke('emulator:frameStreamStop', args), + onFrameStreamFrame: ( + callback: (data: { streamId: string; bytes: ArrayBuffer }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; bytes: ArrayBuffer } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) + }, + onFrameStreamError: ( + callback: (data: { streamId: string; message: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; message: string } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamError', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamError', listener) + }, + startVideoStream: (args: { deviceId: string; streamId: string }): Promise<{ streamId: string }> => + ipcRenderer.invoke('emulator:videoStreamStart', args), + stopVideoStream: (args: { streamId: string }): Promise => + ipcRenderer.invoke('emulator:videoStreamStop', args), + onVideoStreamMeta: ( + callback: (data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamMeta', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamMeta', listener) + }, + onVideoStreamFrame: ( + callback: (data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) + }, + onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('emulator:pane-focus', listener) + return () => ipcRenderer.removeListener('emulator:pane-focus', listener) + }, + onAutoAttach: ( + callback: (data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + } + ) => callback(data) + ipcRenderer.on('ui:emulatorAutoAttach', listener) + return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) + } +} diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts new file mode 100644 index 00000000000..9f55530d6b8 --- /dev/null +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -0,0 +1,25 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const ephemeralVmApi = { + listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), + listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), + doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), + provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), + cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), + onProvisionEvent: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string } + ): void => callback(event) + ipcRenderer.on('ephemeralVm:provisionEvent', listener) + return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) + }, + listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), + attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), + suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), + resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), + cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), + stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), + getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) +} satisfies PreloadApi['ephemeralVm'] diff --git a/src/preload/api/export-bridge.ts b/src/preload/api/export-bridge.ts new file mode 100644 index 00000000000..637d4bea2ef --- /dev/null +++ b/src/preload/api/export-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' + +export const exportApi = { + htmlToPdf: (args: { + html: string + title: string + }): Promise< + { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } + > => ipcRenderer.invoke('export:html-to-pdf', args) +} diff --git a/src/preload/api/feedback-bridge.ts b/src/preload/api/feedback-bridge.ts new file mode 100644 index 00000000000..55b3b5fcaa7 --- /dev/null +++ b/src/preload/api/feedback-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' + +export const feedbackApi = { + submit: (args: { + feedback: string + submitAnonymously?: boolean + githubLogin: string | null + githubEmail: string | null + images?: { contentType: string; data: Uint8Array }[] + }): Promise< + { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } + > => ipcRenderer.invoke('feedback:submit', args) +} diff --git a/src/preload/api/folder-workspaces-bridge.ts b/src/preload/api/folder-workspaces-bridge.ts new file mode 100644 index 00000000000..d085719d8ab --- /dev/null +++ b/src/preload/api/folder-workspaces-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const folderWorkspacesApi = { + list: () => ipcRenderer.invoke('folderWorkspaces:list'), + getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), + create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), + update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), + delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) +} satisfies PreloadApi['folderWorkspaces'] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts new file mode 100644 index 00000000000..538480ce2f5 --- /dev/null +++ b/src/preload/api/fs-bridge.ts @@ -0,0 +1,219 @@ +import { ipcRenderer } from 'electron' +import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { SearchResult } from '../../shared/code-search-types' +import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' + +export const fsApi = { + readDir: (args: { + dirPath: string + connectionId?: string + }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => + ipcRenderer.invoke('fs:readDir', args), + readFile: (args: { + filePath: string + connectionId?: string + includeLocalLogMetadata?: boolean + }): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => ipcRenderer.invoke('fs:readFile', args), + readLocalLogTail: (args: LocalLogTailReadArgs): Promise => + ipcRenderer.invoke('fs:readLocalLogTail', args), + startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => + ipcRenderer.invoke('fs:startLocalLogTail', args), + stopLocalLogTail: (args: { subscriptionId: string }): Promise => + ipcRenderer.invoke('fs:stopLocalLogTail', args), + onLocalLogTailChanged: ( + callback: (payload: LocalLogTailChangedPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LocalLogTailChangedPayload + ): void => callback(payload) + ipcRenderer.on('fs:localLogTailChanged', listener) + return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) + }, + downloadFile: (args: { + filePath: string + connectionId: string + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:downloadFile', args), + downloadFolder: (args: { + dirPath: string + connectionId: string + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:downloadFolder', args), + saveDownloadedFile: (args: { + suggestedName: string + content: string + encoding: 'utf8' | 'base64' + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:saveDownloadedFile', args), + startDownloadedFile: (args: { + suggestedName: string + }): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => ipcRenderer.invoke('fs:startDownloadedFile', args), + appendDownloadedFileChunk: (args: { + transferId: string + contentBase64: string + }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), + finishDownloadedFile: (args: { + transferId: string + }): Promise<{ canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:finishDownloadedFile', args), + cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => + ipcRenderer.invoke('fs:cancelDownloadedFile', args), + listMarkdownDocuments: (args: { + rootPath: string + connectionId?: string + }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => + ipcRenderer.invoke('fs:listMarkdownDocuments', args), + writeFile: ( + args: { + filePath: string + content: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:writeFile', args), + createFile: ( + args: { filePath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createFile', args), + createDir: ( + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createDir', args), + rename: ( + args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:rename', args), + copy: ( + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:copy', args), + deletePath: ( + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:deletePath', args), + authorizeExternalPath: (args: { targetPath: string }): Promise => + ipcRenderer.invoke('fs:authorizeExternalPath', args), + stat: (args: { + filePath: string + connectionId?: string + }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => + ipcRenderer.invoke('fs:stat', args), + pathExists: (args: { filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:pathExists', args), + listFiles: (args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + }): Promise => ipcRenderer.invoke('fs:listFiles', args), + cancelListFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('fs:cancelListFiles', args), + search: (args: { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:search', args), + importExternalPaths: ( + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation + ): Promise<{ + results: ( + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:importExternalPaths', args), + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }): Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + resolveDroppedPathsForAgent: ( + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation + ): Promise<{ + resolvedPaths: string[] + skipped: { + sourcePath: string + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + }[] + failed: { sourcePath: string; reason: string }[] + }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:watchWorktree', args), + unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:unwatchWorktree', args), + onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => + callback(payload) + ipcRenderer.on('fs:changed', listener) + return () => ipcRenderer.removeListener('fs:changed', listener) + } +} diff --git a/src/preload/api/gh-bridge-part-1.ts b/src/preload/api/gh-bridge-part-1.ts new file mode 100644 index 00000000000..11ed8c32f79 --- /dev/null +++ b/src/preload/api/gh-bridge-part-1.ts @@ -0,0 +1,193 @@ +import { ipcRenderer } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' +import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' + +export const ghApiPart1 = { + viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string; repoId?: string }): Promise => + ipcRenderer.invoke('gh:repoSlug', args), + repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + ipcRenderer.invoke('gh:repoUpstream', args), + prForBranch: (args: { + repoPath: string + repoId?: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + }): Promise => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => + ipcRenderer.invoke('gh:refreshPRNow', args), + enqueuePRRefresh: (args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + reportVisiblePRRefreshCandidates: (args: { + candidates: GitHubPRRefreshCandidate[] + generation: number + }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => + callback(event) + ipcRenderer.on('gh:prRefreshEvent', listener) + return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) + }, + issue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + }): Promise => ipcRenderer.invoke('gh:issue', args), + workItem: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItem', args), + workItemByOwnerRepo: (args: { + repoPath: string + repoId?: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + workItemDetails: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), + notifyWorkItemMutated: (args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), + prFileContents: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: string + headSha: string + baseSha: string + }): Promise => ipcRenderer.invoke('gh:prFileContents', args), + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => + ipcRenderer.invoke('gh:listIssues', args), + createIssue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + title: string + body: string + labels?: string[] + assignees?: string[] + }): Promise => ipcRenderer.invoke('gh:createIssue', args), + countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }): Promise => + ipcRenderer.invoke('gh:countWorkItems', args), + listWorkItems: (args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + }): Promise>> => + ipcRenderer.invoke('gh:listWorkItems', args), + prChecks: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }): Promise => ipcRenderer.invoke('gh:prChecks', args), + prCheckDetails: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + rerunPRChecks: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:rerunPRChecks', args), + prComments: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }): Promise => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), + setPRFileViewed: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), + updatePRTitle: (args: { + repoPath: string + repoId?: string + prNumber: number + title: string + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), + mergePR: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args) +} diff --git a/src/preload/api/gh-bridge-part-2.ts b/src/preload/api/gh-bridge-part-2.ts new file mode 100644 index 00000000000..c7ebc51b57e --- /dev/null +++ b/src/preload/api/gh-bridge-part-2.ts @@ -0,0 +1,202 @@ +import { ipcRenderer } from 'electron' +import type { GitHubCommentResult } from '../../shared/github/comment-types' +import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GetRateLimitResult } from '../../shared/github/rate-limit-types' +import type { GhAuthDiagnostic } from '../../shared/github/auth-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugResult, + ListLabelsBySlugResult, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefResult +} from '../../shared/github/project-result-types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../../shared/github/project-request-types' +import type { AppStarSource } from '../../shared/gh-star-source' + +export const ghApiPart2 = { + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:setPRAutoMerge', args), + updatePRState: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updatePRState', args), + markPRReadyForReview: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:markPRReadyForReview', args), + requestPRReviewers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:requestPRReviewers', args), + removePRReviewers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:removePRReviewers', args), + updateIssue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + updates: unknown + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updateIssue', args), + addIssueComment: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), + addPRReviewCommentReply: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), + addPRReviewComment: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }): Promise => ipcRenderer.invoke('gh:listLabels', args), + listAssignableUsers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), + onWorkItemMutated: ( + callback: (payload: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number } + ): void => callback(payload) + ipcRenderer.on('gh:workItemMutated', listener) + return () => ipcRenderer.removeListener('gh:workItemMutated', listener) + }, + checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), + starOrca: (source: AppStarSource): Promise => ipcRenderer.invoke('gh:starOrca', source), + rateLimit: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('gh:rateLimit', args), + diagnoseAuth: (args?: { host?: string }): Promise => + ipcRenderer.invoke('gh:diagnoseAuth', args), + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ): Promise => ipcRenderer.invoke('gh:listAccessibleProjects', args), + resolveProjectRef: (args: ResolveProjectRefArgs): Promise => + ipcRenderer.invoke('gh:resolveProjectRef', args), + listProjectViews: (args: ListProjectViewsArgs): Promise => + ipcRenderer.invoke('gh:listProjectViews', args), + getProjectViewTable: (args: GetProjectViewTableArgs): Promise => + ipcRenderer.invoke('gh:getProjectViewTable', args), + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ): Promise => ipcRenderer.invoke('gh:updateProjectItemField', args), + clearProjectItemField: (args: ClearProjectItemFieldArgs): Promise => + ipcRenderer.invoke('gh:clearProjectItemField', args), + updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueBySlug', args), + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ): Promise => ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => + ipcRenderer.invoke('gh:listLabelsBySlug', args), + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => + ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) +} diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts new file mode 100644 index 00000000000..c4bef718721 --- /dev/null +++ b/src/preload/api/gh-bridge.ts @@ -0,0 +1,4 @@ +import { ghApiPart1 } from './gh-bridge-part-1' +import { ghApiPart2 } from './gh-bridge-part-2' + +export const ghApi = { ...ghApiPart1, ...ghApiPart2 } diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts new file mode 100644 index 00000000000..bd62dfc614a --- /dev/null +++ b/src/preload/api/git-bash-bridge.ts @@ -0,0 +1,5 @@ +import { ipcRenderer } from 'electron' + +export const gitBashApi = { + isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') +} diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts new file mode 100644 index 00000000000..89dfc8db5ae --- /dev/null +++ b/src/preload/api/git-bridge.ts @@ -0,0 +1,200 @@ +import { ipcRenderer } from 'electron' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' + +export const gitApi = { + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + }): Promise => ipcRenderer.invoke('git:status', args), + cancelStatus: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelStatus', args), + setStatusUpstreamRefWatch: (args: { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + branch?: string + upstreamName?: string + }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), + submoduleStatus: (args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:checkIgnored', args), + findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), + appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => + ipcRenderer.invoke('git:appendGitignore', args), + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => ipcRenderer.invoke('git:history', args), + conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:conflictOperation', args), + abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortMerge', args), + abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortRebase', args), + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitCompare', args), + upstreamStatus: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), + fetch: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fetch', args), + syncFork: (args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + }): Promise => ipcRenderer.invoke('git:syncFork', args), + push: (args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: unknown + }): Promise => ipcRenderer.invoke('git:push', args), + pull: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:pull', args), + fastForward: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fastForward', args), + rebaseFromBase: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), + branchDiff: (args: { + worktreePath: string + compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } + filePath: string + oldPath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:branchDiff', args), + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitDiff', args), + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), + generateCommitMessage: (args: { + worktreePath: string + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: unknown + sourceControlAi?: unknown + agentCmdOverrides?: Record + }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + cancelGenerateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + generatePullRequestFields: (args: { + worktreePath: string + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: unknown + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: unknown + sourceControlAi?: unknown + agentCmdOverrides?: Record + }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), + stage: (args: { worktreePath: string; filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:stage', args), + bulkStage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkStage', args), + unstage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:unstage', args), + bulkUnstage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), + discard: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discard', args), + bulkDiscard: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), + remoteFileUrl: (args: { + worktreePath: string + relativePath: string + line: number + connectionId?: string + }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), + remoteCommitUrl: (args: { + worktreePath: string + sha: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) +} diff --git a/src/preload/api/gl-bridge.ts b/src/preload/api/gl-bridge.ts new file mode 100644 index 00000000000..c48794a4976 --- /dev/null +++ b/src/preload/api/gl-bridge.ts @@ -0,0 +1,3 @@ +import { glApi } from '../gitlab' + +export const glApiBridge = glApi diff --git a/src/preload/api/grok-accounts-bridge.ts b/src/preload/api/grok-accounts-bridge.ts new file mode 100644 index 00000000000..b4719905ec7 --- /dev/null +++ b/src/preload/api/grok-accounts-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { GrokAccountStatus } from '../../shared/rate-limit-types' + +export const grokAccountsApi = { + getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') +} diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts new file mode 100644 index 00000000000..59a6fee71b6 --- /dev/null +++ b/src/preload/api/hooks-bridge.ts @@ -0,0 +1,44 @@ +import { ipcRenderer } from 'electron' +import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' +import type { ExecutionHostId } from '../../shared/execution-host' + +export const hooksApi = { + check: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise<{ + status?: 'ok' | 'error' + hasHooks: boolean + hooks: unknown + mayNeedUpdate: boolean + }> => ipcRenderer.invoke('hooks:check', args), + + inspectSetupScriptImports: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + + createIssueCommandRunner: (args: { + repoId: string + worktreePath: string + command: string + }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), + + readIssueCommand: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise<{ + status?: 'ok' | 'error' + localContent: string | null + sharedContent: string | null + effectiveContent: string | null + localFilePath: string + source: 'local' | 'shared' | 'none' + }> => ipcRenderer.invoke('hooks:readIssueCommand', args), + + writeIssueCommand: (args: { + repoId: string + content: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) +} diff --git a/src/preload/api/hosted-review-bridge.ts b/src/preload/api/hosted-review-bridge.ts new file mode 100644 index 00000000000..dc8323b5bf3 --- /dev/null +++ b/src/preload/api/hosted-review-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' + +export const hostedReviewApi = { + forBranch: (args: HostedReviewForBranchArgs): Promise => + ipcRenderer.invoke('hostedReview:forBranch', args), + getCreationEligibility: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:getCreationEligibility', args), + create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:createStacked', args) +} diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts new file mode 100644 index 00000000000..47a27b77f68 --- /dev/null +++ b/src/preload/api/jira-bridge.ts @@ -0,0 +1,110 @@ +import { ipcRenderer } from 'electron' +import type { JiraProjectStatusOrder } from '../../shared/jira-types' + +export const jiraApi = { + connect: (args: { + siteUrl: string + email: string + apiToken: string + authType?: 'cloud' | 'server' + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:connect', args), + + disconnect: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:disconnect', args), + + selectSite: (args: { siteId: string }): Promise => + ipcRenderer.invoke('jira:selectSite', args), + + status: (): Promise => ipcRenderer.invoke('jira:status'), + + readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), + + testConnection: (args?: { + siteId?: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:testConnection', args), + + searchIssues: (args: { + jql: string + limit?: number + siteId?: string + requestId?: string + }): Promise => ipcRenderer.invoke('jira:searchIssues', args), + cancelSearchIssues: (args: { requestId: string }): Promise => + ipcRenderer.invoke('jira:cancelSearchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'reported' | 'all' | 'done' + limit?: number + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listIssues', args), + + getIssue: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:getIssue', args), + + lookupIssueSummary: (args: { + key: string + siteId: string + requestId?: string + }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), + cancelIssueSummary: (args: { requestId: string }): Promise => + ipcRenderer.invoke('jira:cancelIssueSummary', args), + + createIssue: (args: { + siteId?: string + projectId: string + issueTypeId: string + title: string + description?: string + customFields?: Record + }): Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:createIssue', args), + + updateIssue: (args: { + key: string + updates: unknown + siteId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:updateIssue', args), + + addIssueComment: (args: { + key: string + body: string + siteId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:addIssueComment', args), + + issueComments: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:issueComments', args), + + listProjects: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:listProjects', args), + + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:listIssueTypes', args), + + listCreateFields: (args: { + projectIdOrKey: string + issueTypeId: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + + listPriorities: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:listPriorities', args), + + listAssignableUsers: (args: { + key: string + query?: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args?: { query?: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:searchUsers', args), + + listTransitions: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:listTransitions', args), + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) +} diff --git a/src/preload/api/keybindings-bridge.ts b/src/preload/api/keybindings-bridge.ts new file mode 100644 index 00000000000..3111ddd6676 --- /dev/null +++ b/src/preload/api/keybindings-bridge.ts @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron' +import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' + +export const keybindingsApi = { + get: (): Promise => ipcRenderer.invoke('keybindings:get'), + ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), + setAction: (args: { + actionId: KeybindingActionId + bindings: string[] | null + }): Promise => ipcRenderer.invoke('keybindings:setAction', args), + reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), + openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), + revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), + onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: KeybindingFileSnapshot): void => + callback(snapshot) + ipcRenderer.on('keybindings:changed', listener) + return () => ipcRenderer.removeListener('keybindings:changed', listener) + } +} diff --git a/src/preload/api/linear-bridge.ts b/src/preload/api/linear-bridge.ts new file mode 100644 index 00000000000..cd6ec0e9c15 --- /dev/null +++ b/src/preload/api/linear-bridge.ts @@ -0,0 +1,143 @@ +import { ipcRenderer } from 'electron' +import type { LinearProjectDetail } from '../../shared/linear/project-types' + +export const linearApi = { + connect: (args: { + apiKey: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:connect', args), + + disconnect: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:disconnect', args), + + selectWorkspace: (args: { workspaceId: string }): Promise => + ipcRenderer.invoke('linear:selectWorkspace', args), + + status: (): Promise => ipcRenderer.invoke('linear:status'), + + testConnection: (args?: { + workspaceId?: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:testConnection', args), + + searchIssues: (args: { + query: string + limit?: number + workspaceId?: string + }): Promise => ipcRenderer.invoke('linear:searchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + workspaceId?: string + attributeFilter?: unknown + }): Promise => ipcRenderer.invoke('linear:listIssues', args), + + createIssue: (args: { + teamId: string + title: string + description?: string + workspaceId?: string + parentIssueId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + }): Promise< + | { ok: true; id: string; identifier: string; title: string; url: string } + | { ok: false; error: string } + > => ipcRenderer.invoke('linear:createIssue', args), + + getIssue: (args: { id: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:getIssue', args), + + updateIssue: (args: { + id: string + updates: unknown + workspaceId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:updateIssue', args), + + addIssueComment: (args: { + issueId: string + body: string + workspaceId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:addIssueComment', args), + + issueComments: (args: { issueId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:issueComments', args), + + listTeams: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:listTeams', args), + + listProjects: (args?: { + query?: string + limit?: number + workspaceId?: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listProjects', args), + + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:createProject', args), + + getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => + ipcRenderer.invoke('linear:getProject', args), + + listProjectIssues: (args: { + projectId: string + limit?: number + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + + listCustomViews: (args: { + model: string + limit?: number + workspaceId?: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + + getCustomView: (args: { + viewId: string + model: string + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + + listCustomViewIssues: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + + listCustomViewProjects: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), + + teamStates: (args: { teamId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:teamStates', args), + + teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:teamLabels', args), + + teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:teamMembers', args) +} diff --git a/src/preload/api/localhost-worktree-labels-bridge.ts b/src/preload/api/localhost-worktree-labels-bridge.ts new file mode 100644 index 00000000000..a44dcffff69 --- /dev/null +++ b/src/preload/api/localhost-worktree-labels-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { + LocalhostWorktreeLabelResult, + LocalhostWorktreeLabelRoute +} from '../../shared/localhost-worktree-labels' +import type { PreloadApi } from '../api-types' + +export const localhostWorktreeLabelsApi = { + register: (args: LocalhostWorktreeLabelRoute): Promise => + ipcRenderer.invoke('localhostWorktreeLabels:register', args) +} satisfies PreloadApi['localhostWorktreeLabels'] diff --git a/src/preload/api/macos-tcc-prompts-bridge.ts b/src/preload/api/macos-tcc-prompts-bridge.ts new file mode 100644 index 00000000000..0c6c6b184fc --- /dev/null +++ b/src/preload/api/macos-tcc-prompts-bridge.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' + +export const macosTccPromptsApi = { + onThreshold: (callback: (payload: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => + callback(payload) + ipcRenderer.on('macosTccPrompts:threshold', listener) + return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + }, + consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => + ipcRenderer.invoke('macosTccPrompts:consumePending'), + acknowledgePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), + releasePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), + dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') +} diff --git a/src/preload/api/memory-bridge.ts b/src/preload/api/memory-bridge.ts new file mode 100644 index 00000000000..15af55cb09c --- /dev/null +++ b/src/preload/api/memory-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { MemorySnapshot } from '../../shared/process-stats-types' + +export const memoryApi = { + getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') +} diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts new file mode 100644 index 00000000000..a758d9e2e5b --- /dev/null +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' + +export const minimaxCredentialsApi = { + getStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:getStatus'), + saveCookie: (cookie: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:clearCookie') +} diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts new file mode 100644 index 00000000000..836f27f6f37 --- /dev/null +++ b/src/preload/api/mobile-bridge.ts @@ -0,0 +1,95 @@ +import { ipcRenderer } from 'electron' +import type { MobileRelayStatus } from '../../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' + +export const mobileApi = { + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + }): Promise< + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } + | { + available: true + qrDataUrl: string | null + /** Natural bitmap width and height in pixels. */ + qrSize: number | null + qrError?: 'encoding_failed' + pairingUrl: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null + deviceId: string + connectionMode: MobilePairingConnectionMode + } + > => ipcRenderer.invoke('mobile:getPairingQR', args), + + getWindowsFirewallStatus: (args?: { address?: string }) => + ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), + + repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), + + openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), + + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not + // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). + reach?: RuntimePairingReach + }): Promise< + | { available: false; reason?: 'network_exposure_failed'; guidance?: string } + | { + available: true + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), + + listDevices: (): Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> => ipcRenderer.invoke('mobile:listDevices'), + + revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeDevice', args), + + listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), + + revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), + + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => + ipcRenderer.invoke('mobile:isWebSocketReady'), + + getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => + ipcRenderer.invoke('mobile:getRelayStatus'), + + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => + callback(status) + ipcRenderer.on('mobile:relayStatusChanged', listener) + return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) + }, + + consumePendingUnpairedDeviceAuthFailure: (): Promise => + ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), + + /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ + onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) + return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) + } +} diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts new file mode 100644 index 00000000000..16c2906349e --- /dev/null +++ b/src/preload/api/native-chat-bridge.ts @@ -0,0 +1,40 @@ +import { ipcRenderer } from 'electron' +import type { + NativeChatAppendedPayload, + NativeChatReadSessionResult, + NativeChatSubscriptionFrame +} from '../api-types' +import type { AgentType } from '../../shared/native-chat-types' + +export const nativeChatApi = { + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ): Promise => + ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ + subscribe: ( + args: { + subscriptionId: string + agent: AgentType + sessionId: string + transcriptPath?: string + limit?: number + }, + onFrame: (frame: NativeChatSubscriptionFrame) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { + if (payload.subscriptionId === args.subscriptionId) { + onFrame(payload.frame) + } + } + ipcRenderer.on('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:subscribe', args) + return () => { + ipcRenderer.removeListener('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) + } + } +} diff --git a/src/preload/api/notebook-bridge.ts b/src/preload/api/notebook-bridge.ts new file mode 100644 index 00000000000..ee307b9f0e2 --- /dev/null +++ b/src/preload/api/notebook-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +export const notebookApi = { + runPythonCell: (args: { + filePath: string + code: string + preamble?: string + connectionId?: string | null + }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => + ipcRenderer.invoke('notebook:runPythonCell', args) +} diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts new file mode 100644 index 00000000000..aa84fb1a8a6 --- /dev/null +++ b/src/preload/api/notifications-bridge.ts @@ -0,0 +1,120 @@ +import { ipcRenderer } from 'electron' +import type { + NotificationDeliveryProbeResult, + NotificationDismissResult, + NotificationDispatchResult, + NotificationPermissionStatusResult, + NotificationSoundDataResult, + NotificationSoundPathResult, + NotificationSoundResult +} from '../../shared/notification-settings-types' + +// Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. +let cachedNotificationSound: { + path: string + blobUrl: string + audio: HTMLAudioElement +} | null = null +let isNotificationSoundPlaying = false +// Why: audio.play() can reject before ended/error fires; cleanup prevents leaked listeners. +let cleanupNotificationSoundPlayback: (() => void) | null = null + +function clearNotificationSoundPlaybackState(): void { + cleanupNotificationSoundPlayback?.() + cleanupNotificationSoundPlayback = null + isNotificationSoundPlaying = false +} + +function disposeCachedNotificationSound(): void { + if (cachedNotificationSound) { + clearNotificationSoundPlaybackState() + cachedNotificationSound.audio.pause() + cachedNotificationSound.audio.src = '' + URL.revokeObjectURL(cachedNotificationSound.blobUrl) + cachedNotificationSound = null + } +} + +export const notificationsApi = { + dispatch: (args: Record): Promise => + ipcRenderer.invoke('notifications:dispatch', args), + dismiss: (ids: string[]): Promise => + ipcRenderer.invoke('notifications:dismiss', ids), + openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + getPermissionStatus: (): Promise => + ipcRenderer.invoke('notifications:getPermissionStatus'), + probeDelivery: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('notifications:probeDelivery', args), + playSound: async (options?: { + force?: boolean + volume?: number + }): Promise => { + try { + // Why: drop replays while still ringing; the test button passes force to always confirm. + if (!options?.force && isNotificationSoundPlaying) { + return { played: false, reason: 'deduped' } + } + + const resolved = (await ipcRenderer.invoke( + 'notifications:resolveSoundPath' + )) as NotificationSoundPathResult + if (!resolved.ok) { + if (cachedNotificationSound) { + disposeCachedNotificationSound() + } + return { played: false, reason: resolved.reason } + } + + let entry = cachedNotificationSound + if (!entry || entry.path !== resolved.path) { + const sound = (await ipcRenderer.invoke( + 'notifications:loadSound' + )) as NotificationSoundDataResult + if (!sound.ok) { + disposeCachedNotificationSound() + return { played: false, reason: sound.reason } + } + const arrayBuffer = new ArrayBuffer(sound.data.byteLength) + new Uint8Array(arrayBuffer).set(sound.data) + const blob = new Blob([arrayBuffer], { type: sound.mimeType }) + disposeCachedNotificationSound() + const blobUrl = URL.createObjectURL(blob) + entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } + cachedNotificationSound = entry + } + + const audio = entry.audio + // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). + audio.currentTime = 0 + if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { + audio.volume = Math.min(1, Math.max(0, options.volume / 100)) + } + isNotificationSoundPlaying = true + cleanupNotificationSoundPlayback?.() + const release = (): void => { + cleanup() + if (cleanupNotificationSoundPlayback === cleanup) { + cleanupNotificationSoundPlayback = null + } + isNotificationSoundPlaying = false + } + const cleanup = (): void => { + audio.removeEventListener('ended', release) + audio.removeEventListener('error', release) + } + cleanupNotificationSoundPlayback = cleanup + audio.addEventListener('ended', release) + audio.addEventListener('error', release) + try { + await audio.play() + } catch { + release() + return { played: false, reason: 'playback-failed' } + } + return { played: true } + } catch { + clearNotificationSoundPlaybackState() + return { played: false, reason: 'playback-failed' } + } + } +} diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts new file mode 100644 index 00000000000..1b4393268ac --- /dev/null +++ b/src/preload/api/onboarding-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { OnboardingState } from '../../shared/onboarding-state-types' + +export const onboardingApi = { + get: (): Promise => ipcRenderer.invoke('onboarding:get'), + update: ( + updates: Partial> & { + checklist?: Partial + } + ): Promise => ipcRenderer.invoke('onboarding:update', updates) +} diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts new file mode 100644 index 00000000000..5cc668e6e00 --- /dev/null +++ b/src/preload/api/open-code-usage-bridge.ts @@ -0,0 +1,4 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' + +export const openCodeUsageApi = createUsageProviderApi(ipcRenderer, 'openCodeUsage') diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts new file mode 100644 index 00000000000..0b2897f8ab1 --- /dev/null +++ b/src/preload/api/orca-profiles-bridge.ts @@ -0,0 +1,21 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const orcaProfilesApi = { + list: () => ipcRenderer.invoke('orcaProfiles:list'), + authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), + createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), + createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), + switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), + transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), + connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), + refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), + signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), + selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), + orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), + orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), + orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), + orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), + orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) +} satisfies PreloadApi['orcaProfiles'] diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts new file mode 100644 index 00000000000..8a5d3309c5c --- /dev/null +++ b/src/preload/api/pet-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { CustomPet } from '../../shared/pet-types' + +export const petApi = { + import: (): Promise => ipcRenderer.invoke('pet:import'), + importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), + read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:read', id, fileName, kind), + delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:delete', id, fileName, kind) +} diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts new file mode 100644 index 00000000000..8e47a4386cf --- /dev/null +++ b/src/preload/api/platform-bridge.ts @@ -0,0 +1,17 @@ +import { getLinuxDisplayServer } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const platformApi = { + get: () => ({ + platform: process.platform, + // Why: sandboxed preload cannot require node:os; Electron exposes the OS + // version on process.getSystemVersion when available. + osRelease: + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? '', + arch: process.arch, + // Why: these identify the default shell without probing user config files. + // process.env is available in the sandboxed preload; node:os is not. + shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', + displayServer: getLinuxDisplayServer() + }) +} satisfies PreloadApi['platform'] diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts new file mode 100644 index 00000000000..5459ca3b696 --- /dev/null +++ b/src/preload/api/plugins-bridge.ts @@ -0,0 +1,63 @@ +import { ipcRenderer } from 'electron' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import type { + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine +} from '../api-types' +import type { PreloadApi } from '../api-types' + +export const pluginsApi = { + list: (): Promise => ipcRenderer.invoke('plugins:list'), + listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + consent: (args: PluginConsentRequest): Promise => + ipcRenderer.invoke('plugins:consent', args), + setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => + ipcRenderer.invoke('plugins:setEnabled', args), + readPanelEntry: (args: { + pluginKey: string + panelId: string + }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + install: (source: PluginHostInstallSource): Promise => + ipcRenderer.invoke('plugins:install', source), + listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), + addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => + ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + remove: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:remove', args), + getLogs: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:getLogs', args), + refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), + onChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => + callback(change) + ipcRenderer.on('plugins:changed', listener) + return () => { + ipcRenderer.removeListener('plugins:changed', listener) + } + } +} satisfies PreloadApi['plugins'] diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts new file mode 100644 index 00000000000..c05721a2d3b --- /dev/null +++ b/src/preload/api/preflight-bridge.ts @@ -0,0 +1,43 @@ +import { ipcRenderer } from 'electron' +import type { PreflightRuntimeContext, RefreshAgentsResult } from '../api-types' + +export const preflightApi = { + check: (args?: { + force?: boolean + }): Promise<{ + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + linear: { connected: boolean } + }> => ipcRenderer.invoke('preflight:check', args), + detectAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:detectAgents', args), + refreshAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:refreshAgents', args), + detectRemoteAgents: (args: { connectionId: string }): Promise => + ipcRenderer.invoke('preflight:detectRemoteAgents', args), + detectRemoteWindowsTerminalCapabilities: (args: { + connectionId: string + }): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) +} diff --git a/src/preload/api/project-groups-bridge.ts b/src/preload/api/project-groups-bridge.ts new file mode 100644 index 00000000000..c14d58b7d00 --- /dev/null +++ b/src/preload/api/project-groups-bridge.ts @@ -0,0 +1,22 @@ +import { ipcRenderer } from 'electron' +import type { NestedRepoScanResult } from '../../shared/project-group-types' +import type { PreloadApi } from '../api-types' + +export const projectGroupsApi = { + list: () => ipcRenderer.invoke('projectGroups:list'), + create: (args) => ipcRenderer.invoke('projectGroups:create', args), + update: (args) => ipcRenderer.invoke('projectGroups:update', args), + delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), + moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), + scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), + cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), + onNestedScanProgress: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { scanId: string; scan: NestedRepoScanResult } + ) => callback(data) + ipcRenderer.on('projectGroups:scanNestedProgress', listener) + return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) + }, + importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) +} satisfies PreloadApi['projectGroups'] diff --git a/src/preload/api/projects-bridge.ts b/src/preload/api/projects-bridge.ts new file mode 100644 index 00000000000..c8114fbdf4a --- /dev/null +++ b/src/preload/api/projects-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const projectsApi = { + list: () => ipcRenderer.invoke('projects:list'), + update: (args) => ipcRenderer.invoke('projects:update', args), + listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), + createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), + setupExistingFolder: (args) => ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), + deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) +} satisfies PreloadApi['projects'] diff --git a/src/preload/api/pty-bridge-part-1.ts b/src/preload/api/pty-bridge-part-1.ts new file mode 100644 index 00000000000..40da8dbfa52 --- /dev/null +++ b/src/preload/api/pty-bridge-part-1.ts @@ -0,0 +1,207 @@ +import { ipcRenderer } from 'electron' +import type { AgentSessionPtyWriteRefusal } from '../../shared/agent-session-pty-write-admission' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import type { PtyListedSession } from '../../shared/pty-listed-session' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' +import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' + +export const ptyApiPart1 = { + spawn: (opts: { + cols: number + rows: number + cwd?: string + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: marks the PTY hidden before its first byte so the delivery gate + model responder own spawn-time queries (terminal-query-authority.md §races). + initiallyHidden?: boolean + // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. + tabId?: string + leafId?: string + // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } + }): Promise<{ + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + }> => ipcRenderer.invoke('pty:spawn', opts), + write: (id: string, data: string): void => { + ipcRenderer.send('pty:write', { id, data }) + }, + writeAccepted: (id: string, data: string): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data }), + onWriteUnavailable: ( + callback: (payload: { + id: string + /** Set only when a durable agent-session lease refused the write; absent otherwise. */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } + ): void => callback(payload) + ipcRenderer.on('pty:writeUnavailable', handler) + return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) + }, + resize: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:resize', { id, cols, rows }) + }, + claimViewport: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:claimViewport', { id, cols, rows }) + }, + reportGeometry: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) + }, + signal: (id: string, signal: string): void => { + ipcRenderer.send('pty:signal', { id, signal }) + }, + clearBuffer: (id: string): void => { + ipcRenderer.send('pty:clearBuffer', { id }) + }, + ackColdRestore: (id: string): void => { + ipcRenderer.send('pty:ackColdRestore', { id }) + }, + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) + }, + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) + }, + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ): Promise => + ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), + rendererDispatcherReady: (): void => { + ipcRenderer.send('pty:rendererDispatcherReady') + }, + setActiveRendererPty: (id: string, active: boolean): void => { + ipcRenderer.send('pty:setActiveRendererPty', { id, active }) + }, + setRendererPtyVisible: (id: string, visible: boolean): void => { + ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) + }, + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, + kill: (id: string, opts?: { keepHistory?: boolean }): Promise => + ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), + listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), + getAuthoritativeBufferSnapshotCapabilities: ( + ids: string[] + ): Promise<{ id: string; authoritative: boolean | null }[]> => + ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), + hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + seq?: number + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + terminalOwner?: 'shell' + } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + getRendererDeliveryDebugSnapshot: (): Promise<{ + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number + }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), + resetRendererDeliveryDebug: (): Promise => + ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), + hasChildProcesses: (id: string): Promise => + ipcRenderer.invoke('pty:hasChildProcesses', { id }), + getForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:getForegroundProcess', { id }) +} diff --git a/src/preload/api/pty-bridge-part-2.ts b/src/preload/api/pty-bridge-part-2.ts new file mode 100644 index 00000000000..1686a621f97 --- /dev/null +++ b/src/preload/api/pty-bridge-part-2.ts @@ -0,0 +1,141 @@ +import { ipcRenderer } from 'electron' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' + +export const ptyApiPart2 = { + inspectProcess: ( + id: string + ): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> => ipcRenderer.invoke('pty:inspectProcess', { id }), + confirmForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + getSize: (id: string): Promise<{ cols: number; rows: number } | null> => + ipcRenderer.invoke('pty:getSize', { id }), + onData: ( + callback: (data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + } + ) => callback(data) + ipcRenderer.on('pty:data', listener) + return () => ipcRenderer.removeListener('pty:data', listener) + }, + onReplay: (callback: (data: { id: string; data: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => + callback(data) + ipcRenderer.on('pty:replay', listener) + return () => ipcRenderer.removeListener('pty:replay', listener) + }, + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: ( + callback: (data: { + id: string + code: number + preserveRendererBinding?: boolean + /** Which lifetime of `id` died; absent when the execution host predates the field. */ + incarnationId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + code: number + preserveRendererBinding?: boolean + incarnationId?: string + } + ) => callback(data) + ipcRenderer.on('pty:exit', listener) + return () => ipcRenderer.removeListener('pty:exit', listener) + }, + onSpawned: (callback: (data: { id: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) + ipcRenderer.on('pty:spawned', listener) + return () => ipcRenderer.removeListener('pty:spawned', listener) + }, + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } + ) => callback(data) + ipcRenderer.on('pty:serializeBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) + }, + onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('pty:clearBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) + }, + sendSerializedBuffer: ( + requestId: string, + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null + ): void => { + ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) + }, + declarePendingPaneSerializer: (paneKey: string): Promise => + ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), + settlePaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), + clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { + listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), + killAll: () => ipcRenderer.invoke('pty:management:killAll'), + killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), + restart: () => ipcRenderer.invoke('pty:management:restart'), + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') + } +} diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts new file mode 100644 index 00000000000..314bd6f993f --- /dev/null +++ b/src/preload/api/pty-bridge.ts @@ -0,0 +1,4 @@ +import { ptyApiPart1 } from './pty-bridge-part-1' +import { ptyApiPart2 } from './pty-bridge-part-2' + +export const ptyApi = { ...ptyApiPart1, ...ptyApiPart2 } diff --git a/src/preload/api/pwsh-bridge.ts b/src/preload/api/pwsh-bridge.ts new file mode 100644 index 00000000000..bd202277ad1 --- /dev/null +++ b/src/preload/api/pwsh-bridge.ts @@ -0,0 +1,5 @@ +import { ipcRenderer } from 'electron' + +export const pwshApi = { + isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') +} diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts new file mode 100644 index 00000000000..f403d79cdc6 --- /dev/null +++ b/src/preload/api/rate-limits-bridge.ts @@ -0,0 +1,30 @@ +import { ipcRenderer } from 'electron' +import type { + CodexRateLimitResetResult, + RateLimitRuntimeTarget, + RateLimitState +} from '../../shared/rate-limit-types' + +export const rateLimitsApi = { + get: (): Promise => ipcRenderer.invoke('rateLimits:get'), + refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + consumeCodexResetCredit: (): Promise => + ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), + refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), + setPollingInterval: (ms: number): Promise => + ipcRenderer.invoke('rateLimits:setPollingInterval', ms), + fetchInactiveClaudeAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), + fetchInactiveCodexAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), + refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), + onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) + ipcRenderer.on('rateLimits:update', listener) + return () => ipcRenderer.removeListener('rateLimits:update', listener) + } +} diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts new file mode 100644 index 00000000000..2a7ca528d21 --- /dev/null +++ b/src/preload/api/remote-workspace-bridge.ts @@ -0,0 +1,19 @@ +import { ipcRenderer } from 'electron' +import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' +import type { PreloadApi } from '../api-types' + +export const remoteWorkspaceApi = { + get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), + setForConnectedTargets: (args) => + ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + listEnabledConnectedTargets: () => + ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), + listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), + clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), + onChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => + callback(data) + ipcRenderer.on('remoteWorkspace:changed', listener) + return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) + } +} satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts new file mode 100644 index 00000000000..36859897938 --- /dev/null +++ b/src/preload/api/repos-bridge.ts @@ -0,0 +1,87 @@ +import { ipcRenderer } from 'electron' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../../shared/host-repo-catalog-contract' +import type { BaseRefDefaultResult, BaseRefSearchResult } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' + +export const reposApi = { + list: () => ipcRenderer.invoke('repos:list'), + + listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => + ipcRenderer.invoke('repos:listForExecutionHost', args), + + add: (args) => ipcRenderer.invoke('repos:add', args), + + addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), + + create: (args) => ipcRenderer.invoke('repos:create', args), + + isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), + + getDefaultCreateProjectParent: (): Promise => + ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), + + remove: (args) => ipcRenderer.invoke('repos:remove', args), + + removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), + + reorder: (args) => ipcRenderer.invoke('repos:reorder', args), + + reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), + + update: (args) => ipcRenderer.invoke('repos:update', args), + + pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), + + pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), + + pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), + + clone: (args) => ipcRenderer.invoke('repos:clone', args), + + cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + + createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), + + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), + + onCloneProgress: (callback: (data: { phase: string; percent: number }) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { phase: string; percent: number } + ) => callback(data) + ipcRenderer.on('repos:clone-progress', listener) + return () => ipcRenderer.removeListener('repos:clone-progress', listener) + }, + + getGitUsername: (args: { repoId: string }): Promise => + ipcRenderer.invoke('repos:getGitUsername', args), + + getBaseRefDefault: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), + + searchBaseRefs: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), + + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), + + onChanged: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('repos:changed', listener) + return () => ipcRenderer.removeListener('repos:changed', listener) + } +} satisfies PreloadApi['repos'] diff --git a/src/preload/api/runtime-bridge.ts b/src/preload/api/runtime-bridge.ts new file mode 100644 index 00000000000..c58049bcbe3 --- /dev/null +++ b/src/preload/api/runtime-bridge.ts @@ -0,0 +1,144 @@ +import { ipcRenderer } from 'electron' +import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' +import type { + RuntimeBrowserDriverState, + RuntimeRendererSyncWindowGraph, + RuntimeStatus, + RuntimeSyncWindowGraphResult, + RuntimeTerminalDriverState +} from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' + +export const runtimeApi = { + syncWindowGraph: (graph: RuntimeRendererSyncWindowGraph): Promise => + ipcRenderer.invoke('runtime:syncWindowGraph', graph), + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + call: (args: { method: string; params?: unknown }): Promise> => + ipcRenderer.invoke('runtime:call', args), + subscribe: async ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ): Promise => { + const subscriptionId = `desktop-${crypto.randomUUID()}` + const channel = `runtime:subscription:${subscriptionId}` + const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => + callback(response) + ipcRenderer.on(channel, listener) + try { + await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) + } catch (error) { + ipcRenderer.removeListener(channel, listener) + throw error + } + return { + unsubscribe: () => { + ipcRenderer.removeListener(channel, listener) + ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) + }, + sendBinary: () => { + throw new Error('Local runtime subscriptions do not accept binary input') + } + } + }, + getTerminalFitOverrides: (): Promise< + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] + > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + getTerminalDrivers: (): Promise< + { + ptyId: string + driver: RuntimeTerminalDriverState + }[] + > => ipcRenderer.invoke('runtime:getTerminalDrivers'), + getBrowserDrivers: (): Promise< + { + browserPageId: string + driver: RuntimeBrowserDriverState + }[] + > => ipcRenderer.invoke('runtime:getBrowserDrivers'), + getBrowserRemoteViewerPages: (): Promise => + ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), + getClientHostedBrowserRows: (): Promise => + ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), + restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => + ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => + ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } + ) => callback(data) + ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) + }, + onTerminalDriverChanged: ( + callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + driver: RuntimeTerminalDriverState + } + ) => callback(data) + ipcRenderer.on('runtime:terminalDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) + }, + onNativeChatLaunchDraftResolved: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; text: string; createdAt: number } + ) => callback(data) + ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) + return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) + }, + onBrowserDriverChanged: ( + callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + driver: RuntimeBrowserDriverState + } + ) => callback(data) + ipcRenderer.on('runtime:browserDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:browserDriverChanged', listener) + }, + onBrowserRemoteViewersChanged: ( + callback: (event: { browserPageId: string; hasRemoteViewers: boolean }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + hasRemoteViewers: boolean + } + ) => callback(data) + ipcRenderer.on('runtime:browserRemoteViewersChanged', listener) + return () => ipcRenderer.removeListener('runtime:browserRemoteViewersChanged', listener) + }, + onClientHostedBrowserRowsChanged: ( + callback: (event: ClientHostedBrowserRowsEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: ClientHostedBrowserRowsEvent) => + callback(data) + ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) + return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) + } +} diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts new file mode 100644 index 00000000000..d018c0574c8 --- /dev/null +++ b/src/preload/api/runtime-environments-bridge.ts @@ -0,0 +1,93 @@ +import { ipcRenderer } from 'electron' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' +import { + subscribeRuntimeEnvironmentFromPreload, + type RuntimeEnvironmentSubscriptionHandle +} from '../runtime-environment-subscriptions' + +export const runtimeEnvironmentsApi = { + list: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:list'), + addFromPairingCode: (args: { + name: string + pairingCode: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }): Promise => + ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), + resolve: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:resolve', args), + remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:remove', args), + disconnect: (args: { + selector: string + }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:disconnect', args), + connect: (args: { + selector: string + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:connect', args), + getStatus: (args: { + selector: string + timeoutMs?: number + observeOnly?: true + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + retryControlConnection: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), + onSharedControlDiagnostics: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + } + ): void => callback(data) + ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + }, + prepareBrowserClientHostPlacement: (args) => + ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), + retryConnectionsNow: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), + subscribe: async ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ): Promise => + subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) +} diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts new file mode 100644 index 00000000000..189dd96c1d1 --- /dev/null +++ b/src/preload/api/session-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const sessionApi = { + // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. + get: (hostId) => ipcRenderer.invoke('session:get', hostId), + set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), + patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + flush: () => ipcRenderer.invoke('session:flush'), + readTerminalScrollback: (args) => + ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), + /** Synchronous session save for beforeunload — blocks until flushed to disk. */ + setSync: (args, hostId) => { + ipcRenderer.sendSync('session:set-sync', args, hostId) + } +} satisfies PreloadApi['session'] diff --git a/src/preload/api/settings-bridge.ts b/src/preload/api/settings-bridge.ts new file mode 100644 index 00000000000..d8aaa23b0b7 --- /dev/null +++ b/src/preload/api/settings-bridge.ts @@ -0,0 +1,39 @@ +import { ipcRenderer } from 'electron' +import type { GhosttyImportPreview } from '../../shared/global-settings-types' +import type { + WarpThemeImportPreview, + WarpThemeImportSource +} from '../../shared/terminal-custom-themes' + +export const settingsApi = { + get: (): Promise => ipcRenderer.invoke('settings:get'), + + // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. + getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + + set: (args: Record): Promise => + ipcRenderer.invoke('settings:set', args), + + setActiveRuntimeEnvironmentPreference: (args: { + environmentId: string | null + }): Promise => + ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), + + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => + ipcRenderer.invoke('settings:update-pr-bot-author-override', args), + + listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), + + previewGhosttyImport: (): Promise => + ipcRenderer.invoke('settings:previewGhosttyImport'), + + previewWarpThemeImport: (source: WarpThemeImportSource): Promise => + ipcRenderer.invoke('settings:previewWarpThemeImport', source), + + onChanged: (callback: (updates: Record) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, updates: Record): void => + callback(updates) + ipcRenderer.on('settings:changed', listener) + return () => ipcRenderer.removeListener('settings:changed', listener) + } +} diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts new file mode 100644 index 00000000000..be34c7ff70e --- /dev/null +++ b/src/preload/api/shell-bridge.ts @@ -0,0 +1,41 @@ +import { ipcRenderer } from 'electron' +import type { + ShellOpenExternalEditorRequest, + ShellOpenExternalEditorResult, + ShellOpenLocalPathResult +} from '../../shared/shell-open-types' + +export const shellApi = { + openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), + + openInFileManager: (path: string): Promise => + ipcRenderer.invoke('shell:openInFileManager', path), + + openInExternalEditor: ( + request: ShellOpenExternalEditorRequest + ): Promise => + ipcRenderer.invoke('shell:openInExternalEditor', request), + + openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), + + openFilePath: (path: string): Promise => ipcRenderer.invoke('shell:openFilePath', path), + + openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + + pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), + + pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), + + pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + + pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => + ipcRenderer.invoke('shell:pickRepoIconImage'), + + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + + pickDirectory: (args: { defaultPath?: string }): Promise => + ipcRenderer.invoke('shell:pickDirectory', args), + + copyFile: (args: { srcPath: string; destPath: string }): Promise => + ipcRenderer.invoke('shell:copyFile', args) +} diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts new file mode 100644 index 00000000000..eafaf2ce543 --- /dev/null +++ b/src/preload/api/skills-bridge.ts @@ -0,0 +1,129 @@ +import { ipcRenderer } from 'electron' +import type { + SkillDeletePlan, + SkillDeleteRequest, + SkillDeleteResult +} from '../../shared/skill-delete-contract' +import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { + SkillCloudOwnedShare, + SkillCloudOperation, + SkillCloudPackageDetails +} from '../../shared/skill-cloud-contract' +import type { + SkillBundleInstallPreviewInput, + SkillBundleInstallPreviewOperation, + SkillBundlePackageVersionInstallInput, + SkillBundleShareInstallInput, + SkillBundleShareInstallOperation, + SkillInstallPreviewInput, + SkillInstallPreviewOperation, + ManagedSkillInstallListOperation, + SkillPackageVersionInstallInput, + SkillRemoveInput, + SkillRemoveOperation, + SkillShareInstallInput, + SkillShareInstallOperation, + SkillInstallCancelInput, + SkillInstallProgress, + SkillSharePreview, + SkillShareProgress, + SkillSharePublishInput, + SkillSharePublishOperation, + SkillShareResolvedOperation +} from '../../shared/skill-sharing-contract' +import type { + SkillFreshnessInventory, + SkillUpdateRun, + SkillUpdateStartResult +} from '../../shared/skill-freshness' + +export const skillsApi = { + discover: (target?: SkillDiscoveryTarget): Promise => + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory'), + startUpdateRun: (names: string[]): Promise => + ipcRenderer.invoke('skills:startUpdateRun', names), + cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), + acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), + getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), + prepareShare: (input: { + skillIds: string[] + bundleName: string + target?: SkillDiscoveryTarget + packageId?: string + }): Promise => ipcRenderer.invoke('skills:prepareShare', input), + publishShare: (input: SkillSharePublishInput): Promise => + ipcRenderer.invoke('skills:publishShare', input), + cancelShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:cancelShare', preparationId), + releaseShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:releaseShare', preparationId), + resolveShare: (shareId: string): Promise => + ipcRenderer.invoke('skills:resolveShare', shareId), + installShare: (input: SkillShareInstallInput): Promise => + ipcRenderer.invoke('skills:installShare', input), + installBundleShare: ( + input: SkillBundleShareInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundleShare', input), + installBundlePackageVersion: ( + input: SkillBundlePackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundlePackageVersion', input), + installPackageVersion: ( + input: SkillPackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installPackageVersion', input), + cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => + ipcRenderer.invoke('skills:cancelInstall', input), + previewInstall: (input: SkillInstallPreviewInput): Promise => + ipcRenderer.invoke('skills:previewInstall', input), + previewBundleInstall: ( + input: SkillBundleInstallPreviewInput + ): Promise => + ipcRenderer.invoke('skills:previewBundleInstall', input), + removeInstall: (input: SkillRemoveInput): Promise => + ipcRenderer.invoke('skills:removeInstall', input), + // Desktop always registers the delete IPC handlers in its own main process. + deleteSupported: (): Promise => Promise.resolve(true), + previewDelete: (request: SkillDeleteRequest): Promise => + ipcRenderer.invoke('skills:previewDelete', request), + delete: (request: SkillDeleteRequest): Promise => + ipcRenderer.invoke('skills:delete', request), + listManagedInstalls: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listManagedInstalls', environmentId), + getPackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:getPackage', packageId), + listOwnedShares: (): Promise> => + ipcRenderer.invoke('skills:listOwnedShares'), + revokeShare: (shareId: string): Promise> => + ipcRenderer.invoke('skills:revokeShare', shareId), + deletePackageVersion: (input: { + packageId: string + versionId: string + }): Promise> => + ipcRenderer.invoke('skills:deletePackageVersion', input), + deletePackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:deletePackage', packageId), + listWslDistros: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listWslDistros', environmentId), + onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => + callback(progress) + ipcRenderer.on('skills:installProgress', listener) + return () => ipcRenderer.removeListener('skills:installProgress', listener) + }, + onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void => + callback(progress) + ipcRenderer.on('skills:shareProgress', listener) + return () => ipcRenderer.removeListener('skills:shareProgress', listener) + }, + onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => callback(run) + ipcRenderer.on('skills:updateRun', listener) + return () => ipcRenderer.removeListener('skills:updateRun', listener) + } +} diff --git a/src/preload/api/sparse-presets-bridge.ts b/src/preload/api/sparse-presets-bridge.ts new file mode 100644 index 00000000000..91676740e6e --- /dev/null +++ b/src/preload/api/sparse-presets-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const sparsePresetsApi = { + list: (args) => ipcRenderer.invoke('sparsePresets:list', args), + + save: (args) => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), + + onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + ipcRenderer.on('sparsePresets:changed', listener) + return () => ipcRenderer.removeListener('sparsePresets:changed', listener) + } +} satisfies PreloadApi['sparsePresets'] diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts new file mode 100644 index 00000000000..513b219f498 --- /dev/null +++ b/src/preload/api/speech-bridge.ts @@ -0,0 +1,81 @@ +import { ipcRenderer } from 'electron' +import type { + SpeechErrorEvent, + SpeechLifecycleEvent, + SpeechModelManifest, + SpeechModelState, + SpeechTranscriptEvent +} from '../../shared/speech-types' + +export const speechApi = { + getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), + getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), + getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), + saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), + clearOpenAiApiKey: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:clearOpenAiApiKey'), + downloadModel: (modelId: string): Promise => + ipcRenderer.invoke('speech:downloadModel', modelId), + cancelDownload: (modelId: string): Promise => + ipcRenderer.invoke('speech:cancelDownload', modelId), + deleteModel: (modelId: string): Promise => + ipcRenderer.invoke('speech:deleteModel', modelId), + startDictation: ( + modelId: string, + hotwords: string[] | undefined, + sessionId: string + ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), + feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => + // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. + ipcRenderer.invoke( + 'speech:feedAudio', + Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), + sampleRate, + sessionId + ), + stopDictation: (sessionId = 'desktop'): Promise => + ipcRenderer.invoke('speech:stopDictation', sessionId), + + onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => + callback(data) + ipcRenderer.on('speech:partial', listener) + return () => ipcRenderer.removeListener('speech:partial', listener) + }, + onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => + callback(data) + ipcRenderer.on('speech:final', listener) + return () => ipcRenderer.removeListener('speech:final', listener) + }, + onDownloadProgress: ( + callback: (data: { modelId: string; progress: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { modelId: string; progress: number } + ): void => callback(data) + ipcRenderer.on('speech:downloadProgress', listener) + return () => ipcRenderer.removeListener('speech:downloadProgress', listener) + }, + onReady: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => + callback(data) + ipcRenderer.on('speech:ready', listener) + return () => ipcRenderer.removeListener('speech:ready', listener) + }, + onStopped: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => + callback(data) + ipcRenderer.on('speech:stopped', listener) + return () => ipcRenderer.removeListener('speech:stopped', listener) + }, + onError: (callback: (data: SpeechErrorEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechErrorEvent): void => + callback(data) + ipcRenderer.on('speech:error', listener) + return () => ipcRenderer.removeListener('speech:error', listener) + } +} diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts new file mode 100644 index 00000000000..2884e4ec8c1 --- /dev/null +++ b/src/preload/api/ssh-bridge.ts @@ -0,0 +1,183 @@ +import { ipcRenderer } from 'electron' +import type { + SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, + SshConfigImportResult, + SshTargetAddResult, + SshTargetCreateInput, + SshTarget, + SshTargetUpdateInput, + PortForwardEntry, + EnrichedDetectedPort +} from '../../shared/ssh-types' +import { + admitSshConnectionStateForAuthorityReconciliation, + admitSshDetectedPorts +} from '../../shared/ssh-retained-payload-admission' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' + +export const sshApi = { + listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + + listRemovedTargetLabels: (): Promise> => + ipcRenderer.invoke('ssh:listRemovedTargetLabels'), + + addTarget: (args: { target: SshTargetCreateInput }): Promise => + ipcRenderer.invoke('ssh:addTarget', args), + + updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => + ipcRenderer.invoke('ssh:updateTarget', args), + + removeTarget: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removeTarget', args), + + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), + + listConfigHosts: (args?: SshConfigHostListArgs): Promise => + ipcRenderer.invoke('ssh:listConfigHosts', args), + + resolveConfigHost: (args: { alias: string }): Promise => + ipcRenderer.invoke('ssh:resolveConfigHost', args), + + connect: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:connect', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, + + disconnect: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:disconnect', args), + + terminateSessions: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:terminateSessions', args), + + resetRelay: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:resetRelay', args), + + getState: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:getState', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, + + needsPassphrasePrompt: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + + testConnection: async (args: { + targetId: string + }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { + const result: { success: boolean; error?: string; state?: unknown } = await ipcRenderer.invoke( + 'ssh:testConnection', + args + ) + const state = result.state + ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) + : null + return { ...result, ...(state ? { state } : { state: undefined }) } + }, + + onStateChanged: ( + callback: (data: { targetId: string; state: SshConnectionState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; state: unknown } + ): void => { + const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) + if (state) { + callback({ targetId: data.targetId, state }) + } + } + ipcRenderer.on('ssh:state-changed', listener) + return () => ipcRenderer.removeListener('ssh:state-changed', listener) + }, + + addPortForward: (args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), + + updatePortForward: (args: { + id: string + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), + + removePortForward: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removePortForward', args), + + listPortForwards: (args?: { targetId?: string }): Promise => + ipcRenderer.invoke('ssh:listPortForwards', args), + + listDetectedPorts: async (args: { targetId: string }): Promise => + admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), + + onPortForwardsChanged: ( + callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; forwards: PortForwardEntry[] } + ) => callback(data) + ipcRenderer.on('ssh:port-forwards-changed', handler) + return () => ipcRenderer.removeListener('ssh:port-forwards-changed', handler) + }, + + onDetectedPortsChanged: ( + callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; ports: unknown } + ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) + ipcRenderer.on('ssh:detected-ports-changed', handler) + return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) + }, + + browseDir: (args: { + targetId: string + dirPath: string + }): Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor + }> => ipcRenderer.invoke('ssh:browseDir', args), + + onCredentialRequest: ( + callback: (data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' | 'keyboard-interactive' + detail: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' | 'keyboard-interactive' + detail: string + } + ) => callback(data) + ipcRenderer.on('ssh:credential-request', listener) + return () => ipcRenderer.removeListener('ssh:credential-request', listener) + }, + + onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) => + callback(data) + ipcRenderer.on('ssh:credential-resolved', listener) + return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) + }, + + submitCredential: (args: { requestId: string; value: string | null }): Promise => + ipcRenderer.invoke('ssh:submitCredential', args) +} diff --git a/src/preload/api/star-nag-bridge.ts b/src/preload/api/star-nag-bridge.ts new file mode 100644 index 00000000000..b697739a52f --- /dev/null +++ b/src/preload/api/star-nag-bridge.ts @@ -0,0 +1,30 @@ +import { ipcRenderer } from 'electron' + +export const starNagApi = { + onShow: ( + callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' } + ): void => callback(payload) + ipcRenderer.on('star-nag:show', listener) + return () => ipcRenderer.removeListener('star-nag:show', listener) + }, + onHide: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('star-nag:hide', listener) + return () => ipcRenderer.removeListener('star-nag:hide', listener) + }, + dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), + later: (): Promise => ipcRenderer.invoke('star-nag:later'), + complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), + disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), + openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), + starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), + forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), + agentValueMoment: (): Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }> => + ipcRenderer.invoke('star-nag:agentValueMoment'), + showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), + onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') +} diff --git a/src/preload/api/stats-bridge.ts b/src/preload/api/stats-bridge.ts new file mode 100644 index 00000000000..20bc823b86a --- /dev/null +++ b/src/preload/api/stats-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' + +export const statsApi = { + getSummary: (): Promise<{ + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null + }> => ipcRenderer.invoke('stats:summary') +} diff --git a/src/preload/api/terminal-preview-bridge.ts b/src/preload/api/terminal-preview-bridge.ts new file mode 100644 index 00000000000..c8bf5b38623 --- /dev/null +++ b/src/preload/api/terminal-preview-bridge.ts @@ -0,0 +1,33 @@ +import { ipcRenderer } from 'electron' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../../shared/terminal-preview' + +export const terminalPreviewApi = { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise => + ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), + input: (ptyId: string, data: string): Promise => + ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), + fit: ( + ptyId: string, + cols: number, + rows: number + ): Promise<{ cols: number; rows: number } | null> => + ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), + ack: (ptyId: string, bytes: number): Promise => + ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), + unsubscribe: (ptyId: string): Promise => + ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), + onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: TerminalPreviewDataPayload + ): void => callback(payload) + ipcRenderer.on('terminalPreview:data', listener) + return () => ipcRenderer.removeListener('terminalPreview:data', listener) + } +} diff --git a/src/preload/api/ui-bridge-part-1.ts b/src/preload/api/ui-bridge-part-1.ts new file mode 100644 index 00000000000..b402b44b248 --- /dev/null +++ b/src/preload/api/ui-bridge-part-1.ts @@ -0,0 +1,167 @@ +import { ipcRenderer } from 'electron' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { KeybindingActionId } from '../../shared/keybindings' + +export const uiApiPart1 = { + get: () => ipcRenderer.invoke('ui:get'), + set: (args) => ipcRenderer.invoke('ui:set', args), + setWithAck: (args) => ipcRenderer.invoke('ui:set', args), + recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), + onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => callback(ui) + ipcRenderer.on('ui:stateChanged', listener) + return () => ipcRenderer.removeListener('ui:stateChanged', listener) + }, + onOpenSettings: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSettings', listener) + return () => ipcRenderer.removeListener('ui:openSettings', listener) + }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), + onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => callback(shareId) + ipcRenderer.on('ui:openSkillShare', listener) + return () => ipcRenderer.removeListener('ui:openSkillShare', listener) + }, + consumePendingSkillShare: (): Promise => + ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenSetupGuide: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSetupGuide', listener) + return () => ipcRenderer.removeListener('ui:openSetupGuide', listener) + }, + onOpenFeatureTour: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openFeatureTour', listener) + return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) + }, + onOpenCrashReport: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openCrashReport', listener) + return () => ipcRenderer.removeListener('ui:openCrashReport', listener) + }, + onToggleLeftSidebar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleLeftSidebar', listener) + return () => ipcRenderer.removeListener('ui:toggleLeftSidebar', listener) + }, + onToggleRightSidebar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleRightSidebar', listener) + return () => ipcRenderer.removeListener('ui:toggleRightSidebar', listener) + }, + onToggleWorktreePalette: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleWorktreePalette', listener) + return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) + }, + onToggleFloatingTerminal: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleFloatingTerminal', listener) + return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener) + }, + onTerminalShortcutCaptured: ( + callback: (data: { actionId: KeybindingActionId }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { actionId: KeybindingActionId }) => + callback(data) + ipcRenderer.on('ui:terminalShortcutCaptured', listener) + return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener) + }, + onOpenQuickOpen: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openQuickOpen', listener) + return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) + }, + onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) + return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) + }, + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openNewWorkspace', listener) + return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) + }, + onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:deleteCurrentWorkspace', listener) + return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) + }, + onOpenWorkspaceBoard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openWorkspaceBoard', listener) + return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) + }, + onOpenTasks: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openTasks', listener) + return () => ipcRenderer.removeListener('ui:openTasks', listener) + }, + onToggleAgentDashboard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleAgentDashboard', listener) + return () => ipcRenderer.removeListener('ui:toggleAgentDashboard', listener) + }, + onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToWorktreeIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) + }, + onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToTabIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) + }, + onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward') => + callback(direction) + ipcRenderer.on('ui:worktreeHistoryNavigate', listener) + return () => ipcRenderer.removeListener('ui:worktreeHistoryNavigate', listener) + }, + onNewBrowserTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newBrowserTab', listener) + return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) + }, + onNewMarkdownTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newMarkdownTab', listener) + return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) + }, + onNewSimulatorTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newSimulatorTab', listener) + return () => ipcRenderer.removeListener('ui:newSimulatorTab', listener) + }, + onRequestTabCreate: ( + callback: (data: { + requestId: string + url: string + worktreeId?: string + browserPageId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + url: string + worktreeId?: string + browserPageId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + } + ) => callback(data) + ipcRenderer.on('browser:requestTabCreate', listener) + return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) + }, + replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { + ipcRenderer.send('browser:tabCreateReply', reply) + } +} diff --git a/src/preload/api/ui-bridge-part-2.ts b/src/preload/api/ui-bridge-part-2.ts new file mode 100644 index 00000000000..fdf8956622e --- /dev/null +++ b/src/preload/api/ui-bridge-part-2.ts @@ -0,0 +1,203 @@ +import { ipcRenderer } from 'electron' +import { admitCloseActiveTabPayload } from '../close-active-tab-payload-admission' +import type { CloseActiveTabPayload } from '../api/ui-command-event-api' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../shared/worktree/launch-types' +import { browserFindSubscriptions } from '../preload-runtime-support' + +export const uiApiPart2 = { + onRequestTabSetProfile: ( + callback: (data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + } + ) => callback(data) + ipcRenderer.on('browser:requestTabSetProfile', listener) + return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) + }, + replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { + ipcRenderer.send('browser:tabSetProfileReply', reply) + }, + onRequestTabClose: ( + callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; tabId: string | null; worktreeId?: string } + ) => callback(data) + ipcRenderer.on('browser:requestTabClose', listener) + return () => ipcRenderer.removeListener('browser:requestTabClose', listener) + }, + replyTabClose: (reply: { + requestId: string + error?: string + code?: 'browser_tab_not_found' + }): void => { + ipcRenderer.send('browser:tabCloseReply', reply) + }, + onNewTerminalTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newTerminalTab', listener) + return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) + }, + onFocusBrowserAddressBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:focusBrowserAddressBar', listener) + return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) + }, + onFindInBrowserPage: browserFindSubscriptions.subscribe, + onReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:reloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) + }, + onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward'): void => + callback(direction) + ipcRenderer.on('ui:browserHistoryNavigate', listener) + return () => ipcRenderer.removeListener('ui:browserHistoryNavigate', listener) + }, + onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => + callback(direction) + ipcRenderer.on('ui:zoomBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener) + }, + onScrollBrowserPage: ( + callback: (event: { browserPageId: string; deltaX: number; deltaY: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { browserPageId: string; deltaX: number; deltaY: number } + ) => callback(payload) + ipcRenderer.on('ui:scrollBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:scrollBrowserPage', listener) + }, + onHardReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:hardReloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) + }, + onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => { + const admitted = admitCloseActiveTabPayload(payload) + if (admitted.kind === 'legacy') { + callback() + } else if (admitted.kind === 'source') { + callback(admitted.payload) + } + } + ipcRenderer.on('ui:closeActiveTab', listener) + return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) + }, + onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => + callback(payload) + ipcRenderer.on('ui:closeFloatingItem', listener) + return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) + }, + onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => + callback(payload) + ipcRenderer.on('ui:selectFloatingIndex', listener) + return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) + }, + onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTab', listener) + return () => ipcRenderer.removeListener('ui:switchTab', listener) + }, + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) + return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) + }, + onSwitchRecentTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:switchRecentTab', listener) + return () => ipcRenderer.removeListener('ui:switchRecentTab', listener) + }, + onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTerminalTab', listener) + return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) + }, + onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => + callback(data) + ipcRenderer.on('ui:ctrlTabKeyDown', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) + }, + onCtrlTabKeyUp: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:ctrlTabKeyUp', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) + }, + onToggleStatusBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleStatusBar', listener) + return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) + }, + onExportPdfRequested: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('export:requestPdf', listener) + return () => ipcRenderer.removeListener('export:requestPdf', listener) + }, + onAppMenuPaste: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:appMenuPaste', listener) + return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) + }, + onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => + callback(action) + ipcRenderer.on('ui:appMenuSelectionAction', listener) + return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) + }, + onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { plainTextOnly: boolean }): void => + callback({ plainTextOnly: data?.plainTextOnly === true }) + ipcRenderer.on('ui:editableContextPaste', listener) + return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) + }, + onDictationKeyDown: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:dictationKeyDown', listener) + return () => ipcRenderer.removeListener('ui:dictationKeyDown', listener) + }, + onActivateWorktree: ( + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch + } + ) => callback(data) + ipcRenderer.on('ui:activateWorktree', listener) + return () => ipcRenderer.removeListener('ui:activateWorktree', listener) + } +} diff --git a/src/preload/api/ui-bridge-part-3.ts b/src/preload/api/ui-bridge-part-3.ts new file mode 100644 index 00000000000..44669b67838 --- /dev/null +++ b/src/preload/api/ui-bridge-part-3.ts @@ -0,0 +1,214 @@ +import { ipcRenderer } from 'electron' +import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' +import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import type { + RuntimeMobileSessionTabMove, + RuntimeTerminalCreateRequestPayload, + RuntimeTerminalPresentation +} from '../../shared/runtime-types' + +export const uiApiPart3 = { + onCreateTerminal: ( + callback: (data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + } + ) => callback(data) + ipcRenderer.on('ui:createTerminal', listener) + return () => ipcRenderer.removeListener('ui:createTerminal', listener) + }, + onRequestTerminalCreate: ( + callback: (data: RuntimeTerminalCreateRequestPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: RuntimeTerminalCreateRequestPayload + ) => callback(data) + ipcRenderer.on('terminal:requestTabCreate', listener) + return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) + }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, + replyTerminalCreate: (reply: TerminalTabCreateReply): void => { + ipcRenderer.send('terminal:tabCreateReply', reply) + }, + onSplitTerminal: ( + callback: (data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + } + ) => callback(data) + ipcRenderer.on('ui:splitTerminal', listener) + return () => ipcRenderer.removeListener('ui:splitTerminal', listener) + }, + onRenameTerminal: ( + callback: (data: { tabId: string; title: string | null }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; title: string | null } + ) => callback(data) + ipcRenderer.on('ui:renameTerminal', listener) + return () => ipcRenderer.removeListener('ui:renameTerminal', listener) + }, + onFocusTerminal: ( + callback: (data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + } + ) => callback(data) + ipcRenderer.on('ui:focusTerminal', listener) + return () => ipcRenderer.removeListener('ui:focusTerminal', listener) + }, + onFocusEditorTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:focusEditorTab', listener) + return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) + }, + onCloseSessionTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:closeSessionTab', listener) + return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) + }, + onSessionTabCloseRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:sessionTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener) + }, + respondSessionTabClose: (response) => { + ipcRenderer.send('ui:sessionTabCloseResponse', response) + }, + onMoveSessionTab: ( + callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string } & RuntimeMobileSessionTabMove + ) => callback(data) + ipcRenderer.on('ui:moveSessionTab', listener) + return () => ipcRenderer.removeListener('ui:moveSessionTab', listener) + }, + onOpenFileFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + } + ) => callback(data) + ipcRenderer.on('ui:openFileFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) + } +} diff --git a/src/preload/api/ui-bridge-part-4.ts b/src/preload/api/ui-bridge-part-4.ts new file mode 100644 index 00000000000..2c5e2683931 --- /dev/null +++ b/src/preload/api/ui-bridge-part-4.ts @@ -0,0 +1,195 @@ +import { ipcRenderer, webFrame } from 'electron' +import type { + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../../shared/mobile-markdown-document' +import { + richMarkdownContextMenuCommandChannel, + richMarkdownContextMenuTargetChannel, + type RichMarkdownContextMenuCommandPayload, + type RichMarkdownContextMenuTableTarget +} from '../../shared/rich-markdown-context-menu' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' +import { subscribeNativeFileDrop } from '../preload-runtime-support' + +export const uiApiPart4 = { + onOpenDiffFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + } + ) => callback(data) + ipcRenderer.on('ui:openDiffFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) + }, + onMobileMarkdownRequest: ( + callback: (request: RuntimeMobileMarkdownRequest) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => + callback(request) + ipcRenderer.on('ui:mobileMarkdownRequest', listener) + return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) + }, + respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { + ipcRenderer.send('ui:mobileMarkdownResponse', response) + }, + onCloseTerminal: ( + callback: (data: { tabId: string; paneRuntimeId?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; paneRuntimeId?: number } + ) => callback(data) + ipcRenderer.on('ui:closeTerminal', listener) + return () => ipcRenderer.removeListener('ui:closeTerminal', listener) + }, + onTerminalTabCloseRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:terminalTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) + }, + respondTerminalTabClose: (response) => { + ipcRenderer.send('ui:terminalTabCloseResponse', response) + }, + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:sleepWorktree', listener) + return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) + }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, + onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => + callback(direction) + ipcRenderer.on('terminal:zoom', listener) + return () => ipcRenderer.removeListener('terminal:zoom', listener) + }, + readClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readText', options), + readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readSelectionText', options), + saveClipboardImageAsTempFile: (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), + writeClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeText', text), + writeTerminalClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeTerminalText', text), + writeSelectionClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeSelectionText', text), + writeClipboardImage: (dataUrl: string): Promise => + ipcRenderer.invoke('clipboard:writeImage', dataUrl), + performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { + ipcRenderer.send('ui:performNativePaste', { + mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' + }) + }, + performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { + ipcRenderer.send('ui:performNativeSelectionAction', action) + }, + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), + onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => + subscribeNativeFileDrop(callback), + getZoomLevel: (): number => webFrame.getZoomLevel(), + setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), + syncTrafficLights: (zoomFactor: number): void => + ipcRenderer.send('ui:sync-traffic-lights', zoomFactor), + setMarkdownEditorFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setMarkdownEditorFocused', focused) + }, + setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { + ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) + }, + setTerminalInputFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setTerminalInputFocused', focused) + }, + setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { + ipcRenderer.send('ui:setFloatingFocus', state) + }, + setShortcutRecorderFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setShortcutRecorderFocused', focused) + }, + onRichMarkdownContextCommand: ( + callback: (payload: RichMarkdownContextMenuCommandPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: RichMarkdownContextMenuCommandPayload + ) => callback(payload) + ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) + return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) + }, + onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => + callback(isFullScreen) + ipcRenderer.on('window:fullscreen-changed', listener) + return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) + }, + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, + minimize: (): void => { + ipcRenderer.send('window:minimize') + }, + maximize: (): void => { + ipcRenderer.send('window:maximize') + }, + isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), + onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => + callback(isMaximized) + ipcRenderer.on('window:maximize-changed', listener) + return () => ipcRenderer.removeListener('window:maximize-changed', listener) + }, + requestClose: (): void => { + ipcRenderer.send('window:request-close') + }, + popupMenu: (): void => { + ipcRenderer.send('menu:popup') + }, + onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { isQuitting: boolean; requestId?: number } + ): void => { + // Why: main cannot reach will-quit while a frozen renderer owns the window close handshake. + ipcRenderer.send('window:close-request-received', data?.requestId) + callback({ isQuitting: data?.isQuitting ?? false }) + } + ipcRenderer.on('window:close-requested', listener) + return () => ipcRenderer.removeListener('window:close-requested', listener) + }, + confirmWindowClose: (): void => { + ipcRenderer.send('window:confirm-close') + } +} diff --git a/src/preload/api/ui-bridge-part-5.ts b/src/preload/api/ui-bridge-part-5.ts new file mode 100644 index 00000000000..7c4c10eb1c6 --- /dev/null +++ b/src/preload/api/ui-bridge-part-5.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' + +export const uiApiPart5 = { + notifyWindowRevealed: (): void => { + ipcRenderer.send('ui:window-revealed') + } +} diff --git a/src/preload/api/ui-bridge.ts b/src/preload/api/ui-bridge.ts new file mode 100644 index 00000000000..97e199215c6 --- /dev/null +++ b/src/preload/api/ui-bridge.ts @@ -0,0 +1,14 @@ +import type { PreloadApi } from '../api-types' +import { uiApiPart1 } from './ui-bridge-part-1' +import { uiApiPart2 } from './ui-bridge-part-2' +import { uiApiPart3 } from './ui-bridge-part-3' +import { uiApiPart4 } from './ui-bridge-part-4' +import { uiApiPart5 } from './ui-bridge-part-5' + +export const uiApi = { + ...uiApiPart1, + ...uiApiPart2, + ...uiApiPart3, + ...uiApiPart4, + ...uiApiPart5 +} satisfies PreloadApi['ui'] diff --git a/src/preload/api/updater-bridge.ts b/src/preload/api/updater-bridge.ts new file mode 100644 index 00000000000..2b40e42b8be --- /dev/null +++ b/src/preload/api/updater-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { UpdateStatus } from '../../shared/update-status-types' +import { prepareAndInvokeUpdaterInstall } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint, updaterQuitAbortRelay } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const updaterApi = { + getStatus: () => ipcRenderer.invoke('updater:getStatus'), + getVersion: () => ipcRenderer.invoke('updater:getVersion'), + check: (options) => ipcRenderer.invoke('updater:check', options), + download: () => ipcRenderer.invoke('updater:download'), + dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), + dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), + getLinuxPackageInstallInstructions: () => + ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), + showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), + listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), + quitAndInstall: (): Promise => + prepareAndInvokeUpdaterInstall( + window, + updaterQuitAbortRelay, + () => ipcRenderer.invoke('updater:quitAndInstall'), + awaitBeforeUnloadCheckpoint + ), + + onStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) + ipcRenderer.on('updater:status', listener) + return () => ipcRenderer.removeListener('updater:status', listener) + }, + onClearDismissal: (callback) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('updater:clearDismissal', listener) + return () => ipcRenderer.removeListener('updater:clearDismissal', listener) + } +} satisfies PreloadApi['updater'] diff --git a/src/preload/api/workspace-cleanup-bridge.ts b/src/preload/api/workspace-cleanup-bridge.ts new file mode 100644 index 00000000000..e9e4ae227cb --- /dev/null +++ b/src/preload/api/workspace-cleanup-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' +import type { PreloadApi } from '../api-types' + +export const workspaceCleanupApi = { + scan: (args, onProgress) => { + if (!onProgress) { + return ipcRenderer.invoke('workspaceCleanup:scan', args) + } + const scanId = args?.scanId ?? crypto.randomUUID() + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceCleanupScanProgress + ): void => { + if (progress.scanId === scanId) { + onProgress(progress) + } + } + ipcRenderer.on('workspaceCleanup:scanProgress', listener) + return ipcRenderer + .invoke('workspaceCleanup:scan', { ...args, scanId }) + .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) + }, + cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), + getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), + dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), + clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args) => + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + beginRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + recordRemovalSnapshotPrune: (args) => + ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + finishRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) +} satisfies PreloadApi['workspaceCleanup'] diff --git a/src/preload/api/workspace-ports-bridge.ts b/src/preload/api/workspace-ports-bridge.ts new file mode 100644 index 00000000000..6cb4c859ed3 --- /dev/null +++ b/src/preload/api/workspace-ports-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { WorkspacePortAdvertisedUrlChangedEvent } from '../../shared/workspace-ports' +import type { PreloadApi } from '../api-types' + +export const workspacePortsApi = { + scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), + kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), + onAdvertisedUrlChanged: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: WorkspacePortAdvertisedUrlChangedEvent + ): void => callback(event) + ipcRenderer.on('workspacePorts:advertised-url-changed', listener) + return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) + } +} satisfies PreloadApi['workspacePorts'] diff --git a/src/preload/api/workspace-space-bridge.ts b/src/preload/api/workspace-space-bridge.ts new file mode 100644 index 00000000000..ec9bc74e12a --- /dev/null +++ b/src/preload/api/workspace-space-bridge.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' +import type { WorkspaceSpaceScanProgress } from '../../shared/workspace-space-types' +import type { PreloadApi } from '../api-types' + +export const workspaceSpaceApi = { + analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), + getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), + cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), + onProgress: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceSpaceScanProgress + ): void => callback(progress) + ipcRenderer.on('workspaceSpace:progress', listener) + return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) + } +} satisfies PreloadApi['workspaceSpace'] diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts new file mode 100644 index 00000000000..8fb8d5728ac --- /dev/null +++ b/src/preload/api/worktrees-bridge.ts @@ -0,0 +1,117 @@ +import { ipcRenderer } from 'electron' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../../shared/host-lineage-contract' +import type { + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent +} from '../../shared/worktree/base-ref-drift-types' +import type { WorktreeHeadIdentity } from '../../shared/worktree/types' +import type { PreloadApi } from '../api-types' + +export const worktreesApi = { + list: (args) => ipcRenderer.invoke('worktrees:list', args), + listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), + + listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + + forgetRemovedForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), + + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + + listAll: () => ipcRenderer.invoke('worktrees:listAll'), + + create: (args) => ipcRenderer.invoke('worktrees:create', args), + + adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), + + onCreateProgress: ( + callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { creationId?: string; phase: 'fetching' | 'creating' } + ) => callback(data) + ipcRenderer.on('createWorktree:progress', listener) + return () => ipcRenderer.removeListener('createWorktree:progress', listener) + }, + + prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), + + resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), + + resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), + + remove: (args) => ipcRenderer.invoke('worktrees:remove', args), + + forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), + + forceDeletePreservedBranch: (args) => + ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), + + updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), + + listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), + + listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => + ipcRenderer.invoke('worktrees:listLineageForHost', args), + + updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), + + persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), + + getBranchRenameFailureOutput: (args) => + ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), + + onChanged: ( + callback: (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } + ) => callback(data) + ipcRenderer.on('worktrees:changed', listener) + return () => ipcRenderer.removeListener('worktrees:changed', listener) + }, + + onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) + return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) + }, + + onHeadIdentitiesChanged: ( + callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; identities: WorktreeHeadIdentity[] } + ) => callback(data) + ipcRenderer.on('worktrees:headIdentitiesChanged', listener) + return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener) + }, + + onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => + callback(data) + ipcRenderer.on('worktree:baseStatus', listener) + return () => ipcRenderer.removeListener('worktree:baseStatus', listener) + }, + + onRemoteBranchConflict: ( + callback: (data: WorktreeRemoteBranchConflictEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeRemoteBranchConflictEvent) => + callback(data) + ipcRenderer.on('worktree:remoteBranchConflict', listener) + return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) + } +} satisfies PreloadApi['worktrees'] diff --git a/src/preload/api/wsl-bridge.ts b/src/preload/api/wsl-bridge.ts new file mode 100644 index 00000000000..aeb32cdfa45 --- /dev/null +++ b/src/preload/api/wsl-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' + +export const wslApi = { + isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), + listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d1c2c358cc2..ad97a911fe5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5351 +1,189 @@ -/* eslint-disable max-lines -- Why: preload is the audited renderer/Electron IPC contract; co-locating the surface eases security and type-drift review. */ -import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -import { preloadE2EConfig } from './e2e-config' -import { glApi } from './gitlab' -import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission' -import type { CloseActiveTabPayload } from './api/ui-command-event-api' -import type { - SkillDeletePlan, - SkillDeleteRequest, - SkillDeleteResult -} from '../shared/skill-delete-contract' +import type { PreloadApi } from './api-types' import { - DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, - DOC_PREVIEW_LOAD_FAILURE_CHANNEL, - DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, - DOC_PREVIEW_MINT_GRANT_CHANNEL, - DOC_PREVIEW_REVOKE_GRANT_CHANNEL, - type DocPreviewFailure -} from '../shared/doc-preview-scheme' -import type { DocPreviewGrantRequest } from './api/doc-preview-api' -import type { AppIdentity } from '../shared/app-identity' -import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys' -import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' -import type { - DashboardRevealAgentArgs, - DashboardSleepWorkspaceArgs, - DashboardSnapshot, - DashboardSpawnAgentArgs -} from '../shared/dashboard-snapshot' -import type { - TerminalPreviewConnectResult, - TerminalPreviewDataPayload -} from '../shared/terminal-preview' -import type { AgentSessionPtyWriteRefusal } from '../shared/agent-session-pty-write-admission' -import type { CliInstallStatus } from '../shared/cli-install-types' -import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' -import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' -import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity' -import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../shared/agent-session-resume' -import type { MobileRelayStatus } from '../shared/mobile-relay-status' -import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' -import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' -import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' -import type { - SshMutationExpectation, - SshConnectionState, - SshConfigHostListArgs, - SshConfigHostListResult, - SshConfigHostResolution, - SshConfigImportResult, - SshTargetAddResult, - SshTargetCreateInput, - SshTarget, - SshTargetUpdateInput, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' -import { - admitSshConnectionStateForAuthorityReconciliation, - admitSshDetectedPorts -} from '../shared/ssh-retained-payload-admission' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../shared/host-repo-catalog-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../shared/host-lineage-contract' -import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../shared/plugins/plugin-panel-bridge' -import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' -import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' -import type { BrowserViewportOverride } from '../shared/browser-workspace-types' -import type { - BrowserWebAuthnAccountRequest, - BrowserWebAuthnAccountResponse -} from '../shared/browser-webauthn-account' -import type { SearchResult } from '../shared/code-search-types' -import type { - FilesystemPathFlavor, - FsChangedPayload, - MarkdownDocument -} from '../shared/filesystem-entry-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../shared/git-fork-sync' -import type { GitStagingArea, GitUpstreamStatus } from '../shared/git-status-types' -import type { GitHubCommentResult, GitHubReactionContent } from '../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, - GitHubPRRefreshReason -} from '../shared/github/pull-request-refresh-types' -import type { GitHubAssignableUser, GitHubOwnerRepo } from '../shared/github/pull-request-types' -import type { GetRateLimitResult } from '../shared/github/rate-limit-types' -import type { GitHubWorkItem, ListWorkItemsResult } from '../shared/github/work-item-types' -import type { GhosttyImportPreview } from '../shared/global-settings-types' -import type { GitHubCreateIssueResult } from '../shared/issue-mutation-types' -import type { JiraProjectStatusOrder } from '../shared/jira-types' -import type { LinearProjectDetail } from '../shared/linear/project-types' -import type { - NotificationDeliveryProbeResult, - NotificationDismissResult, - NotificationDispatchResult, - NotificationPermissionStatusResult, - NotificationSoundDataResult, - NotificationSoundPathResult, - NotificationSoundResult -} from '../shared/notification-settings-types' -import type { OnboardingState } from '../shared/onboarding-state-types' -import type { PersistedUIState } from '../shared/persisted-ui-state-types' -import type { CustomPet } from '../shared/pet-types' -import type { MemorySnapshot } from '../shared/process-stats-types' -import type { NestedRepoScanResult } from '../shared/project-group-types' -import type { BaseRefDefaultResult, BaseRefSearchResult } from '../shared/repo-types' -import type { TuiAgent } from '../shared/tui-agent' -import type { FloatingTerminalCwdRequest } from '../shared/ui-chrome-types' -import type { UpdateStatus } from '../shared/update-status-types' -import type { - WorktreeBaseStatusEvent, - WorktreeRemoteBranchConflictEvent -} from '../shared/worktree/base-ref-drift-types' -import type { - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch -} from '../shared/worktree/launch-types' -import type { GitPushTarget, WorktreeHeadIdentity } from '../shared/worktree/types' -import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' -import type { PtyListedSession } from '../shared/pty-listed-session' -import type { - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../shared/pty-renderer-delivery-health' -import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' -import type { WriteTerminalRenderDesyncEvidenceArgs } from '../shared/terminal-render-desync-evidence' -import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' -import type { - WarpThemeImportPreview, - WarpThemeImportSource -} from '../shared/terminal-custom-themes' -import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' -import type { - ShellOpenExternalEditorRequest, - ShellOpenExternalEditorResult, - ShellOpenLocalPathResult -} from '../shared/shell-open-types' -import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' -import type { - SkillCloudOwnedShare, - SkillCloudOperation, - SkillCloudPackageDetails -} from '../shared/skill-cloud-contract' -import type { - SkillBundleInstallPreviewInput, - SkillBundleInstallPreviewOperation, - SkillBundlePackageVersionInstallInput, - SkillBundleShareInstallInput, - SkillBundleShareInstallOperation, - SkillInstallPreviewInput, - SkillInstallPreviewOperation, - ManagedSkillInstallListOperation, - SkillPackageVersionInstallInput, - SkillRemoveInput, - SkillRemoveOperation, - SkillShareInstallInput, - SkillShareInstallOperation, - SkillInstallCancelInput, - SkillInstallProgress, - SkillSharePreview, - SkillShareProgress, - SkillSharePublishInput, - SkillSharePublishOperation, - SkillShareResolvedOperation -} from '../shared/skill-sharing-contract' -import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../shared/skill-freshness' -import type { ClientHostedBrowserRowsEvent } from '../shared/client-hosted-browser-rows' -import type { - RuntimeBrowserDriverState, - RuntimeMobileSessionTabMove, - RuntimeRendererSyncWindowGraph, - RuntimeStatus, - RuntimeSyncWindowGraphResult, - RuntimeTerminalCreateRequestPayload, - RuntimeTerminalDriverState, - RuntimeTerminalPresentation -} from '../shared/runtime-types' -import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../shared/remote-runtime-shared-control-types' -import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../shared/runtime-environment-diagnostics' -import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' -import type { - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../shared/mobile-markdown-document' -import type { - CodexRateLimitResetResult, - GrokAccountStatus, - RateLimitRuntimeTarget, - RateLimitState -} from '../shared/rate-limit-types' -import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' -import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' -import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' -import type { GhAuthDiagnostic } from '../shared/github/auth-types' -import type { TaskSourceContext } from '../shared/task-source-context' -import type { - GetProjectViewTableResult, - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAccessibleProjectsResult, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugResult, - ListLabelsBySlugResult, - ListProjectViewsResult, - ProjectWorkItemDetailsBySlugResult, - ResolveProjectRefResult -} from '../shared/github/project-result-types' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../shared/github/project-request-types' -import { - richMarkdownContextMenuCommandChannel, - richMarkdownContextMenuTargetChannel, - type RichMarkdownContextMenuCommandPayload, - type RichMarkdownContextMenuTableTarget -} from '../shared/rich-markdown-context-menu' -import type { - AgentStatusClearIpcPayload, - AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry -} from '../shared/agent-status-types' -import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' -import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import type { - SpeechErrorEvent, - SpeechLifecycleEvent, - SpeechModelManifest, - SpeechModelState, - SpeechTranscriptEvent -} from '../shared/speech-types' -import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { - PreflightRuntimeContext, - RefreshAgentsResult, - NativeChatAppendedPayload, - NativeChatReadSessionResult, - NativeChatSubscriptionFrame, - PluginHostInstallResult, - PluginHostInstallSource, - PluginHostListEntry, - PluginHostLogLine, - ExternalAutomationManagerResult, - PreloadApi -} from './api-types' -import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' -import { - KEYBOARD_LAYOUT_CHANGED_CHANNEL, - type KeyboardLayoutChangeEvent -} from '../shared/keyboard-layout-events' -import { createBrowserFindSubscriptions } from './browser-find-subscriptions' -import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' -import { readBrowserClientHostIdArgument } from '../shared/browser-client-host-id-argument' -import { createUsageProviderApi } from './usage-provider-api' -import type { AppStarSource } from '../shared/gh-star-source' -import type { ExecutionHostId } from '../shared/execution-host' -import type { - AutomationDispatchRequest, - AutomationDispatchResult, - ExternalAutomationRunsPage, - AutomationRun, - AutomationPrecheckResult -} from '../shared/automations-types' -import type { AutomationOwnerRef } from '../shared/automation-owner-ref' -import type { - ScopedExternalManagerActionRequest, - ScopedExternalManagerCreateRequest, - ScopedExternalManagerListRequest, - ScopedExternalManagerRunsRequest, - ScopedExternalManagerUpdateRequest -} from '../shared/external-automation-scope' -import type { AutomationsChangedPayload } from '../shared/runtime-client-events' -import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' -import type { - AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult -} from '../shared/ai-vault-session-deletion' -import type { - AiVaultFirstUserPromptArgs, - AiVaultListArgs, - AiVaultSubagentListArgs -} from '../shared/ai-vault-types' -import type { AiVaultSessionTitlesArgs } from '../shared/ai-vault-session-title' -import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' -import type { AgentType } from '../shared/native-chat-types' -import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' -import { - ORCA_INTERNAL_FILE_DRAG_TYPE, - createNativeFileDropPayload, - createRejectedNativeFileDropPayload, - hasNativeFileDragTypes, - NATIVE_FILE_DROP_MAX_PATHS, - resolveNativeFileDropPath, - type NativeDropResolution, - type NativeFileDropPayload, - type NativeFileDropPathEntry -} from '../shared/native-file-drop' -import type { - LocalLogTailChangedPayload, - LocalLogTailReadArgs, - LocalLogTailReadResult, - LocalLogTailWatchArgs -} from '../shared/local-log-tail-types' -import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' -import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' -import type { HostedReviewForBranchArgs } from '../shared/hosted-review' -import type { ReadClipboardTextOptions } from '../shared/clipboard-text' -import type { - LocalhostWorktreeLabelResult, - LocalhostWorktreeLabelRoute -} from '../shared/localhost-worktree-labels' -import type { - CrashReportBreadcrumbData, - CrashReportCopyDiagnosticsArgs, - CrashReportSubmitArgs, - CrashReportSubmitResult, - ReactErrorBoundaryReportArgs, - ReactErrorBoundaryReportResult -} from '../shared/crash-reporting' -import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' -import type { RendererProcessMemory } from '../shared/renderer-process-memory' -import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import { readRendererProcessMemory } from './renderer-process-memory-reader' -import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' -import { - prepareAndInvokeAppRestart, - prepareAndInvokeUpdaterInstall, - registerRendererRestartIpcRelays -} from './renderer-restart-wiring' + installBrowserFindListener, + installNativeFileDropHandlers +} from './preload-runtime-support' +import { appApi } from './api/app-bridge' +import { orcaProfilesApi } from './api/orca-profiles-bridge' +import { platformApi } from './api/platform-bridge' +import { wslApi } from './api/wsl-bridge' +import { pwshApi } from './api/pwsh-bridge' +import { gitBashApi } from './api/git-bash-bridge' +import { pluginsApi } from './api/plugins-bridge' +import { reposApi } from './api/repos-bridge' +import { projectsApi } from './api/projects-bridge' +import { projectGroupsApi } from './api/project-groups-bridge' +import { folderWorkspacesApi } from './api/folder-workspaces-bridge' +import { sparsePresetsApi } from './api/sparse-presets-bridge' +import { worktreesApi } from './api/worktrees-bridge' +import { workspaceCleanupApi } from './api/workspace-cleanup-bridge' +import { workspaceSpaceApi } from './api/workspace-space-bridge' +import { workspacePortsApi } from './api/workspace-ports-bridge' +import { ptyApi } from './api/pty-bridge' +import { feedbackApi } from './api/feedback-bridge' +import { crashReportsApi } from './api/crash-reports-bridge' +import { exportApi } from './api/export-bridge' +import { ghApi } from './api/gh-bridge' +import { hostedReviewApi } from './api/hosted-review-bridge' +import { glApiBridge } from './api/gl-bridge' +import { bitbucketApi } from './api/bitbucket-bridge' +import { linearApi } from './api/linear-bridge' +import { jiraApi } from './api/jira-bridge' +import { starNagApi } from './api/star-nag-bridge' +import { diagnosticsApi } from './api/diagnostics-bridge' +import { settingsApi } from './api/settings-bridge' +import { agentAwakeApi } from './api/agent-awake-bridge' +import { localhostWorktreeLabelsApi } from './api/localhost-worktree-labels-bridge' +import { keybindingsApi } from './api/keybindings-bridge' +import { codexAccountsApi } from './api/codex-accounts-bridge' +import { claudeAccountsApi } from './api/claude-accounts-bridge' +import { cliApi } from './api/cli-bridge' +import { codexConfigSyncApi } from './api/codex-config-sync-bridge' +import { agentTrustApi } from './api/agent-trust-bridge' +import { preflightApi } from './api/preflight-bridge' +import { notificationsApi } from './api/notifications-bridge' +import { onboardingApi } from './api/onboarding-bridge' +import { dashboardApi } from './api/dashboard-bridge' +import { terminalPreviewApi } from './api/terminal-preview-bridge' +import { macosTccPromptsApi } from './api/macos-tcc-prompts-bridge' +import { developerPermissionsApi } from './api/developer-permissions-bridge' +import { computerUsePermissionsApi } from './api/computer-use-permissions-bridge' +import { shellApi } from './api/shell-bridge' +import { skillsApi } from './api/skills-bridge' +import { petApi } from './api/pet-bridge' +import { browserApi } from './api/browser-bridge' +import { emulatorApi } from './api/emulator-bridge' +import { hooksApi } from './api/hooks-bridge' +import { ephemeralVmApi } from './api/ephemeral-vm-bridge' +import { cacheApi } from './api/cache-bridge' +import { sessionApi } from './api/session-bridge' +import { remoteWorkspaceApi } from './api/remote-workspace-bridge' +import { updaterApi } from './api/updater-bridge' +import { docPreviewApi } from './api/doc-preview-bridge' +import { notebookApi } from './api/notebook-bridge' +import { fsApi } from './api/fs-bridge' +import { gitApi } from './api/git-bridge' +import { uiApi } from './api/ui-bridge' +import { statsApi } from './api/stats-bridge' +import { memoryApi } from './api/memory-bridge' +import { claudeUsageApi } from './api/claude-usage-bridge' +import { codexUsageApi } from './api/codex-usage-bridge' +import { openCodeUsageApi } from './api/open-code-usage-bridge' +import { aiVaultApi } from './api/ai-vault-bridge' +import { nativeChatApi } from './api/native-chat-bridge' +import { runtimeApi } from './api/runtime-bridge' +import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' +import { rateLimitsApi } from './api/rate-limits-bridge' +import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' +import { grokAccountsApi } from './api/grok-accounts-bridge' +import { sshApi } from './api/ssh-bridge' +import { automationsApi } from './api/automations-bridge' +import { e2eApi } from './api/e2e-bridge' +import { mobileApi } from './api/mobile-bridge' +import { agentStatusApi } from './api/agent-status-bridge' +import { speechApi } from './api/speech-bridge' -// Why: the sync checkpoint only stages; this joins its durable write so a -// navigating path can abort instead of losing the staged session. -async function awaitBeforeUnloadCheckpoint(): Promise { - const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { - ok?: unknown - } - if (result?.ok !== true) { - throw new Error('Failed to persist renderer state before unload.') - } -} +installNativeFileDropHandlers() +installBrowserFindListener() -type NativeFileDropCallback = (data: NativeFileDropPayload) => void +// Custom APIs for renderer. Each domain bridge owns its IPC contract. +const telemetryTrackApi: PreloadApi['telemetryTrack'] = (name, props) => + ipcRenderer.invoke('telemetry:track', name, props) +const telemetrySetOptInApi: PreloadApi['telemetrySetOptIn'] = (optedIn) => + ipcRenderer.invoke('telemetry:setOptIn', optedIn) +const telemetryAcknowledgeBannerApi: PreloadApi['telemetryAcknowledgeBanner'] = () => + ipcRenderer.invoke('telemetry:acknowledgeBanner') +const telemetryGetConsentStateApi: PreloadApi['telemetryGetConsentState'] = () => + ipcRenderer.invoke('telemetry:getConsentState') -const nativeFileDropCallbacks: NativeFileDropCallback[] = [] -let nativeFileDropListenerRegistered = false -const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( - window, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT -) - -registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay) - -function getLinuxDisplayServer(): 'wayland' | 'x11' | null { - if (process.platform !== 'linux') { - return null - } - if ( - process.env.WAYLAND_DISPLAY || - process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' || - process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland' - ) { - return 'wayland' - } - return process.env.DISPLAY ? 'x11' : null -} - -const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { - for (const callback of Array.from(nativeFileDropCallbacks)) { - callback(data) - } -} - -function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { - nativeFileDropCallbacks.push(callback) - if (!nativeFileDropListenerRegistered) { - // Why: keep one real IPC listener and fan out locally — panes subscribe per split group, which would otherwise trip listener warnings. - ipcRenderer.on('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = true - } - return () => { - const callbackIndex = nativeFileDropCallbacks.indexOf(callback) - if (callbackIndex !== -1) { - nativeFileDropCallbacks.splice(callbackIndex, 1) - } - if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { - ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = false - } - } -} - -// Why: cache one shared Audio + blob URL per sound path so we don't re-read 10MB from disk and re-transfer over IPC on every notification. -let cachedNotificationSound: { - path: string - blobUrl: string - audio: HTMLAudioElement -} | null = null -let isNotificationSoundPlaying = false -// Why: audio.play() can reject before ended/error fires — cleanup hook prevents leaked listeners on the cached Audio. -let cleanupNotificationSoundPlayback: (() => void) | null = null - -function clearNotificationSoundPlaybackState(): void { - cleanupNotificationSoundPlayback?.() - cleanupNotificationSoundPlayback = null - isNotificationSoundPlaying = false -} - -function disposeCachedNotificationSound(): void { - if (cachedNotificationSound) { - clearNotificationSoundPlaybackState() - cachedNotificationSound.audio.pause() - cachedNotificationSound.audio.src = '' - URL.revokeObjectURL(cachedNotificationSound.blobUrl) - cachedNotificationSound = null - } -} - -/** - * Classify which UI surface the native OS drop landed on, and for file-explorer drops - * extract the destination directory from `data-native-file-drop-dir`. - * - * Why: preload consumes the native `drop` before React can read paths, so it must capture - * the destination dir now — otherwise the renderer can't tell "root" from "inside this folder". - */ -function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const pathEntries: NativeFileDropPathEntry[] = [] - for (const entry of event.composedPath()) { - if (entry instanceof HTMLElement) { - pathEntries.push({ - nativeFileDropTarget: entry.dataset.nativeFileDropTarget, - nativeFileDropDir: entry.dataset.nativeFileDropDir, - terminalTabId: entry.dataset.terminalTabId, - terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId - }) - } - } - return resolveNativeFileDropPath(pathEntries) -} - -// File drag-and-drop lives in preload because webUtils (File→path) is only available in the preload/main world, not the renderer's isolated world. -document.addEventListener( - 'dragover', - (e) => { - // Let in-app drags through to React handlers (their own dropEffect); only override for native OS file drops. - if (e.dataTransfer && !hasNativeFileDragTypes(e.dataTransfer.types)) { - return - } - e.preventDefault() - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy' - } - }, - true -) - -document.addEventListener( - 'drop', - (e) => { - // Let in-app drags (e.g. file explorer → terminal) through to React handlers - if (e.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { - return - } - - e.preventDefault() - e.stopPropagation() - const files = e.dataTransfer?.files - if (!files || files.length === 0) { - return - } - const resolution = resolveNativeFileDrop(e) - - // Why: reject oversized gestures by count before resolving every File object (path resolution is synchronous here). - if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { - ipcRenderer.send( - 'terminal:file-dropped-from-preload', - createRejectedNativeFileDropPayload({ - byteLength: 0, - pathCount: files.length, - reason: 'too-many-paths', - status: 'rejected' - }) - ) - return - } - - const paths: string[] = [] - for (let i = 0; i < files.length; i++) { - // webUtils.getPathForFile is the Electron 28+ replacement for File.path - const filePath = webUtils.getPathForFile(files[i]) - if (filePath) { - paths.push(filePath) - } - } - - if (paths.length === 0) { - return - } - - // Why: explorer marker present but no destination dir resolved → reject entirely, no editor fallback (fail-closed, design §7.1). - if (resolution?.target === 'rejected') { - return - } - - const payload = createNativeFileDropPayload(resolution, paths) - if (!payload) { - return - } - // Why: emit exactly one native-drop event per gesture (the shared planner rejects oversized payloads without leaking path contents). - ipcRenderer.send('terminal:file-dropped-from-preload', payload) - }, - true -) - -const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' -const browserFindSubscriptions = createBrowserFindSubscriptions() -const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ - ipc: ipcRenderer, - isTopFrame: () => window.top === window -}) - -ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { - browserFindSubscriptions.dispatch(source) -}) - -// Custom APIs for renderer const api = { - app: { - getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), - getFeatureWallAssetBaseUrl: (): Promise => - ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), - relaunch: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:relaunch'), - awaitBeforeUnloadCheckpoint - ), - restart: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:restart'), - awaitBeforeUnloadCheckpoint - ), - reload: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:reload'), - awaitBeforeUnloadCheckpoint - ), - stageBeforeUnloadSync: (args: Parameters[0]) => { - const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { - ok?: unknown - } - if (result?.ok !== true) { - throw new Error('Failed to stage renderer state before unload.') - } - }, - awaitFirstWindowStartupServices: (): Promise => - ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), - prepareTerminalStartupRestoration: (): Promise => - ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => - ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), - startupDiagnostic: (event: string, details?: Record): Promise => - startupDiagnosticsEnabled - ? ipcRenderer.invoke('app:startupDiagnostic', event, details) - : Promise.resolve(), - // Why: macOS input mode (or layout ID) so keyboard workarounds can tell CJK/compose layouts from US QWERTY (issue #1205); null on non-Darwin or read failure. - getKeyboardInputSourceId: (): Promise => - ipcRenderer.invoke('app:getKeyboardInputSourceId'), - getMacCapturedDigitRowChords: (): Promise => - ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), - getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), - onKeyboardLayoutChanged: ( - callback: (event: KeyboardLayoutChangeEvent) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: KeyboardLayoutChangeEvent - ): void => callback(event) - ipcRenderer.on(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) - return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) - }, - setUnreadDockBadgeCount: (count: number): Promise => - ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), - getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => - ipcRenderer.invoke('app:getFloatingTerminalCwd', args), - getFloatingMarkdownDirectory: (): Promise => - ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), - pickFloatingMarkdownDocument: (): Promise => - ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), - pickFloatingWorkspaceDirectory: (): Promise => - ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), - writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => - ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) - }, - - orcaProfiles: { - list: () => ipcRenderer.invoke('orcaProfiles:list'), - authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), - createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), - createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), - findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), - connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), - refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), - signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), - selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), - orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), - orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), - orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), - orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), - orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) - } satisfies PreloadApi['orcaProfiles'], - - platform: { - get: () => ({ - platform: process.platform, - // Why: sandboxed preload cannot require node:os; Electron exposes the OS - // version on process.getSystemVersion when available. - osRelease: - (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? - '', - arch: process.arch, - // Why: these identify the default shell without probing user config files. - // process.env is available in the sandboxed preload; node:os is not. - shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', - displayServer: getLinuxDisplayServer() - }) - } satisfies PreloadApi['platform'], - - wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), - listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') - }, - - pwsh: { - isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') - }, - - gitBash: { - isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') - }, - - plugins: { - list: (): Promise => ipcRenderer.invoke('plugins:list'), - listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), - consent: (args: PluginConsentRequest): Promise => - ipcRenderer.invoke('plugins:consent', args), - setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => - ipcRenderer.invoke('plugins:setEnabled', args), - readPanelEntry: (args: { - pluginKey: string - panelId: string - }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), - panelAction: (args: { - sessionToken: string - action: string - params?: unknown - }): Promise => ipcRenderer.invoke('plugins:panelAction', args), - install: (source: PluginHostInstallSource): Promise => - ipcRenderer.invoke('plugins:install', source), - listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), - addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), - removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), - refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), - listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), - previewMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), - installMarketplacePlugin: (preview) => - ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), - previewMarketplaceUpdate: (args) => - ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), - rollbackMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), - remove: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:remove', args), - getLogs: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:getLogs', args), - refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), - onChanged: (callback): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => - callback(change) - ipcRenderer.on('plugins:changed', listener) - return () => { - ipcRenderer.removeListener('plugins:changed', listener) - } - } - } satisfies PreloadApi['plugins'], - - repos: { - list: () => ipcRenderer.invoke('repos:list'), - - listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), - - add: (args) => ipcRenderer.invoke('repos:add', args), - - addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), - - create: (args) => ipcRenderer.invoke('repos:create', args), - - isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), - - getDefaultCreateProjectParent: (): Promise => - ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), - - remove: (args) => ipcRenderer.invoke('repos:remove', args), - - removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), - - reorder: (args) => ipcRenderer.invoke('repos:reorder', args), - - reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), - - update: (args) => ipcRenderer.invoke('repos:update', args), - - pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), - - pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), - - pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), - - clone: (args) => ipcRenderer.invoke('repos:clone', args), - - cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), - - createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), - - cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), - - onCloneProgress: ( - callback: (data: { phase: string; percent: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { phase: string; percent: number } - ) => callback(data) - ipcRenderer.on('repos:clone-progress', listener) - return () => ipcRenderer.removeListener('repos:clone-progress', listener) - }, - - getGitUsername: (args: { repoId: string }): Promise => - ipcRenderer.invoke('repos:getGitUsername', args), - - getBaseRefDefault: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), - - searchBaseRefs: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), - - searchBaseRefDetails: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), - - onChanged: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('repos:changed', listener) - return () => ipcRenderer.removeListener('repos:changed', listener) - } - } satisfies PreloadApi['repos'], - - projects: { - list: () => ipcRenderer.invoke('projects:list'), - update: (args) => ipcRenderer.invoke('projects:update', args), - listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), - createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), - setupExistingFolder: (args) => - ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), - updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), - deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) - } satisfies PreloadApi['projects'], - - projectGroups: { - list: () => ipcRenderer.invoke('projectGroups:list'), - create: (args) => ipcRenderer.invoke('projectGroups:create', args), - update: (args) => ipcRenderer.invoke('projectGroups:update', args), - delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), - moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), - scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), - cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), - onNestedScanProgress: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { scanId: string; scan: NestedRepoScanResult } - ) => callback(data) - ipcRenderer.on('projectGroups:scanNestedProgress', listener) - return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) - }, - importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) - } satisfies PreloadApi['projectGroups'], - - folderWorkspaces: { - list: () => ipcRenderer.invoke('folderWorkspaces:list'), - getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), - create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), - update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), - delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) - } satisfies PreloadApi['folderWorkspaces'], - - sparsePresets: { - list: (args) => ipcRenderer.invoke('sparsePresets:list', args), - - save: (args) => ipcRenderer.invoke('sparsePresets:save', args), - - remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), - - onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => - callback(data) - ipcRenderer.on('sparsePresets:changed', listener) - return () => ipcRenderer.removeListener('sparsePresets:changed', listener) - } - } satisfies PreloadApi['sparsePresets'], - - worktrees: { - list: (args) => ipcRenderer.invoke('worktrees:list', args), - listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), - - listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), - - listKnownForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), - - forgetRemovedForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), - - cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), - - listAll: () => ipcRenderer.invoke('worktrees:listAll'), - - create: (args) => ipcRenderer.invoke('worktrees:create', args), - - adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), - - onCreateProgress: ( - callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { creationId?: string; phase: 'fetching' | 'creating' } - ) => callback(data) - ipcRenderer.on('createWorktree:progress', listener) - return () => ipcRenderer.removeListener('createWorktree:progress', listener) - }, - - prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), - - resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), - - resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), - - remove: (args) => ipcRenderer.invoke('worktrees:remove', args), - - forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), - - forceDeletePreservedBranch: (args) => - ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), - - updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), - - listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - - listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), - - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), - - persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), - - getBranchRenameFailureOutput: (args) => - ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), - - onChanged: ( - callback: (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } - ) => callback(data) - ipcRenderer.on('worktrees:changed', listener) - return () => ipcRenderer.removeListener('worktrees:changed', listener) - }, - - onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => - callback(data) - ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) - return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) - }, - - onHeadIdentitiesChanged: ( - callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; identities: WorktreeHeadIdentity[] } - ) => callback(data) - ipcRenderer.on('worktrees:headIdentitiesChanged', listener) - return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener) - }, - - onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => - callback(data) - ipcRenderer.on('worktree:baseStatus', listener) - return () => ipcRenderer.removeListener('worktree:baseStatus', listener) - }, - - onRemoteBranchConflict: ( - callback: (data: WorktreeRemoteBranchConflictEvent) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: WorktreeRemoteBranchConflictEvent - ) => callback(data) - ipcRenderer.on('worktree:remoteBranchConflict', listener) - return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) - } - } satisfies PreloadApi['worktrees'], - - workspaceCleanup: { - scan: (args, onProgress) => { - if (!onProgress) { - return ipcRenderer.invoke('workspaceCleanup:scan', args) - } - const scanId = args?.scanId ?? crypto.randomUUID() - const listener = ( - _event: Electron.IpcRendererEvent, - progress: WorkspaceCleanupScanProgress - ): void => { - if (progress.scanId === scanId) { - onProgress(progress) - } - } - ipcRenderer.on('workspaceCleanup:scanProgress', listener) - return ipcRenderer - .invoke('workspaceCleanup:scan', { ...args, scanId }) - .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) - }, - cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), - getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), - dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), - clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), - hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), - beginRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), - recordRemovalSnapshotPrune: (args) => - ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), - finishRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) - } satisfies PreloadApi['workspaceCleanup'], - - workspaceSpace: { - analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), - getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), - cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), - onProgress: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - progress: WorkspaceSpaceScanProgress - ): void => callback(progress) - ipcRenderer.on('workspaceSpace:progress', listener) - return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) - } - } satisfies PreloadApi['workspaceSpace'], - - workspacePorts: { - scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), - kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), - onAdvertisedUrlChanged: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: WorkspacePortAdvertisedUrlChangedEvent - ): void => callback(event) - ipcRenderer.on('workspacePorts:advertised-url-changed', listener) - return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) - } - } satisfies PreloadApi['workspacePorts'], - - pty: { - spawn: (opts: { - cols: number - rows: number - cwd?: string - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { foreground?: string; background?: string } - // Why: marks the PTY hidden before its first byte so the delivery gate + model responder own spawn-time queries (terminal-query-authority.md §races). - initiallyHidden?: boolean - // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. - tabId?: string - leafId?: string - // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). - telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }): Promise<{ - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - }> => ipcRenderer.invoke('pty:spawn', opts), - - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) - }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), - onWriteUnavailable: ( - callback: (payload: { - id: string - /** Set only when a durable agent-session lease refused the write; absent otherwise. */ - agentSessionRefusal?: AgentSessionPtyWriteRefusal - }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } - ): void => callback(payload) - ipcRenderer.on('pty:writeUnavailable', handler) - return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) - }, - - resize: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:resize', { id, cols, rows }) - }, - claimViewport: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:claimViewport', { id, cols, rows }) - }, - - /** Why: measurement-only sibling of resize — keeps the runtime's restore-target baseline fresh while a mobile-fit override blocks pty:resize. Never resizes the PTY. See docs/mobile-fit-hold.md. */ - reportGeometry: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) - }, - - signal: (id: string, signal: string): void => { - ipcRenderer.send('pty:signal', { id, signal }) - }, - - /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host keeps its own screen state and would repaint the next prompt at the stale cursor row. */ - clearBuffer: (id: string): void => { - ipcRenderer.send('pty:clearBuffer', { id }) - }, - - ackColdRestore: (id: string): void => { - ipcRenderer.send('pty:ackColdRestore', { id }) - }, - /** charCount is the legacy per-chunk delta; processedChars is the cumulative per-pty total (self-heals under lost ACKs). */ - ackData: (id: string, charCount: number, processedChars?: number): void => { - ipcRenderer.send('pty:ackData', { - id, - charCount, - ...(typeof processedChars === 'number' ? { processedChars } : {}) - }) - }, - /** Main requests the renderer's cumulative processed totals when delivery looks stuck on lost ACKs. */ - onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => - callback(payload) - ipcRenderer.on('pty:requestDeliveryResync', listener) - return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) - }, - respondDeliveryResync: (payload: { - requestId: number - processedCharsByPty: Record - }): void => { - ipcRenderer.send('pty:deliveryResyncResponse', payload) - }, - /** Renderer-initiated delivery health/heal lane — rides invoke because the field wedge (v1.4.121-rc.0) kills main→renderer push while invoke stays alive. */ - reportRendererDeliveryState: ( - report: PtyRendererDeliveryStateReport - ): Promise => - ipcRenderer.invoke('pty:reportRendererDeliveryState', report), - /** Live pty:data listener count — the watchdog's "listener detached" vs "channel dead" discriminator. */ - getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), - rendererDispatcherReady: (): void => { - ipcRenderer.send('pty:rendererDispatcherReady') - }, - setActiveRendererPty: (id: string, active: boolean): void => { - ipcRenderer.send('pty:setActiveRendererPty', { id, active }) - }, - setRendererPtyVisible: (id: string, visible: boolean): void => { - ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) - }, - /** Hidden-delivery gate: hidden=true lets main DROP renderer byte delivery after model ingestion; reveal restores from the model snapshot. Fire-and-forget. */ - setHiddenRendererPty: (id: string, hidden: boolean): void => { - ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) - }, - /** Delivery-interest signal: a renderer party needing raw bytes suppresses the hidden-delivery gate for that PTY while registered. */ - setPtyDeliveryInterest: (id: string, interested: boolean): void => { - ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) - }, - /** Push composed terminal appearance so main's model responder can answer OSC 4/10/11/12 and DSR ?996n for hidden-gated PTYs with renderer-true values. */ - publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { - ipcRenderer.send('pty:terminalViewAttributes', attributes) - }, - - kill: (id: string, opts?: { keepHistory?: boolean }): Promise => - ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), - - listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), - getAuthoritativeBufferSnapshotCapabilities: ( - ids: string[] - ): Promise<{ id: string; authoritative: boolean | null }[]> => - ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), - hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), - - getMainBufferSnapshot: ( - id: string, - opts?: { scrollbackRows?: number } - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - seq?: number - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - terminalOwner?: 'shell' - } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), - - getRendererDeliveryDebugSnapshot: (): Promise<{ - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - diagnostics: PtyMainDeliveryDiagnostics - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number - }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), - - resetRendererDeliveryDebug: (): Promise => - ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), - - /** True if the PTY's shell has child processes (a running command); false at an idle prompt. */ - hasChildProcesses: (id: string): Promise => - ipcRenderer.invoke('pty:hasChildProcesses', { id }), - - /** Return the PTY foreground process basename when available (e.g. "codex"). */ - getForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:getForegroundProcess', { id }), - inspectProcess: ( - id: string - ): Promise<{ - foregroundProcess: string | null - hasChildProcesses: boolean - unavailable?: true - }> => ipcRenderer.invoke('pty:inspectProcess', { id }), - confirmForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), - - /** Resolve a PTY's live cwd via `/proc` (Linux) or `lsof` (macOS); `''` when unknown or unresolvable. */ - getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), - - /** The PTY's last APPLIED size (real winsize), or null if unknown — lets the renderer detect drift after a dropped resize and re-assert. */ - getSize: (id: string): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('pty:getSize', { id }), - - onData: ( - callback: (data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - } - ) => callback(data) - ipcRenderer.on('pty:data', listener) - return () => ipcRenderer.removeListener('pty:data', listener) - }, - - onReplay: (callback: (data: { id: string; data: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => - callback(data) - ipcRenderer.on('pty:replay', listener) - return () => ipcRenderer.removeListener('pty:replay', listener) - }, - - /** Out-of-band signal that main dropped renderer-bound bytes (hidden-gate / pending cap); pane restores from the model snapshot. - * NOT on pty:data — an in-band marker is ambiguous with chunks fully stripped by OSC-9999 cleaning. */ - onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => - callback(event) - ipcRenderer.on('pty:modelRestoreNeeded', listener) - return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) - }, - - /** Batched side-effect facts (title/bell/agent transitions) for local-main PTYs. - * Per-PTY in-order; deliberately NOT synced with pty:data (terminal-side-effect-authority.md). */ - onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => - callback(batch) - ipcRenderer.on('pty:sideEffect', listener) - return () => ipcRenderer.removeListener('pty:sideEffect', listener) - }, - - /** Title-only replay snapshot on (re)attach — attention facts (bells/completions) never replay. */ - getSideEffectSnapshot: (id: string): Promise => - ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), - - onExit: ( - callback: (data: { - id: string - code: number - preserveRendererBinding?: boolean - /** Which lifetime of `id` died; absent when the execution host predates the field. */ - incarnationId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - id: string - code: number - preserveRendererBinding?: boolean - incarnationId?: string - } - ) => callback(data) - ipcRenderer.on('pty:exit', listener) - return () => ipcRenderer.removeListener('pty:exit', listener) - }, - - onSpawned: (callback: (data: { id: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) - ipcRenderer.on('pty:spawned', listener) - return () => ipcRenderer.removeListener('pty:spawned', listener) - }, - - onSerializeBufferRequest: ( - callback: (data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - } - ) => callback(data) - ipcRenderer.on('pty:serializeBuffer:request', listener) - return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) - }, - - onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) - ipcRenderer.on('pty:clearBuffer:request', listener) - return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) - }, - - sendSerializedBuffer: ( - requestId: string, - snapshot: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - ): void => { - ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) - }, - - // Claim serializer ownership before spawn; echo the generation token on settle/clear to prevent pane-key reuse races. - declarePendingPaneSerializer: (paneKey: string): Promise => - ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), - - settlePaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), - - clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), - - reportRendererSerializerReady: (ptyId: string): Promise => - ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), - - management: { - listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), - killAll: () => ipcRenderer.invoke('pty:management:killAll'), - killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart'), - macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') - } - }, - - feedback: { - submit: (args: { - feedback: string - submitAnonymously?: boolean - githubLogin: string | null - githubEmail: string | null - images?: { contentType: string; data: Uint8Array }[] - }): Promise< - { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } - > => ipcRenderer.invoke('feedback:submit', args) - }, - - crashReports: { - getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), - getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), - dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), - recordRendererError: ( - args: ReactErrorBoundaryReportArgs - ): Promise => - ipcRenderer.invoke('crashReports:recordRendererError', args), - recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => - ipcRenderer.send('crashReports:recordBreadcrumb', args), - submit: (args: CrashReportSubmitArgs): Promise => - ipcRenderer.invoke('crashReports:submit', args), - copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), - readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), - readProcessMemory: (): Promise => readRendererProcessMemory() - }, - - export: { - htmlToPdf: (args: { - html: string - title: string - }): Promise< - { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } - > => ipcRenderer.invoke('export:html-to-pdf', args) - }, - - gh: { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), - - repoSlug: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoSlug', args), - - repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoUpstream', args), - - prForBranch: (args: { - repoPath: string - repoId?: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - - refreshPRNow: (args: { - candidate: GitHubPRRefreshCandidate - reason?: GitHubPRRefreshReason - }): Promise => ipcRenderer.invoke('gh:refreshPRNow', args), - - enqueuePRRefresh: (args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), - - reportVisiblePRRefreshCandidates: (args: { - candidates: GitHubPRRefreshCandidate[] - generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), - - onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => - callback(event) - ipcRenderer.on('gh:prRefreshEvent', listener) - return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) - }, - - issue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), - - workItem: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), - - workItemByOwnerRepo: (args: { - repoPath: string - repoId?: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), - - workItemDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), - - notifyWorkItemMutated: (args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), - - prFileContents: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: string - headSha: string - baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), - - listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => - ipcRenderer.invoke('gh:listIssues', args), - - createIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - title: string - body: string - labels?: string[] - assignees?: string[] - }): Promise => ipcRenderer.invoke('gh:createIssue', args), - - countWorkItems: (args: { - repoPath: string - repoId?: string - query?: string - }): Promise => ipcRenderer.invoke('gh:countWorkItems', args), - - listWorkItems: (args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - }): Promise>> => - ipcRenderer.invoke('gh:listWorkItems', args), - - prChecks: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), - - prCheckDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), - - rerunPRChecks: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:rerunPRChecks', args), - - prComments: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), - - setPRCommentReaction: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), - - resolveReviewThread: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), - - setPRFileViewed: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), - - updatePRTitle: (args: { - repoPath: string - repoId?: string - prNumber: number - title: string - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), - - mergePR: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:mergePR', args), - - setPRAutoMerge: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:setPRAutoMerge', args), - - updatePRState: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - updates: { state: 'open' | 'closed' } - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updatePRState', args), - - markPRReadyForReview: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:markPRReadyForReview', args), - - requestPRReviewers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:requestPRReviewers', args), - - removePRReviewers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:removePRReviewers', args), - - updateIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - updates: unknown - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updateIssue', args), - - addIssueComment: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), - - addPRReviewCommentReply: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), - - addPRReviewComment: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), - - listLabels: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listLabels', args), - - listAssignableUsers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), - - // Why: renderer owns the work-item cache; main fires this for non-origin mutations only (origin callers updated optimistically). See src/main/ipc/github.ts. - onWorkItemMutated: ( - callback: (payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number } - ): void => callback(payload) - ipcRenderer.on('gh:workItemMutated', listener) - return () => ipcRenderer.removeListener('gh:workItemMutated', listener) - }, - - checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (source: AppStarSource): Promise => - ipcRenderer.invoke('gh:starOrca', source), - - // Why: rate_limit is exempt from rate-limit accounting; `force` still busts the 30s in-process cache after an expensive op. - rateLimit: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('gh:rateLimit', args), - - diagnoseAuth: (args?: { host?: string }): Promise => - ipcRenderer.invoke('gh:diagnoseAuth', args), - - // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── - listAccessibleProjects: ( - args?: ListAccessibleProjectsArgs - ): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects', args), - resolveProjectRef: (args: ResolveProjectRefArgs): Promise => - ipcRenderer.invoke('gh:resolveProjectRef', args), - listProjectViews: (args: ListProjectViewsArgs): Promise => - ipcRenderer.invoke('gh:listProjectViews', args), - getProjectViewTable: (args: GetProjectViewTableArgs): Promise => - ipcRenderer.invoke('gh:getProjectViewTable', args), - projectWorkItemDetailsBySlug: ( - args: ProjectWorkItemDetailsBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), - updateProjectItemField: ( - args: UpdateProjectItemFieldArgs - ): Promise => - ipcRenderer.invoke('gh:updateProjectItemField', args), - clearProjectItemField: ( - args: ClearProjectItemFieldArgs - ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), - updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => - ipcRenderer.invoke('gh:updateIssueBySlug', args), - updatePullRequestBySlug: ( - args: UpdatePullRequestBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updatePullRequestBySlug', args), - addIssueCommentBySlug: ( - args: AddIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:addIssueCommentBySlug', args), - updateIssueCommentBySlug: ( - args: UpdateIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), - deleteIssueCommentBySlug: ( - args: DeleteIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), - listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => - ipcRenderer.invoke('gh:listLabelsBySlug', args), - listAssignableUsersBySlug: ( - args: ListAssignableUsersBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), - listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => - ipcRenderer.invoke('gh:listIssueTypesBySlug', args), - updateIssueTypeBySlug: ( - args: UpdateIssueTypeBySlugArgs - ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) - }, - - hostedReview: { - forBranch: (args: HostedReviewForBranchArgs): Promise => - ipcRenderer.invoke('hostedReview:forBranch', args), - getCreationEligibility: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) - }, - - // Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file. - gl: glApi, - - bitbucket: { - connect: (args: { - authMode: 'token' | 'basic' - accessToken?: string | null - email?: string | null - apiToken?: string | null - baseUrl?: string | null - }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => - ipcRenderer.invoke('bitbucket:connect', args), - - disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), - - status: (): Promise => ipcRenderer.invoke('bitbucket:status') - }, - - linear: { - connect: (args: { - apiKey: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), - - disconnect: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:disconnect', args), - - selectWorkspace: (args: { workspaceId: string }): Promise => - ipcRenderer.invoke('linear:selectWorkspace', args), - - status: (): Promise => ipcRenderer.invoke('linear:status'), - - testConnection: (args?: { - workspaceId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:testConnection', args), - - searchIssues: (args: { - query: string - limit?: number - workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), - - listIssues: (args?: { - filter?: 'assigned' | 'created' | 'all' | 'completed' - limit?: number - workspaceId?: string - attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), - - createIssue: (args: { - teamId: string - title: string - description?: string - workspaceId?: string - parentIssueId?: string - projectId?: string | null - stateId?: string - priority?: number - assigneeId?: string | null - labelIds?: string[] - }): Promise< - | { ok: true; id: string; identifier: string; title: string; url: string } - | { ok: false; error: string } - > => ipcRenderer.invoke('linear:createIssue', args), - - getIssue: (args: { id: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:getIssue', args), - - updateIssue: (args: { - id: string - updates: unknown - workspaceId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:updateIssue', args), - - addIssueComment: (args: { - issueId: string - body: string - workspaceId?: string - }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:addIssueComment', args), - - issueComments: (args: { issueId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:issueComments', args), - - listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), - - listProjects: (args?: { - query?: string - limit?: number - workspaceId?: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), - - createProject: (args: { - name: string - description?: string - content?: string - teamIds: string[] - workspaceId?: string - leadId?: string | null - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string - }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:createProject', args), - - getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => - ipcRenderer.invoke('linear:getProject', args), - - listProjectIssues: (args: { - projectId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), - - listCustomViews: (args: { - model: string - limit?: number - workspaceId?: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), - - getCustomView: (args: { - viewId: string - model: string - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), - - listCustomViewIssues: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), - - listCustomViewProjects: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), - - teamStates: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamStates', args), - - teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamLabels', args), - - teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamMembers', args) - }, - - jira: { - connect: (args: { - siteUrl: string - email: string - apiToken: string - authType?: 'cloud' | 'server' - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), - - disconnect: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:disconnect', args), - - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), - - status: (): Promise => ipcRenderer.invoke('jira:status'), - - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), - - testConnection: (args?: { - siteId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), - - searchIssues: (args: { - jql: string - limit?: number - siteId?: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), - cancelSearchIssues: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelSearchIssues', args), - - listIssues: (args?: { - filter?: 'assigned' | 'reported' | 'all' | 'done' - limit?: number - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), - - getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), - - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), - cancelIssueSummary: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelIssueSummary', args), - - createIssue: (args: { - siteId?: string - projectId: string - issueTypeId: string - title: string - description?: string - customFields?: Record - }): Promise< - { ok: true; id: string; key: string; url: string } | { ok: false; error: string } - > => ipcRenderer.invoke('jira:createIssue', args), - - updateIssue: (args: { - key: string - updates: unknown - siteId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:updateIssue', args), - - addIssueComment: (args: { - key: string - body: string - siteId?: string - }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:addIssueComment', args), - - issueComments: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:issueComments', args), - - listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), - - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listIssueTypes', args), - - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), - - listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), - - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), - searchUsers: (args?: { query?: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:searchUsers', args), - - listTransitions: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listTransitions', args), - getProjectStatusOrder: (args: { - projectKey: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) - }, - - starNag: { - onShow: ( - callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' } - ): void => callback(payload) - ipcRenderer.on('star-nag:show', listener) - return () => ipcRenderer.removeListener('star-nag:show', listener) - }, - onHide: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('star-nag:hide', listener) - return () => ipcRenderer.removeListener('star-nag:hide', listener) - }, - dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), - later: (): Promise => ipcRenderer.invoke('star-nag:later'), - complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), - disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), - openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), - starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), - forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), - agentValueMoment: (): Promise< - { status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' } - > => ipcRenderer.invoke('star-nag:agentValueMoment'), - showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), - onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') - }, - - // Why: main validates telemetry; renderer call sites use typed wrappers. - telemetryTrack: (name: string, props: Record): Promise => - ipcRenderer.invoke('telemetry:track', name, props), - telemetrySetOptIn: (optedIn: boolean): Promise => - ipcRenderer.invoke('telemetry:setOptIn', optedIn), - telemetryAcknowledgeBanner: (): Promise => - ipcRenderer.invoke('telemetry:acknowledgeBanner'), - telemetryGetConsentState: (): Promise => - ipcRenderer.invoke('telemetry:getConsentState'), - - // Why: bridges are deliberately loose — main type-narrows this untrusted renderer input (see telemetry-error-tracking.md). - diagnostics: { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), - collectBundle: (lookbackMinutes?: number): Promise => - ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), - openBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), - discardBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), - uploadBundle: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), - deleteBundle: (ticketId: string): Promise => - ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) - }, - - settings: { - get: (): Promise => ipcRenderer.invoke('settings:get'), - - // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. - getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), - - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), - - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }): Promise => - ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), - - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => - ipcRenderer.invoke('settings:update-pr-bot-author-override', args), - - listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), - - previewGhosttyImport: (): Promise => - ipcRenderer.invoke('settings:previewGhosttyImport'), - - previewWarpThemeImport: (source: WarpThemeImportSource): Promise => - ipcRenderer.invoke('settings:previewWarpThemeImport', source), - - onChanged: (callback: (updates: Record) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - updates: Record - ): void => callback(updates) - ipcRenderer.on('settings:changed', listener) - return () => ipcRenderer.removeListener('settings:changed', listener) - } - }, - - agentAwake: { - getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), - onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => - callback(status) - ipcRenderer.on('agentAwake:changed', listener) - return () => ipcRenderer.removeListener('agentAwake:changed', listener) - } - } satisfies PreloadApi['agentAwake'], - - localhostWorktreeLabels: { - register: (args: LocalhostWorktreeLabelRoute): Promise => - ipcRenderer.invoke('localhostWorktreeLabels:register', args) - } satisfies PreloadApi['localhostWorktreeLabels'], - - keybindings: { - get: (): Promise => ipcRenderer.invoke('keybindings:get'), - ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), - setAction: (args: { - actionId: KeybindingActionId - bindings: string[] | null - }): Promise => ipcRenderer.invoke('keybindings:setAction', args), - reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), - openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), - revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), - onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - snapshot: KeybindingFileSnapshot - ): void => callback(snapshot) - ipcRenderer.on('keybindings:changed', listener) - return () => ipcRenderer.removeListener('keybindings:changed', listener) - } - }, - - codexAccounts: { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { - accountId: string - activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), - listStalePanes: (args: { - ptyIds: string[] - }): Promise< - { - ptyId: string - launchAccountId: string | null - activeAccountId: string | null - reason?: 'account-change' | 'home-route-change' - }[] - > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), - listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => - ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), - forgetStalePanes: (args: { ptyIds: string[] }): Promise => - ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) - }, - - claudeAccounts: { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:add', args), - cancelPendingLogin: (): Promise => - ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), - reauthenticate: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) - }, - - cli: { - getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), - install: (): Promise => ipcRenderer.invoke('cli:install'), - remove: (): Promise => ipcRenderer.invoke('cli:remove'), - getWslInstallStatus: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:getWslInstallStatus', args), - installWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:installWsl', args), - removeWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:removeWsl', args) - }, - - codexConfigSync: { - status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') - }, - agentTrust: { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' - workspacePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) - }, - - preflight: { - check: (args?: { - force?: boolean - }): Promise<{ - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } - glab?: { installed: boolean; authenticated: boolean } - bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } - azureDevOps?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - gitea?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - linear: { connected: boolean } - }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), - detectRemoteAgents: (args: { connectionId: string }): Promise => - ipcRenderer.invoke('preflight:detectRemoteAgents', args), - detectRemoteWindowsTerminalCapabilities: (args: { - connectionId: string - }): Promise<{ - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) - }, - - notifications: { - dispatch: (args: Record): Promise => - ipcRenderer.invoke('notifications:dispatch', args), - dismiss: (ids: string[]): Promise => - ipcRenderer.invoke('notifications:dismiss', ids), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), - getPermissionStatus: (): Promise => - ipcRenderer.invoke('notifications:getPermissionStatus'), - probeDelivery: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('notifications:probeDelivery', args), - playSound: async (options?: { - force?: boolean - volume?: number - }): Promise => { - try { - // Why: drop replays while still ringing; the test button passes force to always confirm. - if (!options?.force && isNotificationSoundPlaying) { - return { played: false, reason: 'deduped' } - } - - const resolved = (await ipcRenderer.invoke( - 'notifications:resolveSoundPath' - )) as NotificationSoundPathResult - if (!resolved.ok) { - if (cachedNotificationSound) { - disposeCachedNotificationSound() - } - return { played: false, reason: resolved.reason } - } - - let entry = cachedNotificationSound - if (!entry || entry.path !== resolved.path) { - const sound = (await ipcRenderer.invoke( - 'notifications:loadSound' - )) as NotificationSoundDataResult - if (!sound.ok) { - disposeCachedNotificationSound() - return { played: false, reason: sound.reason } - } - const arrayBuffer = new ArrayBuffer(sound.data.byteLength) - new Uint8Array(arrayBuffer).set(sound.data) - const blob = new Blob([arrayBuffer], { type: sound.mimeType }) - disposeCachedNotificationSound() - const blobUrl = URL.createObjectURL(blob) - entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } - cachedNotificationSound = entry - } - - const audio = entry.audio - // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). - audio.currentTime = 0 - if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { - audio.volume = Math.min(1, Math.max(0, options.volume / 100)) - } - isNotificationSoundPlaying = true - cleanupNotificationSoundPlayback?.() - const release = (): void => { - cleanup() - if (cleanupNotificationSoundPlayback === cleanup) { - cleanupNotificationSoundPlayback = null - } - isNotificationSoundPlaying = false - } - const cleanup = (): void => { - audio.removeEventListener('ended', release) - audio.removeEventListener('error', release) - } - cleanupNotificationSoundPlayback = cleanup - audio.addEventListener('ended', release) - audio.addEventListener('error', release) - try { - await audio.play() - } catch { - release() - return { played: false, reason: 'playback-failed' } - } - return { played: true } - } catch { - clearNotificationSoundPlaybackState() - return { played: false, reason: 'playback-failed' } - } - } - }, - - onboarding: { - get: (): Promise => ipcRenderer.invoke('onboarding:get'), - update: ( - updates: Partial> & { - checklist?: Partial - } - ): Promise => ipcRenderer.invoke('onboarding:update', updates) - }, - - dashboard: { - // Open the pop-out dashboard window, or focus it if already open. - openPopout: (view?: 'board' | 'map'): Promise => - ipcRenderer.invoke('dashboardPopout:open', view), - - // ── Producer side (main window) ────────────────────────────────────── - publishSnapshot: (snapshot: DashboardSnapshot): Promise => - ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), - getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), - onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) - ipcRenderer.on('dashboard:popoutOpenChanged', listener) - return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) - }, - onSnapshotRequested: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('dashboard:snapshotRequested', listener) - return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) - }, - onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => - callback(args) - ipcRenderer.on('ui:revealDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) - }, - onAckAgent: (callback: (paneKey: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => - callback(paneKey) - ipcRenderer.on('ui:ackDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) - }, - onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => - callback(args) - ipcRenderer.on('ui:spawnDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) - }, - onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - args: DashboardSleepWorkspaceArgs - ): void => callback(args) - ipcRenderer.on('ui:sleepDashboardWorkspace', listener) - return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) - }, - - // ── Consumer side (pop-out window) ─────────────────────────────────── - requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), - onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => - callback(snapshot) - ipcRenderer.on('dashboard:snapshot', listener) - return () => ipcRenderer.removeListener('dashboard:snapshot', listener) - }, - onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => - callback(view) - ipcRenderer.on('dashboard:viewRequested', listener) - return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) - }, - revealAgent: (args: DashboardRevealAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:revealAgent', args), - ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), - spawnAgent: (args: DashboardSpawnAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:spawnAgent', args), - sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => - ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) - }, - - terminalPreview: { - connect: ( - ptyId: string, - opts?: { scrollbackRows?: number } - ): Promise => - ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), - input: (ptyId: string, data: string): Promise => - ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), - fit: ( - ptyId: string, - cols: number, - rows: number - ): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), - ack: (ptyId: string, bytes: number): Promise => - ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), - unsubscribe: (ptyId: string): Promise => - ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), - onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: TerminalPreviewDataPayload - ): void => callback(payload) - ipcRenderer.on('terminalPreview:data', listener) - return () => ipcRenderer.removeListener('terminalPreview:data', listener) - } - }, - - macosTccPrompts: { - onThreshold: (callback: (payload: unknown) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => - callback(payload) - ipcRenderer.on('macosTccPrompts:threshold', listener) - return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) - }, - consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => - ipcRenderer.invoke('macosTccPrompts:consumePending'), - acknowledgePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), - releasePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), - dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') - }, - - developerPermissions: { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), - request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), - openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args), - testLocalNetworkConnection: (args: { host: string; port: number }): Promise => - ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) - }, - - computerUsePermissions: { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), - openSetup: (args?: { id?: string }): Promise => - ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') - }, - - shell: { - openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), - - openInFileManager: (path: string): Promise => - ipcRenderer.invoke('shell:openInFileManager', path), - - openInExternalEditor: ( - request: ShellOpenExternalEditorRequest - ): Promise => - ipcRenderer.invoke('shell:openInExternalEditor', request), - - openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), - - openFilePath: (path: string): Promise => - ipcRenderer.invoke('shell:openFilePath', path), - - openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), - - pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), - - pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), - - pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), - - pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => - ipcRenderer.invoke('shell:pickRepoIconImage'), - - pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), - - pickDirectory: (args: { defaultPath?: string }): Promise => - ipcRenderer.invoke('shell:pickDirectory', args), - - copyFile: (args: { srcPath: string; destPath: string }): Promise => - ipcRenderer.invoke('shell:copyFile', args) - }, - - skills: { - discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target), - freshnessInventory: (): Promise => - ipcRenderer.invoke('skills:freshnessInventory'), - startUpdateRun: (names: string[]): Promise => - ipcRenderer.invoke('skills:startUpdateRun', names), - cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), - acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), - getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), - prepareShare: (input: { - skillIds: string[] - bundleName: string - target?: SkillDiscoveryTarget - packageId?: string - }): Promise => ipcRenderer.invoke('skills:prepareShare', input), - publishShare: (input: SkillSharePublishInput): Promise => - ipcRenderer.invoke('skills:publishShare', input), - cancelShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:cancelShare', preparationId), - releaseShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:releaseShare', preparationId), - resolveShare: (shareId: string): Promise => - ipcRenderer.invoke('skills:resolveShare', shareId), - installShare: (input: SkillShareInstallInput): Promise => - ipcRenderer.invoke('skills:installShare', input), - installBundleShare: ( - input: SkillBundleShareInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundleShare', input), - installBundlePackageVersion: ( - input: SkillBundlePackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundlePackageVersion', input), - installPackageVersion: ( - input: SkillPackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installPackageVersion', input), - cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => - ipcRenderer.invoke('skills:cancelInstall', input), - previewInstall: (input: SkillInstallPreviewInput): Promise => - ipcRenderer.invoke('skills:previewInstall', input), - previewBundleInstall: ( - input: SkillBundleInstallPreviewInput - ): Promise => - ipcRenderer.invoke('skills:previewBundleInstall', input), - removeInstall: (input: SkillRemoveInput): Promise => - ipcRenderer.invoke('skills:removeInstall', input), - // Desktop always registers the delete IPC handlers in its own main process. - deleteSupported: (): Promise => Promise.resolve(true), - previewDelete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:previewDelete', request), - delete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:delete', request), - listManagedInstalls: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listManagedInstalls', environmentId), - getPackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:getPackage', packageId), - listOwnedShares: (): Promise> => - ipcRenderer.invoke('skills:listOwnedShares'), - revokeShare: (shareId: string): Promise> => - ipcRenderer.invoke('skills:revokeShare', shareId), - deletePackageVersion: (input: { - packageId: string - versionId: string - }): Promise> => - ipcRenderer.invoke('skills:deletePackageVersion', input), - deletePackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:deletePackage', packageId), - listWslDistros: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listWslDistros', environmentId), - onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => - callback(progress) - ipcRenderer.on('skills:installProgress', listener) - return () => ipcRenderer.removeListener('skills:installProgress', listener) - }, - onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void => - callback(progress) - ipcRenderer.on('skills:shareProgress', listener) - return () => ipcRenderer.removeListener('skills:shareProgress', listener) - }, - onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => - callback(run) - ipcRenderer.on('skills:updateRun', listener) - return () => ipcRenderer.removeListener('skills:updateRun', listener) - } - }, - - pet: { - import: (): Promise => ipcRenderer.invoke('pet:import'), - importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), - read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:read', id, fileName, kind), - delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:delete', id, fileName, kind) - }, - - browser: { - onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, - readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), - registerGuest: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), - - isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => - ipcRenderer.invoke('browser:isGuestRegistered', args), - - repairGuestRegistration: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), - - unregisterGuest: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:unregisterGuest', args), - - onWebAuthnAccountRequest: ( - callback: (request: BrowserWebAuthnAccountRequest) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: BrowserWebAuthnAccountRequest - ): void => callback(request) - ipcRenderer.on('browser:webauthn-account-requested', listener) - return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) - }, - - onWebAuthnAccountRequestClosed: ( - callback: (event: { requestId: string }) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => - callback(data) - ipcRenderer.on('browser:webauthn-account-request-closed', listener) - return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) - }, - - respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => - ipcRenderer.invoke('browser:respondWebAuthnAccount', response), - - openDevTools: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:openDevTools', args), - - setViewportOverride: (args: { - browserPageId: string - override: BrowserViewportOverride | null - }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), - - reportViewportScrollState: (args: { - browserPageId: string - state: { - scrollLeft: number - scrollTop: number - maxScrollLeft: number - maxScrollTop: number - } - }): void => ipcRenderer.send('browser:reportViewportScrollState', args), - - setAnnotationViewportBridge: (args): Promise => - ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), - - publishClientPageMetadata: (args) => - ipcRenderer.invoke('browser:publishClientPageMetadata', args), - - onGuestLoadFailed: ( - callback: (args: { - browserPageId: string - loadError: { code: number; description: string; validatedUrl: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - loadError: { code: number; description: string; validatedUrl: string } - } - ) => callback(data) - ipcRenderer.on('browser:guest-load-failed', listener) - return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) - }, - - onCertificateFailureChanged: (callback): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: Parameters[0] - ): void => callback(data) - ipcRenderer.on('browser:certificate-failure-changed', listener) - return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) - }, - - proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), - - onPermissionDenied: ( - callback: (event: { browserPageId: string; permission: string; origin: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; permission: string; origin: string } - ) => callback(data) - ipcRenderer.on('browser:permission-denied', listener) - return () => ipcRenderer.removeListener('browser:permission-denied', listener) - }, - - onPopup: ( - callback: (event: { - browserPageId: string - origin: string - action: 'opened-in-orca' | 'opened-external' | 'blocked' - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - origin: string - action: 'opened-in-orca' | 'opened-external' | 'blocked' - } - ) => callback(data) - ipcRenderer.on('browser:popup', listener) - return () => ipcRenderer.removeListener('browser:popup', listener) - }, - - onDownloadRequested: ( - callback: (event: { - browserPageId: string - downloadId: string - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - savePath: string - status: 'downloading' - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - downloadId: string - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - savePath: string - status: 'downloading' - } - ) => callback(data) - ipcRenderer.on('browser:download-requested', listener) - return () => ipcRenderer.removeListener('browser:download-requested', listener) - }, - - onDownloadProgress: ( - callback: (event: { - browserPageId?: string - downloadId: string - receivedBytes: number - totalBytes: number | null - state: 'progressing' | 'interrupted' | null - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId?: string - downloadId: string - receivedBytes: number - totalBytes: number | null - state: 'progressing' | 'interrupted' | null - } - ) => callback(data) - ipcRenderer.on('browser:download-progress', listener) - return () => ipcRenderer.removeListener('browser:download-progress', listener) - }, - - onDownloadFinished: ( - callback: (event: { - browserPageId?: string - downloadId: string - status: 'completed' | 'canceled' | 'failed' - savePath: string | null - remoteDestination?: { workspaceRelativePath: string; hostLabel: string } - error: string | null - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId?: string - downloadId: string - status: 'completed' | 'canceled' | 'failed' - savePath: string | null - remoteDestination?: { workspaceRelativePath: string; hostLabel: string } - error: string | null - } - ) => callback(data) - ipcRenderer.on('browser:download-finished', listener) - return () => ipcRenderer.removeListener('browser:download-finished', listener) - }, - - onContextMenuRequested: ( - callback: (event: { - browserPageId: string - x: number - y: number - screenX: number - screenY: number - pageUrl: string - linkUrl: string | null - selectionText: string - canGoBack: boolean - canGoForward: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - x: number - y: number - screenX: number - screenY: number - pageUrl: string - linkUrl: string | null - selectionText: string - canGoBack: boolean - canGoForward: boolean - } - ) => callback(data) - ipcRenderer.on('browser:context-menu-requested', listener) - return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) - }, - - onContextMenuDismissed: ( - callback: (event: { browserPageId: string }) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => - callback(data) - ipcRenderer.on('browser:context-menu-dismissed', listener) - return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) - }, - - onNavigationUpdate: ( - callback: (event: { browserPageId: string; url: string; title: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string; title: string } - ) => callback(data) - ipcRenderer.on('browser:navigation-update', listener) - return () => ipcRenderer.removeListener('browser:navigation-update', listener) - }, - - onActivateView: ( - callback: (data: { worktreeId?: string; browserPageId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId?: string; browserPageId?: string } - ) => callback(data) - ipcRenderer.on('browser:activateView', listener) - return () => ipcRenderer.removeListener('browser:activateView', listener) - }, - - onPaneFocus: ( - callback: (data: { worktreeId: string | null; browserPageId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string | null; browserPageId: string } - ) => callback(data) - ipcRenderer.on('browser:pane-focus', listener) - return () => ipcRenderer.removeListener('browser:pane-focus', listener) - }, - - onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string } - ) => callback(data) - ipcRenderer.on('browser:open-link-in-orca-tab', listener) - return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) - }, - - cancelDownload: (args: { downloadId: string }): Promise => - ipcRenderer.invoke('browser:cancelDownload', args), - - setGrabMode: (args: { - browserPageId: string - enabled: boolean - }): Promise<{ ok: true } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:setGrabMode', args), - - awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => - ipcRenderer.invoke('browser:awaitGrabSelection', args), - - cancelGrab: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:cancelGrab', args), - - captureSelectionScreenshot: (args: { - browserPageId: string - rect: { x: number; y: number; width: number; height: number } - }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), - - extractHoverPayload: (args: { - browserPageId: string - }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:extractHoverPayload', args), - - onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => - callback(browserPageId) - ipcRenderer.on('browser:grabModeToggle', listener) - return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) - }, - - onGrabActionShortcut: ( - callback: (args: { browserPageId: string; key: 'c' | 's' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; key: 'c' | 's' } - ) => callback(data) - ipcRenderer.on('browser:grabActionShortcut', listener) - return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) - }, - - sessionListProfiles: (): Promise => - ipcRenderer.invoke('browser:session:listProfiles'), - - prepareSshWorkspacePartition: (args: { - targetId: string - browserProfileId?: string - skipProbe?: boolean - }): Promise<{ partition: string }> => - ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), - - sessionCreateProfile: (args: { - scope: 'default' | 'isolated' | 'imported' - label: string - userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), - - sessionDeleteProfile: (args: { profileId: string }): Promise => - ipcRenderer.invoke('browser:session:deleteProfile', args), - - sessionImportCookies: (args: { - profileId: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importCookies', args), - - sessionResolvePartition: (args: { profileId: string | null }): Promise => - ipcRenderer.invoke('browser:session:resolvePartition', args), - - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), - - sessionDetectBrowsersForClientHost: (args: { - environmentId: string - }): Promise => - ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), - - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importFromBrowser', args), - - sessionImportFromBrowserForClientHost: (args: { - environmentId: string - profileId: string - browserFamily: string - browserProfile?: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), - - sessionClientRouteImportSources: (args: { - environmentId: string - }): Promise> => - ipcRenderer.invoke('browser:session:clientRouteImportSources', args), - - sessionClearDefaultCookies: (): Promise => - ipcRenderer.invoke('browser:session:clearDefaultCookies'), - - notifyActiveTabChanged: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:activeTabChanged', args) - }, - - emulator: { - startFrameStream: (args: { - streamUrl: string - streamKey?: string - }): Promise<{ - streamId: string - }> => ipcRenderer.invoke('emulator:frameStreamStart', args), - stopFrameStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:frameStreamStop', args), - onFrameStreamFrame: ( - callback: (data: { streamId: string; bytes: ArrayBuffer }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; bytes: ArrayBuffer } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) - }, - onFrameStreamError: ( - callback: (data: { streamId: string; message: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; message: string } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamError', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamError', listener) - }, - startVideoStream: (args: { - deviceId: string - streamId: string - }): Promise<{ streamId: string }> => ipcRenderer.invoke('emulator:videoStreamStart', args), - stopVideoStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:videoStreamStop', args), - onVideoStreamMeta: ( - callback: (data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamMeta', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamMeta', listener) - }, - onVideoStreamFrame: ( - callback: (data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) - }, - onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('emulator:pane-focus', listener) - return () => ipcRenderer.removeListener('emulator:pane-focus', listener) - }, - onAutoAttach: ( - callback: (data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - } - ) => callback(data) - ipcRenderer.on('ui:emulatorAutoAttach', listener) - return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) - } - }, - - hooks: { - check: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: unknown - mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), - - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), - - createIssueCommandRunner: (args: { - repoId: string - worktreePath: string - command: string - }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), - - readIssueCommand: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - localContent: string | null - sharedContent: string | null - effectiveContent: string | null - localFilePath: string - source: 'local' | 'shared' | 'none' - }> => ipcRenderer.invoke('hooks:readIssueCommand', args), - - writeIssueCommand: (args: { - repoId: string - content: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) - }, - - ephemeralVm: { - listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), - listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), - doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), - provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), - cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), - onProvisionEvent: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string } - ): void => callback(event) - ipcRenderer.on('ephemeralVm:provisionEvent', listener) - return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) - }, - listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), - attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), - suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), - resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), - cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), - stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), - getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) - } satisfies PreloadApi['ephemeralVm'], - - cache: { - getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), - setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) - } satisfies PreloadApi['cache'], - - session: { - // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. - get: (hostId) => ipcRenderer.invoke('session:get', hostId), - set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), - patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), - flush: () => ipcRenderer.invoke('session:flush'), - readTerminalScrollback: (args) => - ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), - /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args, hostId) => { - ipcRenderer.sendSync('session:set-sync', args, hostId) - } - } satisfies PreloadApi['session'], - - remoteWorkspace: { - get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), - listEnabledConnectedTargets: () => - ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), - listConnectedClients: (args) => - ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), - clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), - onChanged: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => - callback(data) - ipcRenderer.on('remoteWorkspace:changed', listener) - return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) - } - } satisfies PreloadApi['remoteWorkspace'], - - updater: { - getStatus: () => ipcRenderer.invoke('updater:getStatus'), - getVersion: () => ipcRenderer.invoke('updater:getVersion'), - check: (options) => ipcRenderer.invoke('updater:check', options), - download: () => ipcRenderer.invoke('updater:download'), - dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), - dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), - getLinuxPackageInstallInstructions: () => - ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), - showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), - listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), - quitAndInstall: (): Promise => - prepareAndInvokeUpdaterInstall( - window, - updaterQuitAbortRelay, - () => ipcRenderer.invoke('updater:quitAndInstall'), - awaitBeforeUnloadCheckpoint - ), - - onStatus: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) - ipcRenderer.on('updater:status', listener) - return () => ipcRenderer.removeListener('updater:status', listener) - }, - onClearDismissal: (callback) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('updater:clearDismissal', listener) - return () => ipcRenderer.removeListener('updater:clearDismissal', listener) - } - } satisfies PreloadApi['updater'], - - docPreview: { - mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => - ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), - revokeGrant: (grantId: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), - authorizeDirectory: (grantId: string, relativePath: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), - onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => - callback(payload) - ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) - return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) - }, - onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void => - callback(payload) - ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) - return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) - } - }, - - notebook: { - runPythonCell: (args: { - filePath: string - code: string - preamble?: string - connectionId?: string | null - }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => - ipcRenderer.invoke('notebook:runPythonCell', args) - }, - - fs: { - readDir: (args: { - dirPath: string - connectionId?: string - }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => - ipcRenderer.invoke('fs:readDir', args), - readFile: (args: { - filePath: string - connectionId?: string - includeLocalLogMetadata?: boolean - }): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => ipcRenderer.invoke('fs:readFile', args), - readLocalLogTail: (args: LocalLogTailReadArgs): Promise => - ipcRenderer.invoke('fs:readLocalLogTail', args), - startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => - ipcRenderer.invoke('fs:startLocalLogTail', args), - stopLocalLogTail: (args: { subscriptionId: string }): Promise => - ipcRenderer.invoke('fs:stopLocalLogTail', args), - onLocalLogTailChanged: ( - callback: (payload: LocalLogTailChangedPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: LocalLogTailChangedPayload - ): void => callback(payload) - ipcRenderer.on('fs:localLogTailChanged', listener) - return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) - }, - downloadFile: (args: { - filePath: string - connectionId: string - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFile', args), - downloadFolder: (args: { - dirPath: string - connectionId: string - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFolder', args), - saveDownloadedFile: (args: { - suggestedName: string - content: string - encoding: 'utf8' | 'base64' - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:saveDownloadedFile', args), - startDownloadedFile: (args: { - suggestedName: string - }): Promise< - { canceled: true } | { canceled: false; transferId: string; destinationPath: string } - > => ipcRenderer.invoke('fs:startDownloadedFile', args), - appendDownloadedFileChunk: (args: { - transferId: string - contentBase64: string - }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), - finishDownloadedFile: (args: { - transferId: string - }): Promise<{ canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:finishDownloadedFile', args), - cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => - ipcRenderer.invoke('fs:cancelDownloadedFile', args), - listMarkdownDocuments: (args: { - rootPath: string - connectionId?: string - }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => - ipcRenderer.invoke('fs:listMarkdownDocuments', args), - writeFile: ( - args: { - filePath: string - content: string - connectionId?: string - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:writeFile', args), - createFile: ( - args: { filePath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createFile', args), - createDir: ( - args: { dirPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createDir', args), - rename: ( - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:rename', args), - copy: ( - args: { - sourcePath: string - destinationPath: string - connectionId?: string - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:copy', args), - deletePath: ( - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), - stat: (args: { - filePath: string - connectionId?: string - }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => - ipcRenderer.invoke('fs:stat', args), - pathExists: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:pathExists', args), - listFiles: (args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - }): Promise => ipcRenderer.invoke('fs:listFiles', args), - cancelListFiles: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('fs:cancelListFiles', args), - search: (args: { - query: string - rootPath: string - caseSensitive?: boolean - wholeWord?: boolean - useRegex?: boolean - includePattern?: string - excludePattern?: string - maxResults?: number - connectionId?: string - }): Promise => ipcRenderer.invoke('fs:search', args), - importExternalPaths: ( - args: { - sourcePaths: string[] - destDir: string - connectionId?: string - ensureDir?: boolean - } & SshMutationExpectation - ): Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), - stageExternalPathsForRuntimeUpload: (args: { - sourcePaths: string[] - }): Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), - resolveDroppedPathsForAgent: ( - args: { - paths: string[] - worktreePath: string - connectionId?: string - } & SshMutationExpectation - ): Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), - watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:watchWorktree', args), - unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:unwatchWorktree', args), - onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => - callback(payload) - ipcRenderer.on('fs:changed', listener) - return () => ipcRenderer.removeListener('fs:changed', listener) - } - }, - - git: { - status: (args: { - worktreePath: string - connectionId?: string - admissionTier?: 'interactive' | 'status' | 'background' - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), - cancelStatus: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('git:cancelStatus', args), - setStatusUpstreamRefWatch: (args: { - worktreeId: string - worktreePath: string - executionHostId: string - connectionId?: string - branch?: string - upstreamName?: string - }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), - submoduleStatus: (args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), - checkIgnored: (args: { - worktreePath: string - paths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:checkIgnored', args), - findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => - ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), - appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => - ipcRenderer.invoke('git:appendGitignore', args), - history: ( - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => ipcRenderer.invoke('git:history', args), - conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:conflictOperation', args), - abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortMerge', args), - abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortRebase', args), - diff: (args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: 'interactive' | 'status' | 'background' - }): Promise => ipcRenderer.invoke('git:branchCompare', args), - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), - upstreamStatus: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), - fetch: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fetch', args), - syncFork: (args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - }): Promise => ipcRenderer.invoke('git:syncFork', args), - push: (args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: unknown - }): Promise => ipcRenderer.invoke('git:push', args), - pull: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:pull', args), - fastForward: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fastForward', args), - rebaseFromBase: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), - branchDiff: (args: { - worktreePath: string - compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } - filePath: string - oldPath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), - commitDiff: (args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), - commit: (args: { - worktreePath: string - message: string - connectionId?: string - }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), - generateCommitMessage: (args: { - worktreePath: string - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: unknown - sourceControlAi?: unknown - agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), - discoverCommitMessageModels: (args: { - agentId: string - worktreePath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), - cancelGenerateCommitMessage: (args: { - worktreePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), - generatePullRequestFields: (args: { - worktreePath: string - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: unknown - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: unknown - sourceControlAi?: unknown - agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), - cancelGeneratePullRequestFields: (args: { - worktreePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), - stage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:stage', args), - bulkStage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkStage', args), - unstage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:unstage', args), - bulkUnstage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), - discard: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:discard', args), - bulkDiscard: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), - remoteFileUrl: (args: { - worktreePath: string - relativePath: string - line: number - connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), - remoteCommitUrl: (args: { - worktreePath: string - sha: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) - }, - - ui: { - get: () => ipcRenderer.invoke('ui:get'), - set: (args) => ipcRenderer.invoke('ui:set', args), - // Same channel: the local invoke already rejects when main fails to apply. - setWithAck: (args) => ipcRenderer.invoke('ui:set', args), - recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), - onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => - callback(ui) - ipcRenderer.on('ui:stateChanged', listener) - return () => ipcRenderer.removeListener('ui:stateChanged', listener) - }, - onOpenSettings: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openSettings', listener) - return () => ipcRenderer.removeListener('ui:openSettings', listener) - }, - consumePendingOpenSettings: (): Promise => - ipcRenderer.invoke('ui:consumePendingOpenSettings'), - onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => - callback(shareId) - ipcRenderer.on('ui:openSkillShare', listener) - return () => ipcRenderer.removeListener('ui:openSkillShare', listener) - }, - consumePendingSkillShare: (): Promise => - ipcRenderer.invoke('ui:consumePendingSkillShare'), - onOpenSetupGuide: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openSetupGuide', listener) - return () => ipcRenderer.removeListener('ui:openSetupGuide', listener) - }, - onOpenFeatureTour: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openFeatureTour', listener) - return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) - }, - onOpenCrashReport: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openCrashReport', listener) - return () => ipcRenderer.removeListener('ui:openCrashReport', listener) - }, - onToggleLeftSidebar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleLeftSidebar', listener) - return () => ipcRenderer.removeListener('ui:toggleLeftSidebar', listener) - }, - onToggleRightSidebar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleRightSidebar', listener) - return () => ipcRenderer.removeListener('ui:toggleRightSidebar', listener) - }, - onToggleWorktreePalette: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleWorktreePalette', listener) - return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) - }, - onToggleFloatingTerminal: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleFloatingTerminal', listener) - return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener) - }, - onTerminalShortcutCaptured: ( - callback: (data: { actionId: KeybindingActionId }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { actionId: KeybindingActionId } - ) => callback(data) - ipcRenderer.on('ui:terminalShortcutCaptured', listener) - return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener) - }, - onOpenQuickOpen: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openQuickOpen', listener) - return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) - }, - onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) - return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) - }, - onOpenNewWorkspace: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openNewWorkspace', listener) - return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) - }, - onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:deleteCurrentWorkspace', listener) - return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) - }, - onOpenWorkspaceBoard: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openWorkspaceBoard', listener) - return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) - }, - onOpenTasks: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openTasks', listener) - return () => ipcRenderer.removeListener('ui:openTasks', listener) - }, - onToggleAgentDashboard: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleAgentDashboard', listener) - return () => ipcRenderer.removeListener('ui:toggleAgentDashboard', listener) - }, - onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) - ipcRenderer.on('ui:jumpToWorktreeIndex', listener) - return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) - }, - onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) - ipcRenderer.on('ui:jumpToTabIndex', listener) - return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) - }, - onWorktreeHistoryNavigate: ( - callback: (direction: 'back' | 'forward') => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward') => - callback(direction) - ipcRenderer.on('ui:worktreeHistoryNavigate', listener) - return () => ipcRenderer.removeListener('ui:worktreeHistoryNavigate', listener) - }, - onNewBrowserTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newBrowserTab', listener) - return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) - }, - onNewMarkdownTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newMarkdownTab', listener) - return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) - }, - onNewSimulatorTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newSimulatorTab', listener) - return () => ipcRenderer.removeListener('ui:newSimulatorTab', listener) - }, - onRequestTabCreate: ( - callback: (data: { - requestId: string - url: string - worktreeId?: string - browserPageId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - url: string - worktreeId?: string - browserPageId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - } - ) => callback(data) - ipcRenderer.on('browser:requestTabCreate', listener) - return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) - }, - replyTabCreate: (reply: { - requestId: string - browserPageId?: string - error?: string - }): void => { - ipcRenderer.send('browser:tabCreateReply', reply) - }, - onRequestTabSetProfile: ( - callback: (data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - } - ) => callback(data) - ipcRenderer.on('browser:requestTabSetProfile', listener) - return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) - }, - replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { - ipcRenderer.send('browser:tabSetProfileReply', reply) - }, - onRequestTabClose: ( - callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; tabId: string | null; worktreeId?: string } - ) => callback(data) - ipcRenderer.on('browser:requestTabClose', listener) - return () => ipcRenderer.removeListener('browser:requestTabClose', listener) - }, - replyTabClose: (reply: { - requestId: string - error?: string - code?: 'browser_tab_not_found' - }): void => { - ipcRenderer.send('browser:tabCloseReply', reply) - }, - onNewTerminalTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newTerminalTab', listener) - return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) - }, - onFocusBrowserAddressBar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:focusBrowserAddressBar', listener) - return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) - }, - onFindInBrowserPage: browserFindSubscriptions.subscribe, - onReloadBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:reloadBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) - }, - onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward'): void => - callback(direction) - ipcRenderer.on('ui:browserHistoryNavigate', listener) - return () => ipcRenderer.removeListener('ui:browserHistoryNavigate', listener) - }, - onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => - callback(direction) - ipcRenderer.on('ui:zoomBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener) - }, - onScrollBrowserPage: ( - callback: (event: { browserPageId: string; deltaX: number; deltaY: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { browserPageId: string; deltaX: number; deltaY: number } - ) => callback(payload) - ipcRenderer.on('ui:scrollBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:scrollBrowserPage', listener) - }, - onHardReloadBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:hardReloadBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) - }, - onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => { - const admitted = admitCloseActiveTabPayload(payload) - if (admitted.kind === 'legacy') { - callback() - } else if (admitted.kind === 'source') { - callback(admitted.payload) - } - } - ipcRenderer.on('ui:closeActiveTab', listener) - return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) - }, - onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => - callback(payload) - ipcRenderer.on('ui:closeFloatingItem', listener) - return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) - }, - onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => - callback(payload) - ipcRenderer.on('ui:selectFloatingIndex', listener) - return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) - }, - onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTab', listener) - return () => ipcRenderer.removeListener('ui:switchTab', listener) - }, - onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) - return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) - }, - onSwitchRecentTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:switchRecentTab', listener) - return () => ipcRenderer.removeListener('ui:switchRecentTab', listener) - }, - onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTerminalTab', listener) - return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) - }, - onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => - callback(data) - ipcRenderer.on('ui:ctrlTabKeyDown', listener) - return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) - }, - onCtrlTabKeyUp: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:ctrlTabKeyUp', listener) - return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) - }, - onToggleStatusBar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleStatusBar', listener) - return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) - }, - onExportPdfRequested: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('export:requestPdf', listener) - return () => ipcRenderer.removeListener('export:requestPdf', listener) - }, - onAppMenuPaste: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:appMenuPaste', listener) - return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) - }, - onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => - callback(action) - ipcRenderer.on('ui:appMenuSelectionAction', listener) - return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) - }, - onEditableContextPaste: ( - callback: (data: { plainTextOnly: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { plainTextOnly: boolean } - ): void => callback({ plainTextOnly: data?.plainTextOnly === true }) - ipcRenderer.on('ui:editableContextPaste', listener) - return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) - }, - onDictationKeyDown: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:dictationKeyDown', listener) - return () => ipcRenderer.removeListener('ui:dictationKeyDown', listener) - }, - onActivateWorktree: ( - callback: (data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: { command: string; env?: Record } - defaultTabs?: WorktreeDefaultTabsLaunch - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: { command: string; env?: Record } - defaultTabs?: WorktreeDefaultTabsLaunch - } - ) => callback(data) - ipcRenderer.on('ui:activateWorktree', listener) - return () => ipcRenderer.removeListener('ui:activateWorktree', listener) - }, - onCreateTerminal: ( - callback: (data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - } - ) => callback(data) - ipcRenderer.on('ui:createTerminal', listener) - return () => ipcRenderer.removeListener('ui:createTerminal', listener) - }, - onRequestTerminalCreate: ( - callback: (data: RuntimeTerminalCreateRequestPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: RuntimeTerminalCreateRequestPayload - ) => callback(data) - ipcRenderer.on('terminal:requestTabCreate', listener) - return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) - }, - onRequestTerminalTabMount: ( - callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string; tabId?: string; ptyId?: string } - ) => callback(data) - ipcRenderer.on('terminal:requestTabMount', listener) - return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) - }, - replyTerminalCreate: (reply: TerminalTabCreateReply): void => { - ipcRenderer.send('terminal:tabCreateReply', reply) - }, - onSplitTerminal: ( - callback: (data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - } - ) => callback(data) - ipcRenderer.on('ui:splitTerminal', listener) - return () => ipcRenderer.removeListener('ui:splitTerminal', listener) - }, - onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; title: string | null } - ) => callback(data) - ipcRenderer.on('ui:renameTerminal', listener) - return () => ipcRenderer.removeListener('ui:renameTerminal', listener) - }, - onFocusTerminal: ( - callback: (data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - } - ) => callback(data) - ipcRenderer.on('ui:focusTerminal', listener) - return () => ipcRenderer.removeListener('ui:focusTerminal', listener) - }, - onFocusEditorTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } - ) => callback(data) - ipcRenderer.on('ui:focusEditorTab', listener) - return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) - }, - onCloseSessionTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } - ) => callback(data) - ipcRenderer.on('ui:closeSessionTab', listener) - return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) - }, - onSessionTabCloseRequest: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: Parameters[0] - ) => callback(request) - ipcRenderer.on('ui:sessionTabCloseRequest', listener) - return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener) - }, - respondSessionTabClose: (response) => { - ipcRenderer.send('ui:sessionTabCloseResponse', response) - }, - onMoveSessionTab: ( - callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string } & RuntimeMobileSessionTabMove - ) => callback(data) - ipcRenderer.on('ui:moveSessionTab', listener) - return () => ipcRenderer.removeListener('ui:moveSessionTab', listener) - }, - onOpenFileFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - } - ) => callback(data) - ipcRenderer.on('ui:openFileFromMobile', listener) - return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) - }, - onOpenDiffFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - } - ) => callback(data) - ipcRenderer.on('ui:openDiffFromMobile', listener) - return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) - }, - onMobileMarkdownRequest: ( - callback: (request: RuntimeMobileMarkdownRequest) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => - callback(request) - ipcRenderer.on('ui:mobileMarkdownRequest', listener) - return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) - }, - respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { - ipcRenderer.send('ui:mobileMarkdownResponse', response) - }, - onCloseTerminal: ( - callback: (data: { tabId: string; paneRuntimeId?: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; paneRuntimeId?: number } - ) => callback(data) - ipcRenderer.on('ui:closeTerminal', listener) - return () => ipcRenderer.removeListener('ui:closeTerminal', listener) - }, - onTerminalTabCloseRequest: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: Parameters[0] - ) => callback(request) - ipcRenderer.on('ui:terminalTabCloseRequest', listener) - return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) - }, - respondTerminalTabClose: (response) => { - ipcRenderer.send('ui:terminalTabCloseResponse', response) - }, - onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('ui:sleepWorktree', listener) - return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) - }, - onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('ui:resumeSleepingAgents', listener) - return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) - }, - onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => - callback(direction) - ipcRenderer.on('terminal:zoom', listener) - return () => ipcRenderer.removeListener('terminal:zoom', listener) - }, - readClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readText', options), - readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readSelectionText', options), - saveClipboardImageAsTempFile: (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), - writeClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeText', text), - writeTerminalClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeTerminalText', text), - writeSelectionClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeSelectionText', text), - writeClipboardImage: (dataUrl: string): Promise => - ipcRenderer.invoke('clipboard:writeImage', dataUrl), - performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { - ipcRenderer.send('ui:performNativePaste', { - mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' - }) - }, - performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { - ipcRenderer.send('ui:performNativeSelectionAction', action) - }, - writeClipboardFile: ( - args: - | { - filePath: string - connectionId?: string | null - } - | string - ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), - onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => - subscribeNativeFileDrop(callback), - getZoomLevel: (): number => webFrame.getZoomLevel(), - setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), - syncTrafficLights: (zoomFactor: number): void => - ipcRenderer.send('ui:sync-traffic-lights', zoomFactor), - // Why: one-way send so main's before-input-event can synchronously skip Cmd+B while the markdown editor is focused (TipTap bold). - setMarkdownEditorFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setMarkdownEditorFocused', focused) - }, - setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { - ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) - }, - setTerminalInputFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setTerminalInputFocused', focused) - }, - // Why: one atomic payload so main's synchronous before-input-event never sees a torn terminal=true/panel=false state. - setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { - ipcRenderer.send('ui:setFloatingFocus', state) - }, - setShortcutRecorderFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setShortcutRecorderFocused', focused) - }, - onRichMarkdownContextCommand: ( - callback: (payload: RichMarkdownContextMenuCommandPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: RichMarkdownContextMenuCommandPayload - ) => callback(payload) - ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) - return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) - }, - onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => - callback(isFullScreen) - ipcRenderer.on('window:fullscreen-changed', listener) - return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) - }, - /** Fired when the OS resumes from sleep — a focus-preserving wake fires no renderer focus/visibility events. */ - onSystemResumed: (callback: () => void): (() => void) => { - const listener = () => callback() - ipcRenderer.on('system:resumed', listener) - return () => ipcRenderer.removeListener('system:resumed', listener) - }, - /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ - minimize: (): void => { - ipcRenderer.send('window:minimize') - }, - /** Desktop custom titlebar only: toggle maximize/restore via renderer-drawn controls. */ - maximize: (): void => { - ipcRenderer.send('window:maximize') - }, - /** Desktop custom titlebar only: read initial maximize state on mount — maximize-changed only fires on transitions. */ - isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), - /** Desktop custom titlebar only: subscribe to maximize-state changes so the maximize button shows the right icon. */ - onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => - callback(isMaximized) - ipcRenderer.on('window:maximize-changed', listener) - return () => ipcRenderer.removeListener('window:maximize-changed', listener) - }, - /** Desktop custom titlebar only: request close via main so the BrowserWindow 'close' event - * (and its terminal-running guard) still fires — window.close() is unreliable in sandboxed renderers. */ - requestClose: (): void => { - ipcRenderer.send('window:request-close') - }, - /** Desktop custom titlebar only: pop up the app menu at the cursor — Alt-reveal replacement for the ··· button. */ - popupMenu: (): void => { - ipcRenderer.send('menu:popup') - }, - /** Fired by main when the user tries to close the window; renderer confirms running - * terminals then calls confirmWindowClose(). isQuitting (Cmd+Q / app.quit) skips that dialog. */ - onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { isQuitting: boolean; requestId?: number } - ): void => { - // Why: main cannot reach will-quit while a frozen renderer owns the window close handshake. - ipcRenderer.send('window:close-request-received', data?.requestId) - callback({ isQuitting: data?.isQuitting ?? false }) - } - ipcRenderer.on('window:close-requested', listener) - return () => ipcRenderer.removeListener('window:close-requested', listener) - }, - /** Tell the main process to proceed with the window close. */ - confirmWindowClose: (): void => { - ipcRenderer.send('window:confirm-close') - }, - /** Report a genuine hidden→visible reveal so main can recover a stale (throttled) layout/compositor surface. */ - notifyWindowRevealed: (): void => { - ipcRenderer.send('ui:window-revealed') - } - } satisfies PreloadApi['ui'], - - stats: { - getSummary: (): Promise<{ - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null - }> => ipcRenderer.invoke('stats:summary') - }, - - memory: { - getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') - }, - - claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), - codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), - openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), - - aiVault: { - listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), - resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => - ipcRenderer.invoke('aiVault:resolveSessionTitles', args), - cancelListSessions: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => - ipcRenderer.invoke('aiVault:prepareSessionResume', args), - listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => - ipcRenderer.invoke('aiVault:listSubagentSessions', args), - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => - ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), - deleteSession: (args: AiVaultDeleteSessionArgs): Promise => - ipcRenderer.invoke('aiVault:deleteSession', args), - onWindowFocused: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('aiVault:windowFocused', listener) - return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) - } - }, - - nativeChat: { - readSession: ( - agent: AgentType, - sessionId: string, - limit?: number, - transcriptPath?: string - ): Promise => - ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), - /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ - subscribe: ( - args: { - subscriptionId: string - agent: AgentType - sessionId: string - transcriptPath?: string - limit?: number - }, - onFrame: (frame: NativeChatSubscriptionFrame) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { - if (payload.subscriptionId === args.subscriptionId) { - onFrame(payload.frame) - } - } - ipcRenderer.on('nativeChat:appended', listener) - ipcRenderer.send('nativeChat:subscribe', args) - return () => { - ipcRenderer.removeListener('nativeChat:appended', listener) - ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) - } - } - }, - - runtime: { - syncWindowGraph: ( - graph: RuntimeRendererSyncWindowGraph - ): Promise => - ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), - call: (args: { method: string; params?: unknown }): Promise> => - ipcRenderer.invoke('runtime:call', args), - subscribe: async ( - args: { method: string; params?: unknown }, - callback: (response: RuntimeRpcResponse) => void - ): Promise => { - const subscriptionId = `desktop-${crypto.randomUUID()}` - const channel = `runtime:subscription:${subscriptionId}` - const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => - callback(response) - ipcRenderer.on(channel, listener) - try { - await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) - } catch (error) { - ipcRenderer.removeListener(channel, listener) - throw error - } - return { - unsubscribe: () => { - ipcRenderer.removeListener(channel, listener) - ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) - }, - sendBinary: () => { - throw new Error('Local runtime subscriptions do not accept binary input') - } - } - }, - getTerminalFitOverrides: (): Promise< - { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] - > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), - getTerminalDrivers: (): Promise< - { - ptyId: string - driver: RuntimeTerminalDriverState - }[] - > => ipcRenderer.invoke('runtime:getTerminalDrivers'), - getBrowserDrivers: (): Promise< - { - browserPageId: string - driver: RuntimeBrowserDriverState - }[] - > => ipcRenderer.invoke('runtime:getBrowserDrivers'), - getBrowserRemoteViewerPages: (): Promise => - ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), - getClientHostedBrowserRows: (): Promise => - ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), - restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => - ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), - reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => - ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), - onTerminalFitOverrideChanged: ( - callback: (event: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - } - ) => callback(data) - ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) - return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) - }, - onTerminalDriverChanged: ( - callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - ptyId: string - driver: RuntimeTerminalDriverState - } - ) => callback(data) - ipcRenderer.on('runtime:terminalDriverChanged', listener) - return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) - }, - onNativeChatLaunchDraftResolved: ( - callback: (event: { tabId: string; text: string; createdAt: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; text: string; createdAt: number } - ) => callback(data) - ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) - return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) - }, - onBrowserDriverChanged: ( - callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - driver: RuntimeBrowserDriverState - } - ) => callback(data) - ipcRenderer.on('runtime:browserDriverChanged', listener) - return () => ipcRenderer.removeListener('runtime:browserDriverChanged', listener) - }, - onBrowserRemoteViewersChanged: ( - callback: (event: { browserPageId: string; hasRemoteViewers: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - hasRemoteViewers: boolean - } - ) => callback(data) - ipcRenderer.on('runtime:browserRemoteViewersChanged', listener) - return () => ipcRenderer.removeListener('runtime:browserRemoteViewersChanged', listener) - }, - onClientHostedBrowserRowsChanged: ( - callback: (event: ClientHostedBrowserRowsEvent) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: ClientHostedBrowserRowsEvent) => - callback(data) - ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) - return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) - } - }, - - runtimeEnvironments: { - list: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:list'), - addFromPairingCode: (args: { - name: string - pairingCode: string - }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), - verifyAndAddFromPairingCode: (args: { - name: string - pairingCode: string - allowLoopback?: boolean - }): Promise => - ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), - resolve: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:resolve', args), - remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:remove', args), - disconnect: (args: { - selector: string - }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:disconnect', args), - connect: (args: { - selector: string - timeoutMs?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:connect', args), - getStatus: (args: { - selector: string - timeoutMs?: number - observeOnly?: true - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:getStatus', args), - retryControlConnection: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), - onSharedControlDiagnostics: ( - callback: (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - } - ): void => callback(data) - ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) - return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) - }, - prepareBrowserClientHostPlacement: (args) => - ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), - retryConnectionsNow: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), - call: (args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:call', args), - subscribe: async ( - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }, - callbacks: { - onResponse: (response: RuntimeRpcResponse) => void - onBinary?: (bytes: Uint8Array) => void - onError?: (error: { code: string; message: string }) => void - onClose?: () => void - } - ): Promise => - subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) - }, - - rateLimits: { - get: (): Promise => ipcRenderer.invoke('rateLimits:get'), - refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), - refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), - consumeCodexResetCredit: (): Promise => - ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), - refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), - setPollingInterval: (ms: number): Promise => - ipcRenderer.invoke('rateLimits:setPollingInterval', ms), - fetchInactiveClaudeAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), - fetchInactiveCodexAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), - refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), - refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), - onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) - ipcRenderer.on('rateLimits:update', listener) - return () => ipcRenderer.removeListener('rateLimits:update', listener) - } - }, - - minimaxCredentials: { - getStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:getStatus'), - saveCookie: (cookie: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), - clearCookie: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:clearCookie') - }, - - grokAccounts: { - getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') - }, - - ssh: { - listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), - - listRemovedTargetLabels: (): Promise> => - ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - - addTarget: (args: { target: SshTargetCreateInput }): Promise => - ipcRenderer.invoke('ssh:addTarget', args), - - updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => - ipcRenderer.invoke('ssh:updateTarget', args), - - removeTarget: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removeTarget', args), - - importConfig: (args?: { reAdopt?: boolean }): Promise => - ipcRenderer.invoke('ssh:importConfig', args), - - listConfigHosts: (args?: SshConfigHostListArgs): Promise => - ipcRenderer.invoke('ssh:listConfigHosts', args), - - resolveConfigHost: (args: { alias: string }): Promise => - ipcRenderer.invoke('ssh:resolveConfigHost', args), - - connect: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:connect', args) - return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null - }, - - disconnect: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:disconnect', args), - - terminateSessions: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:terminateSessions', args), - - resetRelay: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:resetRelay', args), - - getState: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:getState', args) - return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null - }, - - needsPassphrasePrompt: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), - - testConnection: async (args: { - targetId: string - }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { - const result: { success: boolean; error?: string; state?: unknown } = - await ipcRenderer.invoke('ssh:testConnection', args) - const state = result.state - ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) - : null - return { ...result, ...(state ? { state } : { state: undefined }) } - }, - - onStateChanged: ( - callback: (data: { targetId: string; state: SshConnectionState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; state: unknown } - ): void => { - const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) - if (state) { - callback({ targetId: data.targetId, state }) - } - } - ipcRenderer.on('ssh:state-changed', listener) - return () => ipcRenderer.removeListener('ssh:state-changed', listener) - }, - - addPortForward: (args: { - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), - - updatePortForward: (args: { - id: string - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), - - removePortForward: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removePortForward', args), - - listPortForwards: (args?: { targetId?: string }): Promise => - ipcRenderer.invoke('ssh:listPortForwards', args), - - listDetectedPorts: async (args: { targetId: string }): Promise => - admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), - - onPortForwardsChanged: ( - callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; forwards: PortForwardEntry[] } - ) => callback(data) - ipcRenderer.on('ssh:port-forwards-changed', handler) - return () => ipcRenderer.removeListener('ssh:port-forwards-changed', handler) - }, - - onDetectedPortsChanged: ( - callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; ports: unknown } - ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) - ipcRenderer.on('ssh:detected-ports-changed', handler) - return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) - }, - - browseDir: (args: { - targetId: string - dirPath: string - }): Promise<{ - entries: { name: string; isDirectory: boolean }[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor - }> => ipcRenderer.invoke('ssh:browseDir', args), - - onCredentialRequest: ( - callback: (data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' | 'keyboard-interactive' - detail: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' | 'keyboard-interactive' - detail: string - } - ) => callback(data) - ipcRenderer.on('ssh:credential-request', listener) - return () => ipcRenderer.removeListener('ssh:credential-request', listener) - }, - - onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) => - callback(data) - ipcRenderer.on('ssh:credential-resolved', listener) - return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) - }, - - submitCredential: (args: { requestId: string; value: string | null }): Promise => - ipcRenderer.invoke('ssh:submitCredential', args) - }, - - // Orca automation CRUD rides the local runtime RPC surface (`runtime:call`), - // so only external-manager and dispatch-loop plumbing stays on IPC. - automations: { - listExternalManagerForOwner: ( - request: ScopedExternalManagerListRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalManagerForOwner', request), - listExternalRunsForOwner: ( - request: ScopedExternalManagerRunsRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalRunsForOwner', request), - createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => - ipcRenderer.invoke('automations:createExternalForOwner', request), - updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => - ipcRenderer.invoke('automations:updateExternalForOwner', request), - runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => - ipcRenderer.invoke('automations:runExternalActionForOwner', request), - retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => - ipcRenderer.invoke('automations:retainExternalScopes', request), - runPrecheck: (args: { - automationId: string - runId: string - }): Promise => - ipcRenderer.invoke('automations:runPrecheck', args), - markDispatchResult: (result: AutomationDispatchResult): Promise => - ipcRenderer.invoke('automations:markDispatchResult', result), - snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => - ipcRenderer.invoke('automations:snapshotWorkspaceName', args), - rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), - onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => - callback(request) - ipcRenderer.on('automations:dispatchRequested', listener) - return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) - }, - onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => - callback(payload) - ipcRenderer.on('automations:changed', listener) - return () => ipcRenderer.removeListener('automations:changed', listener) - } - }, - - e2e: { - getConfig: () => preloadE2EConfig - }, - - mobile: { - listNetworkInterfaces: (): Promise<{ - interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] - }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), - - getPairingQR: (args?: { - address?: string - connectionMode?: MobilePairingConnectionMode - rotate?: boolean - }): Promise< - | { - available: false - reason?: string - guidance?: string - relayFailure?: MobileRelayMintFailure - } - | { - available: true - qrDataUrl: string | null - /** Natural bitmap width and height in pixels. */ - qrSize: number | null - qrError?: 'encoding_failed' - pairingUrl: string - /** Null when no direct address was advertised — the QR pairs over Relay alone. */ - endpoint: string | null - deviceId: string - connectionMode: MobilePairingConnectionMode - } - > => ipcRenderer.invoke('mobile:getPairingQR', args), - - getWindowsFirewallStatus: (args?: { address?: string }) => - ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), - - repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), - - openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), - - getRuntimePairingUrl: (args?: { - address?: string - rotate?: boolean - // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not - // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). - reach?: RuntimePairingReach - }): Promise< - | { available: false; reason?: 'network_exposure_failed'; guidance?: string } - | { - available: true - pairingUrl: string - webClientUrl: string | null - endpoint: string - deviceId: string - } - > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), - - listDevices: (): Promise<{ - devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] - }> => ipcRenderer.invoke('mobile:listDevices'), - - revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeDevice', args), - - listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), - - revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), - - isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => - ipcRenderer.invoke('mobile:isWebSocketReady'), - - getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => - ipcRenderer.invoke('mobile:getRelayStatus'), - - onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => - callback(status) - ipcRenderer.on('mobile:relayStatusChanged', listener) - return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) - }, - - consumePendingUnpairedDeviceAuthFailure: (): Promise => - ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), - - /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ - onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { - const listener = () => callback() - ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) - return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) - } - }, - - agentStatus: { - /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => - callback(data) - ipcRenderer.on('agentStatus:set', listener) - return () => ipcRenderer.removeListener('agentStatus:set', listener) - }, - onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => - callback(data) - ipcRenderer.on('agentStatus:clear', listener) - return () => ipcRenderer.removeListener('agentStatus:clear', listener) - }, - /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ - getSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getSnapshot'), - inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferInterrupt', request), - inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), - onMigrationUnsupported: ( - callback: (entry: MigrationUnsupportedPtyEntry) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => - callback(entry) - ipcRenderer.on('agentStatus:migrationUnsupported', listener) - return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) - }, - onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) - ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) - return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) - }, - onLegacyWorkerTerminalRecovery: ( - callback: (data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) - }, - getMigrationUnsupportedSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), - /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ - drop: (paneKey: string): void => { - ipcRenderer.send('agentStatus:drop', paneKey) - }, - reconcileEndedProcess: (paneKey: string): void => { - ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) - }, - /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ - dropByTabPrefix: (tabId: string): void => { - ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) - }, - retirePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) - }, - restorePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) - }, - transferPaneAuthority: (args: { - fromPaneKey: string - toPaneKey: string - ptyId?: string - }): void => { - ipcRenderer.send('agentStatus:transferPaneAuthority', args) - } - }, - - speech: { - getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), - getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), - getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), - saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), - clearOpenAiApiKey: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:clearOpenAiApiKey'), - downloadModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:downloadModel', modelId), - cancelDownload: (modelId: string): Promise => - ipcRenderer.invoke('speech:cancelDownload', modelId), - deleteModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:deleteModel', modelId), - startDictation: ( - modelId: string, - hotwords: string[] | undefined, - sessionId: string - ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), - feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => - // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. - ipcRenderer.invoke( - 'speech:feedAudio', - Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), - sampleRate, - sessionId - ), - stopDictation: (sessionId = 'desktop'): Promise => - ipcRenderer.invoke('speech:stopDictation', sessionId), - - onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => - callback(data) - ipcRenderer.on('speech:partial', listener) - return () => ipcRenderer.removeListener('speech:partial', listener) - }, - onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => - callback(data) - ipcRenderer.on('speech:final', listener) - return () => ipcRenderer.removeListener('speech:final', listener) - }, - onDownloadProgress: ( - callback: (data: { modelId: string; progress: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { modelId: string; progress: number } - ): void => callback(data) - ipcRenderer.on('speech:downloadProgress', listener) - return () => ipcRenderer.removeListener('speech:downloadProgress', listener) - }, - onReady: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => - callback(data) - ipcRenderer.on('speech:ready', listener) - return () => ipcRenderer.removeListener('speech:ready', listener) - }, - onStopped: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => - callback(data) - ipcRenderer.on('speech:stopped', listener) - return () => ipcRenderer.removeListener('speech:stopped', listener) - }, - onError: (callback: (data: SpeechErrorEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechErrorEvent): void => - callback(data) - ipcRenderer.on('speech:error', listener) - return () => ipcRenderer.removeListener('speech:error', listener) - } - } + app: appApi, + orcaProfiles: orcaProfilesApi, + platform: platformApi, + wsl: wslApi, + pwsh: pwshApi, + gitBash: gitBashApi, + plugins: pluginsApi, + repos: reposApi, + projects: projectsApi, + projectGroups: projectGroupsApi, + folderWorkspaces: folderWorkspacesApi, + sparsePresets: sparsePresetsApi, + worktrees: worktreesApi, + workspaceCleanup: workspaceCleanupApi, + workspaceSpace: workspaceSpaceApi, + workspacePorts: workspacePortsApi, + pty: ptyApi, + feedback: feedbackApi, + crashReports: crashReportsApi, + export: exportApi, + gh: ghApi, + hostedReview: hostedReviewApi, + gl: glApiBridge, + bitbucket: bitbucketApi, + linear: linearApi, + jira: jiraApi, + starNag: starNagApi, + telemetryTrack: telemetryTrackApi, + telemetrySetOptIn: telemetrySetOptInApi, + telemetryAcknowledgeBanner: telemetryAcknowledgeBannerApi, + telemetryGetConsentState: telemetryGetConsentStateApi, + diagnostics: diagnosticsApi, + settings: settingsApi, + agentAwake: agentAwakeApi, + localhostWorktreeLabels: localhostWorktreeLabelsApi, + keybindings: keybindingsApi, + codexAccounts: codexAccountsApi, + claudeAccounts: claudeAccountsApi, + cli: cliApi, + codexConfigSync: codexConfigSyncApi, + agentTrust: agentTrustApi, + preflight: preflightApi, + notifications: notificationsApi, + onboarding: onboardingApi, + dashboard: dashboardApi, + terminalPreview: terminalPreviewApi, + macosTccPrompts: macosTccPromptsApi, + developerPermissions: developerPermissionsApi, + computerUsePermissions: computerUsePermissionsApi, + shell: shellApi, + skills: skillsApi, + pet: petApi, + browser: browserApi, + emulator: emulatorApi, + hooks: hooksApi, + ephemeralVm: ephemeralVmApi, + cache: cacheApi, + session: sessionApi, + remoteWorkspace: remoteWorkspaceApi, + updater: updaterApi, + docPreview: docPreviewApi, + notebook: notebookApi, + fs: fsApi, + git: gitApi, + ui: uiApi, + stats: statsApi, + memory: memoryApi, + claudeUsage: claudeUsageApi, + codexUsage: codexUsageApi, + openCodeUsage: openCodeUsageApi, + aiVault: aiVaultApi, + nativeChat: nativeChatApi, + runtime: runtimeApi, + runtimeEnvironments: runtimeEnvironmentsApi, + rateLimits: rateLimitsApi, + minimaxCredentials: minimaxCredentialsApi, + grokAccounts: grokAccountsApi, + ssh: sshApi, + automations: automationsApi, + e2e: e2eApi, + mobile: mobileApi, + agentStatus: agentStatusApi, + speech: speechApi } -// Expose Electron APIs via contextBridge when context-isolated, otherwise attach to the DOM global. if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts new file mode 100644 index 00000000000..b4ea3260ac8 --- /dev/null +++ b/src/preload/preload-runtime-support.ts @@ -0,0 +1,165 @@ +import { ipcRenderer, webUtils } from 'electron' +import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' +import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' +import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' +import { + ORCA_INTERNAL_FILE_DRAG_TYPE, + createNativeFileDropPayload, + createRejectedNativeFileDropPayload, + hasNativeFileDragTypes, + NATIVE_FILE_DROP_MAX_PATHS, + resolveNativeFileDropPath, + type NativeDropResolution, + type NativeFileDropPayload, + type NativeFileDropPathEntry +} from '../shared/native-file-drop' + +/** Joins the synchronous unload checkpoint with its durable renderer write. */ +export async function awaitBeforeUnloadCheckpoint(): Promise { + const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { + ok?: unknown + } + if (result?.ok !== true) { + throw new Error('Failed to persist renderer state before unload.') + } +} + +export const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' + +export function getLinuxDisplayServer(): 'wayland' | 'x11' | null { + if (process.platform !== 'linux') { + return null + } + if ( + process.env.WAYLAND_DISPLAY || + process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' || + process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland' + ) { + return 'wayland' + } + return process.env.DISPLAY ? 'x11' : null +} + +type NativeFileDropCallback = (data: NativeFileDropPayload) => void +const nativeFileDropCallbacks: NativeFileDropCallback[] = [] +let nativeFileDropListenerRegistered = false + +const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { + for (const callback of Array.from(nativeFileDropCallbacks)) { + callback(data) + } +} + +export function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { + nativeFileDropCallbacks.push(callback) + if (!nativeFileDropListenerRegistered) { + ipcRenderer.on('terminal:file-drop', onNativeFileDrop) + nativeFileDropListenerRegistered = true + } + return () => { + const callbackIndex = nativeFileDropCallbacks.indexOf(callback) + if (callbackIndex !== -1) { + nativeFileDropCallbacks.splice(callbackIndex, 1) + } + if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { + ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) + nativeFileDropListenerRegistered = false + } + } +} + +function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { + const pathEntries: NativeFileDropPathEntry[] = [] + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement) { + pathEntries.push({ + nativeFileDropTarget: entry.dataset.nativeFileDropTarget, + nativeFileDropDir: entry.dataset.nativeFileDropDir, + terminalTabId: entry.dataset.terminalTabId, + terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId + }) + } + } + return resolveNativeFileDropPath(pathEntries) +} + +/** Installs the one preload-side listener that converts native File objects to paths. */ +export function installNativeFileDropHandlers(): void { + document.addEventListener( + 'dragover', + (event) => { + if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + }, + true + ) + document.addEventListener( + 'drop', + (event) => { + if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { + return + } + event.preventDefault() + event.stopPropagation() + const files = event.dataTransfer?.files + if (!files || files.length === 0) { + return + } + const resolution = resolveNativeFileDrop(event) + if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { + ipcRenderer.send( + 'terminal:file-dropped-from-preload', + createRejectedNativeFileDropPayload({ + byteLength: 0, + pathCount: files.length, + reason: 'too-many-paths', + status: 'rejected' + }) + ) + return + } + const paths: string[] = [] + for (let index = 0; index < files.length; index += 1) { + const filePath = webUtils.getPathForFile(files[index]) + if (filePath) { + paths.push(filePath) + } + } + if (paths.length === 0 || resolution?.target === 'rejected') { + return + } + const payload = createNativeFileDropPayload(resolution, paths) + if (payload) { + ipcRenderer.send('terminal:file-dropped-from-preload', payload) + } + }, + true + ) +} + +export const browserFindSubscriptions = createBrowserFindSubscriptions() +export const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ + ipc: ipcRenderer, + isTopFrame: () => window.top === window +}) + +/** Registers browser find forwarding once for this preload context. */ +export function installBrowserFindListener(): void { + ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { + browserFindSubscriptions.dispatch(source) + }) +} + +export const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( + window, + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT +) + +registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay) From 7e8337b15548f5343be5887331036d6a476d613d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:17:06 -0700 Subject: [PATCH 06/93] test(preload): census split GitHub bridge owners (cherry picked from commit cd08e4e91c43bb34757c7a989fcf611a50b50db2) --- src/main/ipc/github-ipc-channel-parity.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 928e0df9fbf..73fc291f4ca 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -85,7 +85,14 @@ describe('GitHub IPC channel parity', () => { it('preserves the facade and fixed IPC channel contract', () => { github.registerGitHubHandlers(harness.store as never, harness.stats as never) - const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') + // The preload facade now composes the two GitHub bridge owners; inspect + // both owners so the channel census remains tied to the actual invokes. + const preloadSource = [ + '../../preload/api/gh-bridge-part-1.ts', + '../../preload/api/gh-bridge-part-2.ts' + ] + .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) + .join('\n') const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( (match) => match[1] ) From 85b95aada2ef6345fe582e6bc1f811abed761a26 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:48:43 -0700 Subject: [PATCH 07/93] fix(preload): merge split plugin imports (cherry picked from commit 682ff4f5be863533872c7a3375fa2730c34aac76) --- src/preload/api/plugins-bridge.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 5459ca3b696..2488d4cfdb7 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -9,9 +9,9 @@ import type { PluginHostInstallResult, PluginHostInstallSource, PluginHostListEntry, - PluginHostLogLine + PluginHostLogLine, + PreloadApi } from '../api-types' -import type { PreloadApi } from '../api-types' export const pluginsApi = { list: (): Promise => ipcRenderer.invoke('plugins:list'), From c475d23d9bc55e44fa55f5bbecb35faa7398ea3d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:30:19 -0700 Subject: [PATCH 08/93] chore(max-lines): prune preload baseline entry (cherry picked from commit aad2e1ec54a8a0ad0edf67a77fb2ef91c2f560b1) --- config/max-lines-baseline.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 78762850ef1..49ce9707ecf 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -17,7 +17,6 @@ inline src/main/ssh/ssh-connection.ts inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts inline src/main/updater.ts -inline src/preload/index.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts mobile-config app/h/*/files/*.tsx From 5b4e7edb50ed86e237fffa871d3fed22251d7185 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:28:43 -0700 Subject: [PATCH 09/93] refactor(main): split backend services and startup (cherry picked from commit a33573328bfeb551cf0f23284e8f18d893fd46c9) --- config/max-lines-baseline.txt | 10 - src/cli/serve-electron-flag-parity.test.ts | 7 +- src/main/agent-hooks/server.ts | 3544 +-------------- .../server/server-authority-aliases.ts | 222 + .../server/server-authority-evidence.ts | 94 + .../server/server-authority-fences.ts | 193 + .../server/server-claude-status-rules.ts | 191 + src/main/agent-hooks/server/server-cleanup.ts | 167 + .../agent-hooks/server/server-constants.ts | 29 + .../agent-hooks/server/server-hydration.ts | 162 + .../server/server-ingest-normalization.ts | 81 + .../server/server-ingest-remote.ts | 282 ++ .../server/server-ingest-terminal.ts | 104 + .../agent-hooks/server/server-lifecycle.ts | 197 + .../agent-hooks/server/server-listeners.ts | 218 + .../server/server-persistence-validation.ts | 183 + .../agent-hooks/server/server-persistence.ts | 141 + src/main/agent-hooks/server/server-reaping.ts | 124 + .../agent-hooks/server/server-runtime-env.ts | 63 + src/main/agent-hooks/server/server-state.ts | 217 + .../server/server-status-application.ts | 141 + .../server/server-status-disposition.ts | 161 + .../server/server-status-identity.ts | 94 + .../server/server-status-inference.ts | 175 + .../server/server-status-retries.ts | 155 + .../server/server-status-update.ts | 219 + .../agent-hooks/server/server-tab-cleanup.ts | 122 + .../server/server-transport-rules.ts | 13 + src/main/agent-hooks/server/server-types.ts | 113 + .../agent-browser-bridge-capture-commands.ts | 185 + .../agent-browser-bridge-core-commands.ts | 186 + .../browser/agent-browser-bridge-execution.ts | 263 ++ .../agent-browser-bridge-input-commands.ts | 124 + .../browser/agent-browser-bridge-input.ts | 67 + ...ent-browser-bridge-interaction-commands.ts | 192 + .../browser/agent-browser-bridge-lifecycle.ts | 266 ++ .../agent-browser-bridge-mouse-commands.ts | 206 + .../browser/agent-browser-bridge-mouse.ts | 195 + .../browser/agent-browser-bridge-process.ts | 183 + .../browser/agent-browser-bridge-queue.ts | 174 + .../agent-browser-bridge-raw-process.ts | 108 + .../browser/agent-browser-bridge-result.ts | 29 + .../browser/agent-browser-bridge-shutdown.ts | 40 + .../agent-browser-bridge-state-commands.ts | 266 ++ .../browser/agent-browser-bridge-state.ts | 64 + src/main/browser/agent-browser-bridge-tabs.ts | 232 + .../browser/agent-browser-bridge-types.ts | 65 + .../agent-browser-bridge-utility-commands.ts | 175 + src/main/browser/agent-browser-bridge.ts | 2904 +------------ .../browser-cookie-chromium-finalize.ts | 132 + .../browser/browser-cookie-chromium-import.ts | 74 + .../browser-cookie-chromium-prepare.ts | 289 ++ .../browser/browser-cookie-chromium-scan.ts | 167 + .../browser/browser-cookie-chromium-types.ts | 83 + src/main/browser/browser-cookie-decryption.ts | 126 + .../browser/browser-cookie-detection-types.ts | 224 + src/main/browser/browser-cookie-detection.ts | 127 + .../browser/browser-cookie-firefox-import.ts | 138 + .../browser-cookie-import-diagnostics.ts | 55 + .../browser/browser-cookie-import-pipeline.ts | 300 ++ src/main/browser/browser-cookie-import.ts | 2198 +--------- src/main/browser/browser-cookie-key.ts | 158 + .../browser/browser-cookie-safari-import.ts | 61 + .../browser/browser-cookie-safari-parser.ts | 148 + src/main/browser/browser-cookie-sqlite.ts | 147 + src/main/browser/browser-cookie-validation.ts | 127 + src/main/browser/browser-manager-bindings.ts | 89 + .../browser-manager-download-creation.ts | 188 + .../browser-manager-download-lifecycle.ts | 241 ++ .../browser-manager-event-forwarding.ts | 123 + src/main/browser/browser-manager-final.ts | 22 + src/main/browser/browser-manager-grab.ts | 126 + .../browser/browser-manager-guest-cleanup.ts | 44 + ...browser-manager-guest-navigation-policy.ts | 152 + .../browser/browser-manager-guest-policy.ts | 76 + .../browser-manager-guest-popup-policy.ts | 210 + .../browser/browser-manager-navigation.ts | 263 ++ src/main/browser/browser-manager-queries.ts | 136 + .../browser/browser-manager-registration.ts | 230 + src/main/browser/browser-manager-state.ts | 289 ++ src/main/browser/browser-manager-types.ts | 238 + .../browser-manager-viewport-scroll-state.ts | 81 + src/main/browser/browser-manager-viewport.ts | 219 + .../browser/browser-manager-visibility.ts | 256 ++ src/main/browser/browser-manager.ts | 2705 +----------- .../runtime-home-service-auth-core.ts | 174 + .../runtime-home-service-auth-provenance.ts | 224 + ...runtime-home-service-auth-sync-identity.ts | 37 + .../runtime-home-service-auth-sync.ts | 292 ++ .../runtime-home-service-home-routing.ts | 297 ++ .../runtime-home-service-launch.ts | 154 + .../runtime-home-service-legacy-migration.ts | 204 + .../runtime-home-service-managed-home.ts | 276 ++ .../runtime-home-service-paths.ts | 187 + .../runtime-home-service-state.ts | 264 ++ .../runtime-home-service-sync.ts | 154 + .../runtime-home-service-types.ts | 62 + .../runtime-home-service-wsl-core.ts | 119 + .../runtime-home-service-wsl.ts | 167 + .../codex-accounts/runtime-home-service.ts | 2274 +--------- .../gpu-crash-diagnostics.test.ts | 20 +- .../gpu-crash-fallback-field-sessions.test.ts | 11 +- .../gpu-fallback-recovered-launch.test.ts | 13 +- ...omation-dispatcher-source-boundary.test.ts | 2 +- src/main/index.ts | 3836 +---------------- src/main/ipc/filesystem.ts | 2463 +---------- .../filesystem-download-handlers.ts | 210 + .../ipc/filesystem/filesystem-file-helpers.ts | 173 + ...lesystem-git-commit-generation-handlers.ts | 157 + .../filesystem-git-commit-handlers.ts | 42 + .../filesystem-git-diff-handlers.ts | 126 + .../filesystem-git-index-handlers.ts | 173 + ...filesystem-git-model-discovery-handlers.ts | 82 + ...em-git-pull-request-generation-handlers.ts | 226 + .../filesystem-git-remote-handlers.ts | 322 ++ .../filesystem-git-status-handlers.ts | 307 ++ .../filesystem/filesystem-git-url-handlers.ts | 54 + .../filesystem/filesystem-handler-context.ts | 75 + .../filesystem/filesystem-read-handlers.ts | 170 + .../filesystem/filesystem-search-handlers.ts | 234 + .../filesystem/filesystem-worktree-helpers.ts | 185 + .../filesystem/filesystem-write-handlers.ts | 91 + src/main/macos-press-and-hold-default.test.ts | 12 +- ...uit-teardown-agent-browser-daemons.test.ts | 4 +- src/main/rate-limits/service.ts | 2188 +--------- .../service/service-account-refresh.ts | 182 + .../service/service-configuration.ts | 139 + .../service/service-fetch-control.ts | 80 + .../service/service-fetch-policy.ts | 139 + .../service/service-fetch-queue.ts | 245 ++ .../service/service-fetch-targets.ts | 171 + .../service/service-full-cycle-application.ts | 215 + .../service/service-full-cycle-preparation.ts | 204 + .../service/service-inactive-accounts.ts | 246 ++ .../rate-limits/service/service-polling.ts | 183 + .../service/service-provider-cycles.ts | 183 + .../service/service-result-policy.ts | 112 + src/main/rate-limits/service/service-state.ts | 165 + src/main/rate-limits/service/service-types.ts | 163 + .../rpc/methods/orchestration-ask-methods.ts | 168 + .../rpc/methods/orchestration-ask-remote.ts | 129 + .../rpc/methods/orchestration-check-direct.ts | 79 + .../methods/orchestration-check-methods.ts | 79 + .../rpc/methods/orchestration-check-run.ts | 251 ++ .../rpc/methods/orchestration-check-worker.ts | 192 + .../methods/orchestration-dispatch-methods.ts | 178 + .../methods/orchestration-message-methods.ts | 219 + .../methods/orchestration-reset-methods.ts | 27 + .../rpc/methods/orchestration-routing.ts | 135 + .../rpc/methods/orchestration-schemas.ts | 272 ++ .../orchestration-send-control-mail.ts | 83 + .../rpc/methods/orchestration-send-group.ts | 131 + .../rpc/methods/orchestration-send-methods.ts | 188 + .../orchestration-send-point-to-point.ts | 187 + .../rpc/methods/orchestration-send-remote.ts | 114 + src/main/runtime/rpc/methods/orchestration.ts | 2059 +-------- src/main/startup/branch-rename-hook.ts | 128 + src/main/startup/codex-launch-preparation.ts | 95 + .../startup/codex-session-resume-launch.ts | 98 + src/main/startup/configure-process.test.ts | 20 +- .../startup/desktop-startup-ordering.test.ts | 371 +- src/main/startup/gpu-lifecycle.ts | 177 + .../headless-pty-hydration-ordering.test.ts | 9 +- .../host-port-bootstrap-wiring.test.ts | 47 +- .../startup/main-process-account-services.ts | 151 + src/main/startup/main-process-automations.ts | 99 + src/main/startup/main-process-i18n-menu.ts | 94 + .../startup/main-process-ipc-bootstrap.ts | 47 + src/main/startup/main-process-observers.ts | 132 + src/main/startup/main-process-plugins.ts | 158 + src/main/startup/main-process-preflight.ts | 323 ++ src/main/startup/main-process-pty-startup.ts | 195 + src/main/startup/main-process-quit.ts | 217 + .../startup/main-process-ready-foundation.ts | 235 + .../startup/main-process-ready-runtime.ts | 134 + src/main/startup/main-process-ready.ts | 17 + .../startup/main-process-runtime-launch.ts | 283 ++ .../startup/main-process-runtime-service.ts | 136 + src/main/startup/main-process-serve.ts | 131 + src/main/startup/main-process-state.ts | 127 + src/main/startup/main-window-actions.ts | 169 + src/main/startup/main-window-agent-status.ts | 138 + src/main/startup/main-window-controller.ts | 197 + src/main/startup/main-window-core-services.ts | 130 + .../startup/main-window-lifecycle-flags.ts | 53 + .../startup/main-window-service-readiness.ts | 42 + ...-protection-report-deferral-wiring.test.ts | 25 +- .../serve-desktop-activation-wiring.test.ts | 75 +- ...serve-mode-argv-cli-redirect-order.test.ts | 11 +- ...single-instance-lock-exit.electron.test.ts | 9 +- ...single-instance-lock-headless-exit.test.ts | 18 +- src/main/startup/synthetic-title-runtime.ts | 187 + src/main/startup/web-contents-timed-flag.ts | 31 + src/main/updater.ts | 2357 +--------- src/main/updater/updater-build-selection.ts | 152 + src/main/updater/updater-check-failure.ts | 118 + src/main/updater/updater-check-state.ts | 298 ++ src/main/updater/updater-download-install.ts | 93 + src/main/updater/updater-install-execution.ts | 228 + src/main/updater/updater-install-support.ts | 174 + src/main/updater/updater-menu-checks.ts | 100 + src/main/updater/updater-nudge.ts | 86 + src/main/updater/updater-package-recovery.ts | 274 ++ src/main/updater/updater-release-feed.ts | 270 ++ src/main/updater/updater-remote-status.ts | 102 + src/main/updater/updater-scheduling.ts | 121 + src/main/updater/updater-setup.ts | 251 ++ src/main/updater/updater-state.ts | 127 + src/main/updater/updater-status.ts | 184 + src/main/updater/updater-types.ts | 2 + 210 files changed, 29470 insertions(+), 26543 deletions(-) create mode 100644 src/main/agent-hooks/server/server-authority-aliases.ts create mode 100644 src/main/agent-hooks/server/server-authority-evidence.ts create mode 100644 src/main/agent-hooks/server/server-authority-fences.ts create mode 100644 src/main/agent-hooks/server/server-claude-status-rules.ts create mode 100644 src/main/agent-hooks/server/server-cleanup.ts create mode 100644 src/main/agent-hooks/server/server-constants.ts create mode 100644 src/main/agent-hooks/server/server-hydration.ts create mode 100644 src/main/agent-hooks/server/server-ingest-normalization.ts create mode 100644 src/main/agent-hooks/server/server-ingest-remote.ts create mode 100644 src/main/agent-hooks/server/server-ingest-terminal.ts create mode 100644 src/main/agent-hooks/server/server-lifecycle.ts create mode 100644 src/main/agent-hooks/server/server-listeners.ts create mode 100644 src/main/agent-hooks/server/server-persistence-validation.ts create mode 100644 src/main/agent-hooks/server/server-persistence.ts create mode 100644 src/main/agent-hooks/server/server-reaping.ts create mode 100644 src/main/agent-hooks/server/server-runtime-env.ts create mode 100644 src/main/agent-hooks/server/server-state.ts create mode 100644 src/main/agent-hooks/server/server-status-application.ts create mode 100644 src/main/agent-hooks/server/server-status-disposition.ts create mode 100644 src/main/agent-hooks/server/server-status-identity.ts create mode 100644 src/main/agent-hooks/server/server-status-inference.ts create mode 100644 src/main/agent-hooks/server/server-status-retries.ts create mode 100644 src/main/agent-hooks/server/server-status-update.ts create mode 100644 src/main/agent-hooks/server/server-tab-cleanup.ts create mode 100644 src/main/agent-hooks/server/server-transport-rules.ts create mode 100644 src/main/agent-hooks/server/server-types.ts create mode 100644 src/main/browser/agent-browser-bridge-capture-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-core-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-execution.ts create mode 100644 src/main/browser/agent-browser-bridge-input-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-input.ts create mode 100644 src/main/browser/agent-browser-bridge-interaction-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-lifecycle.ts create mode 100644 src/main/browser/agent-browser-bridge-mouse-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-mouse.ts create mode 100644 src/main/browser/agent-browser-bridge-process.ts create mode 100644 src/main/browser/agent-browser-bridge-queue.ts create mode 100644 src/main/browser/agent-browser-bridge-raw-process.ts create mode 100644 src/main/browser/agent-browser-bridge-result.ts create mode 100644 src/main/browser/agent-browser-bridge-shutdown.ts create mode 100644 src/main/browser/agent-browser-bridge-state-commands.ts create mode 100644 src/main/browser/agent-browser-bridge-state.ts create mode 100644 src/main/browser/agent-browser-bridge-tabs.ts create mode 100644 src/main/browser/agent-browser-bridge-types.ts create mode 100644 src/main/browser/agent-browser-bridge-utility-commands.ts create mode 100644 src/main/browser/browser-cookie-chromium-finalize.ts create mode 100644 src/main/browser/browser-cookie-chromium-import.ts create mode 100644 src/main/browser/browser-cookie-chromium-prepare.ts create mode 100644 src/main/browser/browser-cookie-chromium-scan.ts create mode 100644 src/main/browser/browser-cookie-chromium-types.ts create mode 100644 src/main/browser/browser-cookie-decryption.ts create mode 100644 src/main/browser/browser-cookie-detection-types.ts create mode 100644 src/main/browser/browser-cookie-detection.ts create mode 100644 src/main/browser/browser-cookie-firefox-import.ts create mode 100644 src/main/browser/browser-cookie-import-diagnostics.ts create mode 100644 src/main/browser/browser-cookie-import-pipeline.ts create mode 100644 src/main/browser/browser-cookie-key.ts create mode 100644 src/main/browser/browser-cookie-safari-import.ts create mode 100644 src/main/browser/browser-cookie-safari-parser.ts create mode 100644 src/main/browser/browser-cookie-sqlite.ts create mode 100644 src/main/browser/browser-cookie-validation.ts create mode 100644 src/main/browser/browser-manager-bindings.ts create mode 100644 src/main/browser/browser-manager-download-creation.ts create mode 100644 src/main/browser/browser-manager-download-lifecycle.ts create mode 100644 src/main/browser/browser-manager-event-forwarding.ts create mode 100644 src/main/browser/browser-manager-final.ts create mode 100644 src/main/browser/browser-manager-grab.ts create mode 100644 src/main/browser/browser-manager-guest-cleanup.ts create mode 100644 src/main/browser/browser-manager-guest-navigation-policy.ts create mode 100644 src/main/browser/browser-manager-guest-policy.ts create mode 100644 src/main/browser/browser-manager-guest-popup-policy.ts create mode 100644 src/main/browser/browser-manager-navigation.ts create mode 100644 src/main/browser/browser-manager-queries.ts create mode 100644 src/main/browser/browser-manager-registration.ts create mode 100644 src/main/browser/browser-manager-state.ts create mode 100644 src/main/browser/browser-manager-types.ts create mode 100644 src/main/browser/browser-manager-viewport-scroll-state.ts create mode 100644 src/main/browser/browser-manager-viewport.ts create mode 100644 src/main/browser/browser-manager-visibility.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-core.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-provenance.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts create mode 100644 src/main/codex-accounts/runtime-home-service-auth-sync.ts create mode 100644 src/main/codex-accounts/runtime-home-service-home-routing.ts create mode 100644 src/main/codex-accounts/runtime-home-service-launch.ts create mode 100644 src/main/codex-accounts/runtime-home-service-legacy-migration.ts create mode 100644 src/main/codex-accounts/runtime-home-service-managed-home.ts create mode 100644 src/main/codex-accounts/runtime-home-service-paths.ts create mode 100644 src/main/codex-accounts/runtime-home-service-state.ts create mode 100644 src/main/codex-accounts/runtime-home-service-sync.ts create mode 100644 src/main/codex-accounts/runtime-home-service-types.ts create mode 100644 src/main/codex-accounts/runtime-home-service-wsl-core.ts create mode 100644 src/main/codex-accounts/runtime-home-service-wsl.ts create mode 100644 src/main/ipc/filesystem/filesystem-download-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-file-helpers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-commit-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-diff-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-index-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-remote-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-status-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-git-url-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-handler-context.ts create mode 100644 src/main/ipc/filesystem/filesystem-read-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-search-handlers.ts create mode 100644 src/main/ipc/filesystem/filesystem-worktree-helpers.ts create mode 100644 src/main/ipc/filesystem/filesystem-write-handlers.ts create mode 100644 src/main/rate-limits/service/service-account-refresh.ts create mode 100644 src/main/rate-limits/service/service-configuration.ts create mode 100644 src/main/rate-limits/service/service-fetch-control.ts create mode 100644 src/main/rate-limits/service/service-fetch-policy.ts create mode 100644 src/main/rate-limits/service/service-fetch-queue.ts create mode 100644 src/main/rate-limits/service/service-fetch-targets.ts create mode 100644 src/main/rate-limits/service/service-full-cycle-application.ts create mode 100644 src/main/rate-limits/service/service-full-cycle-preparation.ts create mode 100644 src/main/rate-limits/service/service-inactive-accounts.ts create mode 100644 src/main/rate-limits/service/service-polling.ts create mode 100644 src/main/rate-limits/service/service-provider-cycles.ts create mode 100644 src/main/rate-limits/service/service-result-policy.ts create mode 100644 src/main/rate-limits/service/service-state.ts create mode 100644 src/main/rate-limits/service/service-types.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-ask-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-ask-remote.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-direct.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-run.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-check-worker.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-message-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-reset-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-routing.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-schemas.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-control-mail.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-group.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-methods.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-send-remote.ts create mode 100644 src/main/startup/branch-rename-hook.ts create mode 100644 src/main/startup/codex-launch-preparation.ts create mode 100644 src/main/startup/codex-session-resume-launch.ts create mode 100644 src/main/startup/gpu-lifecycle.ts create mode 100644 src/main/startup/main-process-account-services.ts create mode 100644 src/main/startup/main-process-automations.ts create mode 100644 src/main/startup/main-process-i18n-menu.ts create mode 100644 src/main/startup/main-process-ipc-bootstrap.ts create mode 100644 src/main/startup/main-process-observers.ts create mode 100644 src/main/startup/main-process-plugins.ts create mode 100644 src/main/startup/main-process-preflight.ts create mode 100644 src/main/startup/main-process-pty-startup.ts create mode 100644 src/main/startup/main-process-quit.ts create mode 100644 src/main/startup/main-process-ready-foundation.ts create mode 100644 src/main/startup/main-process-ready-runtime.ts create mode 100644 src/main/startup/main-process-ready.ts create mode 100644 src/main/startup/main-process-runtime-launch.ts create mode 100644 src/main/startup/main-process-runtime-service.ts create mode 100644 src/main/startup/main-process-serve.ts create mode 100644 src/main/startup/main-process-state.ts create mode 100644 src/main/startup/main-window-actions.ts create mode 100644 src/main/startup/main-window-agent-status.ts create mode 100644 src/main/startup/main-window-controller.ts create mode 100644 src/main/startup/main-window-core-services.ts create mode 100644 src/main/startup/main-window-lifecycle-flags.ts create mode 100644 src/main/startup/main-window-service-readiness.ts create mode 100644 src/main/startup/synthetic-title-runtime.ts create mode 100644 src/main/startup/web-contents-timed-flag.ts create mode 100644 src/main/updater/updater-build-selection.ts create mode 100644 src/main/updater/updater-check-failure.ts create mode 100644 src/main/updater/updater-check-state.ts create mode 100644 src/main/updater/updater-download-install.ts create mode 100644 src/main/updater/updater-install-execution.ts create mode 100644 src/main/updater/updater-install-support.ts create mode 100644 src/main/updater/updater-menu-checks.ts create mode 100644 src/main/updater/updater-nudge.ts create mode 100644 src/main/updater/updater-package-recovery.ts create mode 100644 src/main/updater/updater-release-feed.ts create mode 100644 src/main/updater/updater-remote-status.ts create mode 100644 src/main/updater/updater-scheduling.ts create mode 100644 src/main/updater/updater-setup.ts create mode 100644 src/main/updater/updater-state.ts create mode 100644 src/main/updater/updater-status.ts create mode 100644 src/main/updater/updater-types.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 49ce9707ecf..4bc75a5a132 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,21 +2,11 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline src/main/agent-hooks/server.ts -inline src/main/browser/agent-browser-bridge.ts -inline src/main/browser/browser-cookie-import.ts -inline src/main/browser/browser-manager.ts -inline src/main/codex-accounts/runtime-home-service.ts -inline src/main/index.ts -inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/rate-limits/service.ts -inline src/main/runtime/rpc/methods/orchestration.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts -inline src/main/updater.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts mobile-config app/h/*/files/*.tsx diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 1abcf84ef64..4213d360a41 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -57,8 +57,11 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', // both ends of the contract are only readable statically. Without this leg the rewrite could // emit a name nothing reads and every behavioural assertion above would still pass. const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = mainSource.indexOf('function getServeOptions(') + const mainSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const start = mainSource.indexOf('export function getServeOptions(') // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. expect(start).toBeGreaterThanOrEqual(0) const end = mainSource.indexOf('\n}', start) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 11c1088315f..f03484f14f9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,3526 +1,30 @@ -/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron; parsing that drifts back into this file stops applying to SSH panes. -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { createHash, randomBytes, randomUUID } from 'node:crypto' -import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { - ORCA_HOOK_PROTOCOL_VERSION, - ORCA_HOOK_RAW_JSON_TRANSPORT -} from '../../shared/agent-hook-types' -import { - clearAllListenerCaches, - clearPaneCacheState, - paneHasStateClaims, - createHookListenerState, - movePaneCacheState, - type HookListenerState -} from '../../shared/agent-hook-listener/listener-state' -import { - clearClaudeAnsweredQuestionWait, - markClaudeLeadTurnInterrupted, - reapRestoredClaudeSubagentsForDeadPane, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots -} from '../../shared/agent-hook-listener/providers/claude-roster-state' -import { - getEndpointFileName, - writeEndpointFile -} from '../../shared/agent-hook-listener/endpoint-publication' -import { - hasCodexTranscriptSubagents, - markCodexLeadTurnInterrupted, - reconcileRemoteCodexState, - seedCodexStateFromSnapshot -} from '../../shared/agent-hook-listener/providers/codex-state' -import { - hasPendingAgentResultText, - preparePendingGrokResultDiscovery -} from '../../shared/agent-hook-listener/grok-result-discovery' -import { - HOOK_REQUEST_SLOWLORIS_MS, - MAX_PANE_KEY_LEN, - normalizeClaudePromptId, - warnOnHookEnvOrVersionMismatch -} from '../../shared/agent-hook-listener/listener-limits' -import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +// This main-process adapter keeps listener internals in shared/ so the relay can host the same pipeline without Electron. +import { clearAllListenerCaches } from '../../shared/agent-hook-listener/listener-state' import { normalizeHookPayload } from '../../shared/agent-hook-listener' -import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' -import { - parseFormEncodedBody, - readRequestBody -} from '../../shared/agent-hook-listener/request-body' -import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import { - canAcceptClaudeCompactCompletion, - isClaudeCompactCompletionConsumed, - markClaudeCompactCompletionConsumed, - resolveLegacyCompactTrigger -} from '../../shared/claude-compact-completion' -import { - createHookTransportInterferenceTracker, - describeHookTransportInterference, - isHookRequestTruncatedError, - type HookTransportInterferenceReport -} from '../../shared/agent-hook-transport-interference' -import { - claudeTeammateIdMatchesName, - claudeRosterHasRestoredSnapshotSubagent, - claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots -} from '../../shared/claude-subagent-roster' -import { - isAgentHookSource, - restoreShedStatusFields, - type AgentHookSource -} from '../../shared/agent-hook-relay' -import { - CLAUDE_STATUSLINE_PATHNAME, - parseClaudeStatusLineBody, - type ClaudeStatusLineRateLimits -} from '../../shared/claude-statusline-rate-limits' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusClearIpcPayload, - type AgentStatusIpcPayload, - type AgentType, - type AgentStatusState, - type ParsedAgentStatusPayload, - normalizeAgentStatusPayload -} from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' -import { - AgentStatusObservationSequencer, - createAgentStatusAuthorityId, - type AgentStatusObservation, - type AgentStatusObservationOrigin -} from '../../shared/agent-status-observation' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../shared/agent-status-identity' -import { - isAgentInterruptInputIntent, - type AgentInterruptInferenceRequest -} from '../../shared/agent-interrupt-intent' -import { - isAskUserQuestionTool, - type AgentQuestionAnsweredInferenceRequest -} from '../../shared/agent-question-answered-intent' -import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../shared/pane-key-alias' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import type { LegacyPaneKeyAliasEntry } from '../../shared/persisted-state-types' -import { - getAgentResumeArgv, - normalizeAgentProviderSession, - type AgentProviderSessionMetadata -} from '../../shared/agent-session-resume' -import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' -import { - buildSpoolHookBody, - drainAgentHookSpool, - launchTokenHash, - type SpoolRecord -} from '../../shared/agent-hook-spool' -import { CodexSubagentPollScheduler } from '../../shared/codex-subagent-poll-scheduler' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { AgentHookServerLifecycle } from './server/server-lifecycle' +import { isValidPaneKey } from './server/server-status-identity' +export type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload +} from './server/server-types' export type { AgentHookSource } - -// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). -type EnrichedAgentHookEventPayload = AgentHookEventPayload & { - receivedAt: number - stateStartedAt: number - /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ - observation?: AgentStatusObservation - /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ - restoredUnconfirmed?: true - /** User-hidden resume identity retained solely for destructive liveness checks. */ - retainedForLiveness?: true - /** Persisted proof that a lead boundary was held working only by child agents. */ - claudeLeadBoundaryChildOnly?: true -} - -type NormalizedLocalHook = { - event: AgentHookEventPayload | null - onAccepted?: () => void -} - -type PersistedAgentHookEventPayload = Omit< - EnrichedAgentHookEventPayload, - | 'claudeRunningNonAgentTask' - | 'launchToken' - | 'promptInteractionKey' - | 'restoredUnconfirmed' - // Why: revision counters are in-memory and the authority id is regenerated per process, so - // a stored observation could only rehydrate as a stale ordering claim from a dead authority. - | 'observation' -> & { - launchTokenHash?: string -} - -type PersistedAgentHookAuthorityCommitment = { - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -} - -export type AgentHookStatusChangeEntry = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} - -export type AgentHookProviderSessionIdentity = { - paneKey: string - sessionId: string - transcriptPath?: string - worktreeId?: string -} - -export type AgentHookAuthorityEvidence = Readonly<{ - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -}> - -export type AgentHookAuthorityAttestation = Readonly<{ - paneKey: string - source: 'current_hook' | 'hydrated_commitment' -}> - -type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void -type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void -type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void -type StatusDropListener = (paneKey: string) => void -type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void -type PaneKeyAliasEntry = { - stablePaneKey: string - ptyId: string | null - updatedAt: number - authorityVerified: boolean -} -type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } -/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ -type RetiredPaneFence = { - paneKeys: readonly string[] - aliases: readonly RetiredPaneAlias[] -} - -// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. -const LAST_STATUS_FILE_NAME = 'last-status.json' -const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 -const ASSISTANT_MESSAGE_RETRY_MS = 50 -const CODEX_SUBAGENT_POLL_MS = 1_000 -const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 - -type CodexSubagentPoll = { - source: AgentHookSource - body: unknown - original: EnrichedAgentHookEventPayload -} - -// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). -const LAST_STATUS_FILE_VERSION = 2 - -// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. -const STATUS_PERSIST_DEBOUNCE_MS = 250 -const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) -const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) - -// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. -const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 - -// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. -export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 -export const PANE_KEY_ALIASES_MAX = 1024 -export const RETIRED_PANE_FENCES_MAX = 1024 - -type LastStatusFile = { - version: number - entries: Record - authorityCommitments?: Record -} - -type AgentPromptSentDedupeEntry = { - agentKind: AgentKind - promptHash: string - promptInteractionKey?: string -} - -function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { - const normalized = agentType?.trim().toLowerCase() - if (!normalized || normalized === 'unknown') { - return 'other' - } - if (normalized === 'claude') { - return 'claude-code' - } - return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) - ? (normalized as AgentKind) - : 'other' -} - -function equivalentInterruptAgentType( - actual: AgentType | undefined, - baseline: AgentType | undefined -): boolean { - const normalizedActual = actual === 'unknown' ? undefined : actual - const normalizedBaseline = baseline === 'unknown' ? undefined : baseline - return normalizedActual === normalizedBaseline -} - -// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. -export function isValidPaneKey(value: unknown): value is string { - return ( - typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null - ) -} - -function dropHydratedIdleClaudeSubagents( - payload: ParsedAgentStatusPayload -): ParsedAgentStatusPayload { - if ( - payload.agentType !== 'claude' || - !payload.subagents?.some((subagent) => subagent.state === 'idle') - ) { - return payload - } - const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') - // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. - return { - ...payload, - subagents: activeSubagents.length > 0 ? activeSubagents : undefined - } -} - -// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. -function isValidPiProviderSessionOnly( - providerSession: AgentProviderSessionMetadata | undefined, - agentType: AgentType | undefined -): boolean { - return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) -} - -function sanitizeHydratedEntry( - paneKey: string, - rawEntry: unknown -): EnrichedAgentHookEventPayload | null { - const parsedPaneKey = parsePaneKey(paneKey) - if (!parsedPaneKey) { - return null - } - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - if (record.paneKey !== paneKey) { - return null - } - const tabId = record.tabId - if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { - return null - } - // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. - if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { - return null - } - const worktreeId = record.worktreeId - if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { - return null - } - const receivedAt = record.receivedAt - if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { - return null - } - const stateStartedAt = record.stateStartedAt - if ( - typeof stateStartedAt !== 'number' || - !Number.isFinite(stateStartedAt) || - stateStartedAt <= 0 - ) { - return null - } - // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. - const connectionIdRaw = record.connectionId - let connectionId: string | null - if (connectionIdRaw === null || connectionIdRaw === undefined) { - connectionId = null - } else if (typeof connectionIdRaw === 'string') { - connectionId = connectionIdRaw - } else { - return null - } - const payload = normalizeAgentStatusPayload(record.payload) - if (!payload) { - return null - } - const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined - const providerSessionOnly = record.providerSessionOnly === true - const retainedForLiveness = record.retainedForLiveness === true - const validRetainedIdentity = Boolean( - retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' - ) - if ( - providerSessionOnly && - !isValidPiProviderSessionOnly(providerSession, payload.agentType) && - !validRetainedIdentity - ) { - return null - } - const source = isAgentHookSource(record.source) ? record.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') - ? record.compactTrigger - : undefined - return { - paneKey, - source, - tabId: typeof tabId === 'string' ? tabId : undefined, - worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, - connectionId, - hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, - hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, - providerPromptId, - compactTrigger, - toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, - toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, - teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, - toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, - providerSession, - providerSessionOnly: providerSessionOnly ? true : undefined, - retainedForLiveness: retainedForLiveness ? true : undefined, - payload, - receivedAt, - stateStartedAt - } -} - -function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { - return launchTokenHash - } - const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' - return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null -} - -function sanitizePersistedAuthorityCommitment( - paneKey: string, - value: unknown -): AgentHookAuthorityEvidence | null { - if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { - return null - } - const record = value as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - const connectionId = record.connectionId - const observedAt = record.observedAt - if ( - !/^[a-f0-9]{64}$/.test(launchTokenHash) || - (connectionId !== null && typeof connectionId !== 'string') || - typeof observedAt !== 'number' || - !Number.isFinite(observedAt) - ) { - return null - } - return Object.freeze({ - paneKey, - launchTokenHash, - connectionId, - ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), - ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), - observedAt - }) -} - -function authorityCommitmentsMatch( - left: AgentHookAuthorityEvidence, - right: AgentHookAuthorityEvidence -): boolean { - return ( - left.paneKey === right.paneKey && - left.launchTokenHash === right.launchTokenHash && - left.connectionId === right.connectionId && - left.tabId === right.tabId && - left.worktreeId === right.worktreeId - ) -} - -function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { - return { - paneKey: entry.paneKey, - ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), - tabId: entry.tabId, - worktreeId: entry.worktreeId, - connectionId: entry.connectionId, - receivedAt: entry.receivedAt, - stateStartedAt: entry.stateStartedAt, - ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), - ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), - ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), - ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(entry.observation ? { observation: entry.observation } : {}), - ...entry.payload - } -} - -function trackEmptyPaneKeyHook(body: unknown): void { - if (typeof body !== 'object' || body === null) { - return - } - const paneKey = (body as Record).paneKey - if (typeof paneKey === 'string' && paneKey.trim().length > 0) { - return - } - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) -} - -function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { - if (next.payload.state !== 'working') { - return false - } - if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { - return false - } - // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. - return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) -} - -function paneCacheKeyTabId(key: string): string | null { - const paneKey = key.split('\0', 1)[0] ?? key - return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null -} - -function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { - return paneCacheKeyTabId(key) === tabId -} - -function attachClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { - const establishesBoundary = - next.payload.agentType === 'claude' && - (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && - !next.toolAgentId && - next.payload.state === 'working' && - next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - next.claudeRunningNonAgentTask === false - const carriesBoundary = - previous?.claudeLeadBoundaryChildOnly === true && - next.payload.agentType === 'claude' && - next.claudeRunningNonAgentTask === false && - (next.toolAgentId !== undefined || - next.hookEventName === 'SubagentStart' || - next.hookEventName === 'SubagentStop' || - next.hookEventName === 'TeammateIdle') - return establishesBoundary || carriesBoundary - ? { ...next, claudeLeadBoundaryChildOnly: true } - : next -} - -function invalidateClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): EnrichedAgentHookEventPayload | undefined { - if ( - previous?.claudeLeadBoundaryChildOnly !== true || - attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true - ) { - return previous - } - const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous - return withoutBoundary -} - -function shouldKeepClaudePermissionVisible( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if (previous?.restoredUnconfirmed) { - return false - } - if ( - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'waiting' || - previous.hookEventName !== 'PermissionRequest' || - next.payload.agentType !== 'claude' || - next.payload.state !== 'working' - ) { - return false - } - if (next.hasExplicitPrompt === true) { - return false - } - if (isClaudePermissionOwningChildEnding(previous, next)) { - return false - } - if (isClaudePermissionResumingApprovedTool(previous, next)) { - return false - } - // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. - if (isAskUserQuestionTool(previous.payload.toolName)) { - return false - } - return true -} - -function isClaudePermissionOwningChildEnding( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const ownerId = previous.toolAgentId?.trim() - if (!ownerId) { - return false - } - if (next.hookEventName === 'SubagentStop') { - return ownerId === next.toolAgentId?.trim() - } - return ( - next.hookEventName === 'TeammateIdle' && - next.teammateName !== undefined && - claudeTeammateIdMatchesName(ownerId, next.teammateName) - ) -} - -function isClaudePermissionResumingApprovedTool( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const previousToolUseId = previous.toolUseId?.trim() || undefined - const nextToolUseId = next.toolUseId?.trim() || undefined - const previousAgentId = previous.toolAgentId?.trim() || undefined - const nextAgentId = next.toolAgentId?.trim() || undefined - const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined - const previousAgentType = previous.toolAgentType?.trim() || undefined - const nextAgentType = next.toolAgentType?.trim() || undefined - const hasMatchingConcreteAgentId = - previousAgentId !== undefined && previousAgentId === nextAgentId - const hasSameExplicitAgentType = - !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType - const sameToolName = - previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownInputFromConcreteAgent = - hasMatchingConcreteAgentId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - const hasMatchingToolUseId = - previousToolUseId !== undefined && previousToolUseId === nextToolUseId - const hasConflictingToolUseId = - previousToolUseId !== undefined && - nextToolUseId !== undefined && - previousToolUseId !== nextToolUseId - const sameUnknownInputFromToolUseId = - hasMatchingToolUseId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - - return ( - (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && - nextToolUseId !== undefined && - !hasConflictingToolUseId && - // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. - (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && - sameToolName && - (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) - ) -} - -function shouldInheritClaudeToolUseIdForPermission( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if ( - previous?.restoredUnconfirmed || - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'working' || - previous.hookEventName !== 'PreToolUse' || - typeof previous.toolUseId !== 'string' || - previous.toolUseId.trim().length === 0 || - next.payload.agentType !== 'claude' || - next.payload.state !== 'waiting' || - next.hookEventName !== 'PermissionRequest' || - next.toolUseId !== undefined - ) { - return false - } - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownToolInput = - previous.payload.toolInput === undefined && next.payload.toolInput === undefined - if ( - previous.toolAgentId !== next.toolAgentId || - previous.toolAgentType !== next.toolAgentType || - previous.payload.toolName === undefined || - previous.payload.toolName !== next.payload.toolName || - (!sameKnownToolInput && !sameUnknownToolInput) - ) { - return false - } - return true -} - -function attachClaudePermissionToolUseId( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload { - const inheritedToolUseId = previous?.toolUseId - if ( - !shouldInheritClaudeToolUseIdForPermission(previous, next) || - typeof inheritedToolUseId !== 'string' - ) { - return next - } - return { - ...next, - // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. - toolUseId: inheritedToolUseId - } -} - -export class AgentHookServer { - private server: ReturnType | null = null - private port = 0 - private token = '' - // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. - private env = 'production' - private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null - private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null - private onPaneStatusCleared: PaneStatusClearListener | null = null - private paneStatusClearListeners = new Set() - private statusDropListeners = new Set() - private statusChangeListeners = new Set() - private providerSessionChangeListeners = new Set() - // Why: setListener is a single slot owned by the main-window fanout; the - // plugin event bus (and future consumers) need an additive subscription - // that also works in headless serve, where no window listener exists. - private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() - // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). - private endpointDir: string | null = null - private endpointFilePathCache: string | null = null - private endpointFileWritten = false - // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. - private state: HookListenerState = createHookListenerState() - private onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = null - private transportInterference = createHookTransportInterferenceTracker((report) => { - console.warn(describeHookTransportInterference(report)) - this.onTransportInterference?.(report) - }) - // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. - private runtimeObservedStatusPaneKeys = new Set() - private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) - private hydratedLaunchTokenHashByPaneKey = new Map() - private persistedAuthorityCommitmentsByPaneKey = new Map() - private revokedHydratedAuthorityCommitments = new WeakSet() - private currentAuthorityObservations = new Map() - private legacyPaneKeyAliases = new Map() - // Why: indexed by every key the retirement fenced, so a re-attach on any of them - // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps - // it mirrors; an evicted record simply degrades to lifting the key it was handed. - private retiredPaneFencesByKey = new Map() - private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null - // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. - private lastStatusFilePath: string | null = null - // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. - private statusPersistTimer: ReturnType | null = null - private assistantMessageRetryTimers = new Map>() - private codexSubagentPollScheduler = new CodexSubagentPollScheduler( - CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) - ) - private promptSentDedupeByPaneKey = new Map() - private activeHookTurnCompletedAtByPaneKey = new Map() - private promptSentHashSalt = randomBytes(16).toString('hex') - private closedAgentStatusTabIds = new Set() - private closedAgentStatusPaneKeys = new Set() - private restartedStatusLaunchTokenHashByPaneKey = new Map() - private connectionTimestampWatermarkById = new Map() - // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. - private lastWrittenJson: string | null = null - // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own - // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. - private readonly observations = new AgentStatusObservationSequencer( - createAgentStatusAuthorityId('main-agent-hooks') - ) - - /** - * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). - * Why: the listener fails open on every request error, so without this the only symptom is - * agent status quietly going stale — for every runtime at once, since they share this transport. - */ - setTransportInterferenceListener( - listener: ((report: HookTransportInterferenceReport) => void) | null - ): void { - this.onTransportInterference = listener - } - - setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { - this.onAgentStatus = listener - if (!listener) { - return - } - // Why: replay is best-effort per pane so one throwing listener can't starve the rest. - for (const payload of this.state.lastStatusByPaneKey.values()) { - try { - // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. - listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) - } catch (err) { - console.error('[agent-hooks] replay listener threw', err) - } - } - } - - // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. - setClaudeStatusLineListener( - listener: ((event: ClaudeStatusLineRateLimits) => void) | null - ): void { - this.onClaudeStatusLine = listener - } - - subscribeStatusChanges(listener: StatusChangeListener): () => void { - this.statusChangeListeners.add(listener) - return () => { - this.statusChangeListeners.delete(listener) - } - } - - subscribeProviderSessionChanges(listener: ProviderSessionChangeListener): () => void { - this.providerSessionChangeListeners.add(listener) - return () => { - this.providerSessionChangeListeners.delete(listener) - } - } - - /** Multi-subscriber tap on every enriched status change (no replay). */ - subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { - this.enrichedStatusListeners.add(listener) - return () => { - this.enrichedStatusListeners.delete(listener) - } - } - - /** Replay is durable evidence from a prior runtime, not a live observation. */ - private withdrawReplayObservation(paneKey: string): void { - if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { - this.notifyStatusChangeListeners() - } - } - - private ingestSpoolRecord(record: SpoolRecord): void { - if (!isAgentHookSource(record.source)) { - return - } - const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) - const normalized = this.normalizeLocalHookPayload(record.source, body) - if (!normalized.event) { - return - } - const replay = { ...normalized.event, isReplay: true as const } - const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { - source: record.source, - hookEventName: replay.hookEventName, - isReplay: true, - hasExplicitPrompt: replay.hasExplicitPrompt, - launchToken: replay.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay - if (statusDisposition === 'restart') { - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus(event, normalized.onAccepted) - if (event.payload.state !== 'done') { - this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) - } - } - - setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { - this.onPaneStatusCleared = listener - } - - /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` - * (a single slot the main window owns and drops on close) this survives window - * teardown and exists at all under headless serve, which never opens one. */ - subscribePaneStatusClear(listener: PaneStatusClearListener): () => void { - this.paneStatusClearListeners.add(listener) - return () => { - this.paneStatusClearListeners.delete(listener) - } - } - - /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user - * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned - * cleanup (synthetic spinners) still has to retire with the row it was driving. */ - subscribeStatusDrop(listener: StatusDropListener): () => void { - this.statusDropListeners.add(listener) - return () => { - this.statusDropListeners.delete(listener) - } - } - - private emitStatusDropped(paneKey: string): void { - for (const listener of this.statusDropListeners) { - // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. - try { - listener(paneKey) - } catch (err) { - console.error('[agent-hooks] status-drop listener threw', err) - } - } - } - - private emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { - this.onPaneStatusCleared?.(clear) - for (const listener of this.paneStatusClearListeners) { - // Why: callers are pane/connection teardown paths; one throwing subscriber must - // not strand the rest, matching every other fan-out here. - try { - listener(clear) - } catch (err) { - console.error('[agent-hooks] pane-status-clear listener threw', err) - } - } - } - - /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the - * dashboard catches up on hook events that fired during startup. */ - getStatusSnapshot(): AgentStatusIpcPayload[] { - return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => - toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) - ) - } - - /** Provider-session identities, including Pi's metadata-only rows. */ - getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { - return this.buildStatusChangeNotification().providerSessions - } - - getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { - const entry = this.state.lastStatusByPaneKey.get(paneKey) - return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] - } - - getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { - return this.hydratedAuthorityCommitments - } - - getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { - return Object.freeze( - Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) - ) - } - - attestCompatibilityAuthority(candidate: { - paneKey: string - launchTokenHash: string - connectionId: string | null - terminalProvenance: 'current_runtime' | 'restored' - }): AgentHookAuthorityAttestation | null { - const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) - const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => - entry.launchTokenHash === candidate.launchTokenHash && - entry.connectionId === candidate.connectionId - const commitments = this.hydratedAuthorityCommitments.filter( - (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) - ) - const current = Array.from(this.currentAuthorityObservations.values()) - const observations = current.filter(matchesCandidate) - const paneObservations = current.filter( - (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey - ) - const hasUniqueCurrentObservation = - observations.length === 1 && - paneObservations.length === 1 && - this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey - if (candidate.terminalProvenance === 'current_runtime') { - return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null - } - if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { - return null - } - if (observations.length === 0 && paneObservations.length === 0) { - return Object.freeze({ paneKey, source: 'hydrated_commitment' }) - } - if (!hasUniqueCurrentObservation) { - return null - } - return Object.freeze({ paneKey, source: 'current_hook' }) - } - - inferInterrupt(request: AgentInterruptInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - if (!isAgentInterruptInputIntent(request.intent)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - if (existing.providerSessionOnly) { - return false - } - // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - const agentType: AgentType | undefined = payload.agentType - // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. - if (agentType === 'droid' && request.intent === 'ctrl-c') { - return false - } - // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. - if ( - (agentType === 'opencode' || agentType === 'copilot') && - request.intent === 'plain-escape' && - request.inputCount !== 2 - ) { - return false - } - const dismissesClaudeQuestion = - agentType === 'claude' && - request.intent === 'plain-escape' && - payload.state === 'waiting' && - isAskUserQuestionTool(payload.toolName) - if (dismissesClaudeQuestion) { - return this.inferQuestionAnswered(request) - } - // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. - if ( - payload.state !== 'working' || - !equivalentInterruptAgentType(agentType, request.baselineAgentType) || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. - if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { - return false - } - // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. - if ( - agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) - ) { - return false - } - - // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. - if (agentType === 'claude') { - markClaudeLeadTurnInterrupted(this.state, existing.paneKey) - } - if (agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, existing.paneKey) - } - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: 'done', - prompt: payload.prompt, - agentType, - ...(payload.model ? { model: payload.model } : {}), - interrupted: true, - // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred interrupted agent status', { - paneKey: inferred.paneKey, - agentType, - intent: request.intent - }) - return true - } - - /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ - inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. - if ( - payload.agentType !== 'claude' || - payload.state !== 'waiting' || - !isAskUserQuestionTool(payload.toolName) - ) { - return false - } - if ( - payload.agentType !== request.baselineAgentType || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. - const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: restored.state, - ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), - prompt: payload.prompt, - agentType: payload.agentType, - ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}), - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred resolved question status', { - paneKey: inferred.paneKey, - state: inferred.payload.state - }) - return true - } - - getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { - return this.buildStatusChangeNotification().statuses - } - - private buildStatusChangeNotification(): { - statuses: AgentHookStatusChangeEntry[] - providerSessions: AgentHookProviderSessionIdentity[] - } { - const statuses: AgentHookStatusChangeEntry[] = [] - const providerSessions: AgentHookProviderSessionIdentity[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if (enriched.providerSession) { - providerSessions.push({ - paneKey, - sessionId: enriched.providerSession.id, - ...(enriched.providerSession.transcriptPath - ? { transcriptPath: enriched.providerSession.transcriptPath } - : {}), - ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) - }) - } - if (!enriched.providerSessionOnly) { - statuses.push({ - state: enriched.payload.state, - receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) - }) - } - } - return { statuses, providerSessions } - } - - private notifyStatusChangeListeners(): void { - if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { - return - } - const { statuses, providerSessions } = this.buildStatusChangeNotification() - for (const listener of this.statusChangeListeners) { - try { - listener(statuses) - } catch (err) { - console.error('[agent-hooks] status-change listener threw', err) - } - } - for (const listener of this.providerSessionChangeListeners) { - try { - listener(providerSessions) - } catch (err) { - console.error('[agent-hooks] provider-session listener threw', err) - } - } - } - - private markTabClosedForAgentStatus(tabId: string): void { - // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. - this.closedAgentStatusTabIds.delete(tabId) - this.closedAgentStatusTabIds.add(tabId) - while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - const oldest = this.closedAgentStatusTabIds.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusTabIds.delete(oldest) - } - } - - private getAgentStatusDisposition( - paneKey: string, - event?: { - source?: AgentHookSource - /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ - rawSource?: unknown - hookEventName?: string - isReplay?: boolean - hasExplicitPrompt?: boolean - launchToken?: string - } - ): 'accept' | 'restart' | 'suppress' { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneRetired = - this.closedAgentStatusPaneKeys.has(paneKey) || - this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (tabId && this.closedAgentStatusTabIds.has(tabId)) { - return 'suppress' - } - if (!paneRetired) { - const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) - // Why: deferred retirement lets a new process start in a still-authorized pane, so - // its tokened SessionStart re-fences; prompts recur, so a stale process would win. - if ( - event?.hookEventName === 'SessionStart' && - event.isReplay !== true && - tokenFence !== undefined - ) { - const startedLaunchToken = event.launchToken?.trim() - if (startedLaunchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(startedLaunchToken).digest('hex') - ) - return 'accept' - } - } - if (event && tokenFence) { - const launchToken = event.launchToken?.trim() - if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { - return 'suppress' - } - } - return 'accept' - } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live new-turn event proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). - // Why the classifier, not literals: only 5 of 18 sources name their boundary - // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. - // Why four branches: `source` collapses to undefined when an older relay omits the field, - // when a newer host sends an unknown string, and when the wire value is malformed. Only an - // unknown string is valid future-provider evidence. Unreachable from the local path, which - // 404s an unresolvable source. - const isNewTurn = - event?.source !== undefined - ? isNewTurnEvent(event.source, event.hookEventName) - : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 - ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and - // the costs are asymmetric — a stranded pane is invisible and permanent with no user - // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. - true - : event?.rawSource === undefined - ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it - // cannot revive a provider whose boundary event is named anything else. - event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' - : false - // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in - // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no - // SessionStart at all, so without this its retired panes never come back. - const freshOpenCodeFamilyPrompt = - (event?.source === 'opencode' || event?.source === 'mimo-code') && - event.hookEventName === 'MessagePart' && - event.hasExplicitPrompt === true - // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up - // status on that launch token stops a stale process reclaiming the pane's row without - // restoring retired orchestration authority. - if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.delete(ownerPaneKey) - const launchToken = event?.launchToken?.trim() - if (launchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(launchToken).digest('hex') - ) - } else { - this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) - } - return 'restart' - } - return 'suppress' - } - - // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric - // keys never parse as stable ones — resolve both forms so neither slips the tab check. - private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { - const tabId = - parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined - return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) - } - - private recordRetiredPaneFence( - paneKeys: ReadonlySet, - aliases: readonly RetiredPaneAlias[] - ): void { - const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } - for (const key of paneKeys) { - // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. - this.retiredPaneFencesByKey.delete(key) - this.retiredPaneFencesByKey.set(key, fence) - } - while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { - const oldest = this.retiredPaneFencesByKey.keys().next().value - if (oldest === undefined) { - break - } - this.retiredPaneFencesByKey.delete(oldest) - } - } - - private markPaneClosedForAgentStatus(paneKey: string): void { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.add(paneKey) - while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { - const oldest = this.closedAgentStatusPaneKeys.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusPaneKeys.delete(oldest) - } - } - - private attachStatusTiming( - payload: AgentHookEventPayload, - now = Date.now() - ): EnrichedAgentHookEventPayload { - const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const commandCodeNewTurn = - previous !== undefined && - isCommandCodeNewTurnWhileWorking({ - agentType: payload.payload.agentType, - previousState: previous.payload.state, - incomingState: payload.payload.state, - previousPrompt: previous.payload.prompt, - incomingPrompt: payload.payload.prompt, - hasExplicitPrompt: payload.hasExplicitPrompt, - previousPromptInteractionKey: previous.promptInteractionKey, - incomingPromptInteractionKey: payload.promptInteractionKey - }) - const stateStartedAt = - previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn - ? previous.stateStartedAt - : now - return { - ...payload, - receivedAt: now, - stateStartedAt - } - } - - private hashPromptForTelemetryDedupe(prompt: string): string { - return createHash('sha256') - .update(this.promptSentHashSalt) - .update('\0') - .update(prompt) - .digest('hex') - } - - private maybeTrackAgentPromptSent( - payload: AgentHookEventPayload, - previousStatus: EnrichedAgentHookEventPayload | undefined - ): void { - if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { - return - } - const prompt = payload.payload.prompt?.trim() ?? '' - if (prompt.length === 0) { - return - } - const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) - const promptHash = this.hashPromptForTelemetryDedupe(prompt) - const promptInteractionKey = - typeof payload.promptInteractionKey === 'string' && - payload.promptInteractionKey.trim().length > 0 - ? payload.promptInteractionKey.trim() - : undefined - const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) - const isCompletedTurnBoundary = - previousStatus?.payload.state === 'done' && payload.payload.state === 'working' - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey === promptInteractionKey && - (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) - ) { - return - } - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptHash === promptHash && - !( - previousStatus?.payload.state === 'done' && - payload.payload.state === 'done' && - previousDedupe.promptInteractionKey !== undefined && - promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey !== promptInteractionKey - ) && - !isCompletedTurnBoundary - ) { - return - } - this.promptSentDedupeByPaneKey.set(payload.paneKey, { - agentKind, - promptHash, - promptInteractionKey - }) - try { - // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. - track('agent_prompt_sent', { - agent_kind: agentKind, - launch_source: 'unknown', - request_kind: 'followup', - ...getCohortAtEmit() - }) - } catch (err) { - console.error('[agent-hooks] prompt-sent telemetry failed', err) - } - } - - /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet - * (STA-4293) — it is stamped here because every main-side ingress funnels through - * applyNormalizedStatus, so no origin can silently arrive untagged. */ - private stampObservation( - payload: AgentHookEventPayload, - origin: AgentStatusObservationOrigin, - observedAt: number - ): AgentStatusObservation { - return this.observations.observe(payload.paneKey, { - origin, - observedAt, - // Why: reuse the listener's own per-provider classifier; a second list of raw event-name - // literals here would strand the providers whose boundary event is named anything else. - boundary: - payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), - kind: payload.providerSessionOnly - ? 'identity-only' - : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the - // current state rather than announcing a change — neither is a fresh transition. - payload.isReplay === true || origin === 'osc' - ? 'snapshot' - : 'transition' - }) - } - - private applyNormalizedStatus( - payload: AgentHookEventPayload, - onAccepted?: () => void, - origin: AgentStatusObservationOrigin = 'hook' - ): EnrichedAgentHookEventPayload { - if (payload.hookEventName === 'UserPromptSubmit') { - // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. - this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) - } - let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) - : undefined - // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. - const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined - const now = Math.max( - Date.now(), - (connectionClearWatermark ?? -1) + 1, - (restoredStatusWatermark ?? -1) + 1 - ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) - } - if (payload.providerSessionOnly) { - // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. - onAccepted?.() - const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) - } - this.clearAssistantMessageRetry(enriched.paneKey) - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName - ? { - ...payload, - payload: reconcileRemoteCodexState( - this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, - previous?.payload - ) - } - : payload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' - ? previous - : undefined - const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession - : undefined - const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model - : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload - const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( - previous, - rootContextPreservingPayload - ) - if (boundaryReconciledPrevious !== previous) { - previous = boundaryReconciledPrevious - if (previous) { - this.state.lastStatusByPaneKey.set(previous.paneKey, previous) - this.scheduleStatusPersist() - } - } - const identity = resolveAgentStatusIdentity({ - existing: previous - ? { - agentType: previous.payload.agentType, - state: previous.payload.state, - updatedAt: previous.receivedAt, - restoredUnconfirmed: previous.restoredUnconfirmed - } - : undefined, - incoming: rootContextPreservingPayload.payload.agentType, - now - }) - if ( - previous && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: rootContextPreservingPayload.payload.state - }) - ) { - return previous - } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType - ? rootContextPreservingPayload - : { - ...rootContextPreservingPayload, - payload: { - ...rootContextPreservingPayload.payload, - agentType: identity.agentType - } - } - const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) - if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - return previous - } - // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'done' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS - ) { - return previous - } - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'working' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - (effectivePayload.isReplay === true || - isToolProgressWorkingAfterInterrupt(effectivePayload) || - (effectivePayload.hasExplicitPrompt !== true && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) - ) { - if (effectivePayload.payload.agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) - } - return previous - } - if ( - effectivePayload.payload.state !== 'done' || - effectivePayload.payload.lastAssistantMessage - ) { - this.clearAssistantMessageRetry(effectivePayload.paneKey) - } - onAccepted?.() - if (!identity.inheritedFromActivePane) { - this.maybeTrackAgentPromptSent(effectivePayload, previous) - } - const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now), - observation: this.stampObservation(boundaryAwarePayload, origin, now) - } - if ( - typeof enriched.payload.turnCompletedAt === 'number' && - Number.isFinite(enriched.payload.turnCompletedAt) - ) { - this.activeHookTurnCompletedAtByPaneKey.set( - enriched.paneKey, - enriched.payload.turnCompletedAt - ) - } - // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. - if (enriched.restoredUnconfirmed) { - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - } else { - this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) - } - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - - // Why: every status emit must reach plugins too, so a new early-return path - // upstream cannot silently leave the plugin tap behind the main-window fanout. - private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { - this.onAgentStatus?.(enriched) - for (const listener of this.enrichedStatusListeners) { - try { - listener(enriched) - } catch (err) { - console.error('[agent-hooks] enriched status listener threw', err) - } - } - } - - private clearAssistantMessageRetry(paneKey: string): void { - const timer = this.assistantMessageRetryTimers.get(paneKey) - if (!timer) { - return - } - clearTimeout(timer) - this.assistantMessageRetryTimers.delete(paneKey) - } - - private clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) - } - - private scheduleCodexSubagentPoll( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload - ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { - return - } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { - return - } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) - } - - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { - const { source, body, original } = poll - // Keep the identity check at callback time: a newer event supersedes this - // payload even when its pane still has transcript children. - if ( - paneKey !== original.paneKey || - !this.server || - this.state.lastStatusByPaneKey.get(original.paneKey) !== original - ) { - return - } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized) { - return - } - const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original - this.scheduleCodexSubagentPoll(source, body, next) - } - - private scheduleAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - attempt = 1, - discoveryReady = false - ): void { - if ( - original.payload.lastAssistantMessage || - !hasPendingAgentResultText(source, body) || - attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS - ) { - return - } - this.clearAssistantMessageRetry(original.paneKey) - if (!discoveryReady) { - const discovery = preparePendingGrokResultDiscovery(source, body) - if (discovery) { - // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. - void discovery - .then(() => { - if (this.server) { - this.applyAssistantMessageRetry(source, body, original, 1, true) - } - }) - .catch((err) => { - console.error('[agent-hooks] Grok result discovery failed:', err) - }) - return - } - } - const timer = setTimeout(() => { - try { - this.assistantMessageRetryTimers.delete(original.paneKey) - this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) - } catch (err) { - console.error('[agent-hooks] assistant message retry failed:', err) - } - }, ASSISTANT_MESSAGE_RETRY_MS) - this.assistantMessageRetryTimers.set(original.paneKey, timer) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - - private applyAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - nextAttempt: number, - requireExactOriginal: boolean - ): void { - const current = this.state.lastStatusByPaneKey.get(original.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - !current || - (requireExactOriginal && current !== original) || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const normalized = this.normalizeLocalHookPayload(source, body) - if (!normalized.event?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) - return - } - // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - this.applyNormalizedStatus(normalized.event, normalized.onAccepted) - } - - setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { - this.paneKeyAliasPersistenceListener = listener - } - - private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { - return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => - entry.ptyId - ? [ - { - ptyId: entry.ptyId, - legacyPaneKey, - stablePaneKey: entry.stablePaneKey, - updatedAt: entry.updatedAt - } - ] - : [] - ) - } - - private notifyPaneKeyAliasPersistenceListener(): void { - this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) - } - - private boundPaneKeyAliases(): void { - while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { - // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. - const oldestKey = this.legacyPaneKeyAliases.keys().next().value - if (!oldestKey) { - break - } - this.legacyPaneKeyAliases.delete(oldestKey) - } - } - - private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - let fallbackPaneKey = paneKey - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if ( - entry.stablePaneKey === ownerPaneKey && - (!ptyId || !entry.ptyId || entry.ptyId === ptyId) - ) { - if (entry.authorityVerified) { - return physicalPaneKey - } - fallbackPaneKey = physicalPaneKey - } - } - return fallbackPaneKey - } - - canTransferPaneAuthority( - fromPaneKey: string, - ptyId: string | undefined, - ownsPty: (physicalPaneKey: string, ptyId: string) => boolean - ): boolean { - if (!isValidPaneKey(fromPaneKey)) { - return false - } - const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) - if (ptyId) { - return Boolean( - (alias?.authorityVerified && alias.ptyId === ptyId) || - ownsPty(physicalPaneKey, ptyId) || - (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) - ) - } - // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. - return alias?.authorityVerified === true - } - - registerPaneKeyAlias( - legacyPaneKey: string, - stablePaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { overwriteExisting?: boolean; authorityVerified?: boolean } - ): void { - const fromPaneKey = legacyPaneKey.trim() - const toPaneKey = stablePaneKey.trim() - if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { - return - } - const existing = this.legacyPaneKeyAliases.get(fromPaneKey) - if (existing && options?.overwriteExisting === false) { - return - } - // Why: remint tokens have no embedded tab id; first pane wins so a later spawn - // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. - if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { - return - } - const normalizedPtyId = - typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId - const normalizedUpdatedAt = - Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) - const authorityVerified = options?.authorityVerified ?? false - if ( - existing && - existing.stablePaneKey === toPaneKey && - existing.ptyId === (normalizedPtyId ?? null) && - existing.updatedAt === normalizedUpdatedAt && - existing.authorityVerified === authorityVerified - ) { - return - } - this.legacyPaneKeyAliases.set(fromPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId ?? null, - updatedAt: normalizedUpdatedAt, - authorityVerified - }) - this.boundPaneKeyAliases() - if (normalizedPtyId) { - this.notifyPaneKeyAliasPersistenceListener() - } - } - - transferPaneAuthority( - fromPaneKey: string, - toPaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { authorityVerified?: boolean } - ): void { - if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { - return - } - const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) - const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) - movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) - const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (movedStatus) { - const owner = parsePaneKey(toPaneKey) - this.state.lastStatusByPaneKey.set(toPaneKey, { - ...movedStatus, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - } - const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - if (hydratedLaunchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) - } - const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) - if (persistedAuthority) { - const owner = parsePaneKey(toPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.set( - toPaneKey, - Object.freeze({ - ...persistedAuthority, - paneKey: toPaneKey, - ...(owner?.tabId ? { tabId: owner.tabId } : {}) - }) - ) - } - if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { - this.runtimeObservedStatusPaneKeys.add(toPaneKey) - } - const restartedTokenHash = - this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) - if (restartedTokenHash) { - this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) - } - const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) - if (activeTurnCompletedAt !== undefined) { - this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) - this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) - } - const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) - if (authorityObservation) { - const owner = parsePaneKey(toPaneKey) - this.currentAuthorityObservations.delete(previousOwnerPaneKey) - this.currentAuthorityObservations.set( - toPaneKey, - Object.freeze({ - ...authorityObservation, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - ) - } - const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) - if (promptDedupe !== undefined) { - this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) - this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) - } - this.clearAssistantMessageRetry(previousOwnerPaneKey) - this.clearCodexSubagentPoll(previousOwnerPaneKey) - // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. - this.legacyPaneKeyAliases.set(physicalPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId, - updatedAt, - authorityVerified: options?.authorityVerified ?? true - }) - this.boundPaneKeyAliases() - this.closedAgentStatusPaneKeys.delete(toPaneKey) - this.notifyPaneKeyAliasPersistenceListener() - if (hadStatus || persistedAuthority) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - retirePaneAuthority(paneKey: string): void { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, ownerPaneKey]) - const retiredAliases: RetiredPaneAlias[] = [] - let aliasChanged = false - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { - this.legacyPaneKeyAliases.delete(physicalPaneKey) - retiredAliases.push({ physicalPaneKey, entry }) - paneKeys.add(physicalPaneKey) - paneKeys.add(entry.stablePaneKey) - aliasChanged = true - } - } - this.recordRetiredPaneFence(paneKeys, retiredAliases) - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) - for (const key of paneKeys) { - this.markPaneClosedForAgentStatus(key) - this.restartedStatusLaunchTokenHashByPaneKey.delete(key) - this.clearAssistantMessageRetry(key) - this.clearCodexSubagentPoll(key) - clearPaneCacheState(this.state, key) - this.activeHookTurnCompletedAtByPaneKey.delete(key) - this.runtimeObservedStatusPaneKeys.delete(key) - this.currentAuthorityObservations.delete(key) - this.promptSentDedupeByPaneKey.delete(key) - this.observations.forget(key) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - // Why: retirement fences a pane and every alias of it, then deletes those aliases. - // Lifting only the key we are handed strands the rest — a detached pane's process - // keeps posting the key it launched under, so it would stay suppressed forever with - // the fence apparently lifted. Replay the recorded fence instead: same key set, same - // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger - // claim survives and a live process is never routed back into a closed tab. - private restoreRetiredPaneFence(fence: RetiredPaneFence): void { - let aliasChanged = false - for (const { physicalPaneKey, entry } of fence.aliases) { - if ( - this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || - this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || - // Why: the pane was rebound in the meantime; the newer alias is the truth. - this.legacyPaneKeyAliases.has(physicalPaneKey) - ) { - continue - } - this.legacyPaneKeyAliases.set(physicalPaneKey, entry) - aliasChanged = true - } - for (const key of fence.paneKeys) { - if (this.retiredPaneFencesByKey.get(key) === fence) { - this.retiredPaneFencesByKey.delete(key) - } - } - if (aliasChanged) { - this.boundPaneKeyAliases() - this.notifyPaneKeyAliasPersistenceListener() - } - } - - // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that - // exact pane disproves the claim at the moment it stops being true, so the fence - // lifts here instead of waiting for the agent to speak again — an agent re-attached - // mid-turn or left idle would otherwise stay suppressed for the rest of its life - // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. - restorePaneAuthority(paneKey: string): boolean { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { - return false - } - const fence = - this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) - let restored = false - for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { - if (this.isClosedAgentStatusTabForPaneKey(key)) { - continue - } - if (this.closedAgentStatusPaneKeys.delete(key)) { - restored = true - } - } - if (fence) { - this.restoreRetiredPaneFence(fence) - } - return restored - } - - clearPaneKeyAliasesForPty( - ptyId: string, - options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } - ): void { - let aliasChanged = false - let statusChanged = false - const clearedStatusPaneKeys = new Set() - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - if (entry.ptyId === ptyId) { - const shouldClearStablePaneKey = - options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true - const revokedPaneKeys = new Set([legacyPaneKey]) - if (shouldClearStablePaneKey) { - revokedPaneKeys.add(entry.stablePaneKey) - } - if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { - statusChanged = true - } - this.legacyPaneKeyAliases.delete(legacyPaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { - statusChanged = true - clearedStatusPaneKeys.add(entry.stablePaneKey) - } - if (shouldClearStablePaneKey) { - // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. - clearPaneCacheState(this.state, entry.stablePaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) - this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) - this.currentAuthorityObservations.delete(entry.stablePaneKey) - this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) - } - aliasChanged = true - } - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - for (const paneKey of clearedStatusPaneKeys) { - this.emitPaneStatusCleared({ paneKey }) - } - } - } - - private resolvePaneKeyAlias(paneKey: string): string { - return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey - } - - private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { - let changed = false - for (const commitment of this.hydratedAuthorityCommitments) { - if ( - paneKeys.has(commitment.paneKey) || - paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) - ) { - this.revokedHydratedAuthorityCommitments.add(commitment) - changed = true - } - } - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed - changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed - } - return changed - } - - private normalizeHookBodyPaneKeyAlias(body: unknown): unknown { - if (typeof body !== 'object' || body === null) { - return body - } - const record = body as Record - const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey - if (!stablePaneKey) { - return body - } - // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. - return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } - } - - private normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { - if (source !== 'claude' || typeof body !== 'object' || body === null) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const rawPaneKey = (body as Record).paneKey - const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' - if (!paneKey) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - const event = normalizeHookPayload(this.state, source, body, this.env) - const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) - if (!event || event.paneKey !== paneKey) { - return { event } - } - // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. - return { - event, - onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) - } - } - - private setClaudeBackgroundEvidence( - paneKey: string, - hasRunningTask: boolean, - hasActiveCron: boolean - ): void { - if (hasRunningTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - if (hasActiveCron) { - this.state.claudeActiveSessionCronPaneKeys.add(paneKey) - } else { - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - } - - ingestTerminalStatus(event: { - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - payload: ParsedAgentStatusPayload - }): void { - const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } - const reportedTabId = - event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { - return - } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined - const connectionId = - typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 - ? event.connectionId.trim() - : null - const previous = this.state.lastStatusByPaneKey.get(paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - previous?.claudeLeadBoundaryChildOnly === true && - previous.payload.agentType === 'claude' && - event.payload.agentType === 'claude' - ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. - return - } - const preserveActiveTurnStamp = - previous?.payload.turnCompletedAt !== undefined && - previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) - if ( - !previous?.restoredUnconfirmed && - previous?.connectionId === connectionId && - previous.tabId === tabId && - previous.worktreeId === worktreeId && - terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) - ) { - return - } - // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is - // never evidence that the session ended — yet overwriting the row dropped the cached identity. - // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which - // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). - // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. - // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an - // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so - // it must not be read as a mismatch and strip the session the renderer would have kept. - const claimedAgentType = - event.payload.agentType && event.payload.agentType !== 'unknown' - ? event.payload.agentType - : undefined - const preservedProviderSession = - previous?.providerSession && - (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && - (previous.payload.state !== 'done' || event.payload.state === 'done') - ? previous.providerSession - : undefined - // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. - this.applyNormalizedStatus( - { - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: event.payload - }, - undefined, - 'osc' - ) - } - - /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ - ingestRemote( - envelope: { - paneKey: string - tabId?: string - worktreeId?: string - env?: string - version?: string - launchToken?: string - hasExplicitPrompt?: boolean - promptInteractionKey?: string - hookEventName?: string - source?: unknown - providerPromptId?: unknown - compactTrigger?: unknown - toolUseId?: string - toolAgentId?: string - teammateName?: string - toolAgentType?: string - providerSession?: unknown - providerSessionOnly?: unknown - isReplay?: boolean - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ - shedFields?: unknown - claudeRunningNonAgentTask?: unknown - payload: unknown - }, - connectionId: string | null - ): void { - // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (connectionId !== null && typeof connectionId !== 'string') { - return - } - const trimmedConnectionId = connectionId?.trim() ?? null - if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { - return - } - if (!envelope || typeof envelope.paneKey !== 'string') { - return - } - // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. - const physicalPaneKey = envelope.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN) { - return - } - if (!parsedPaneKey) { - return - } - // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. - if (envelope.isReplay === true) { - const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) - if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { - return - } - } - if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { - return - } - if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { - return - } - // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. - const reportedTabId = - envelope.tabId !== undefined && envelope.tabId.trim().length > 0 - ? envelope.tabId.trim() - : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.trim() - : undefined - const source = isAgentHookSource(envelope.source) ? envelope.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && - (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') - ? envelope.compactTrigger - : undefined - const statusDisposition = this.getAgentStatusDisposition(paneKey, { - source, - rawSource: envelope.source, - hookEventName, - isReplay: envelope.isReplay === true, - hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - if (statusDisposition === 'restart') { - // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. - // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the - // stable pane, so the rebind cannot land on a legacy key. - this.observations.rebind(paneKey) - } - const worktreeId = - envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 - ? envelope.worktreeId.trim() - : undefined - const promptInteractionKey = - typeof envelope.promptInteractionKey === 'string' && - envelope.promptInteractionKey.trim().length > 0 - ? envelope.promptInteractionKey.trim() - : undefined - const toolUseId = - typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 - ? envelope.toolUseId.trim() - : undefined - const toolAgentId = - typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 - ? envelope.toolAgentId.trim() - : undefined - const teammateName = - typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 - ? envelope.teammateName.trim() - : undefined - const toolAgentType = - typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 - ? envelope.toolAgentType.trim() - : undefined - const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined - // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const validatedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!validatedPayload) { - return - } - // Why: restore a shed roster only when its digest and turn identity still match the cache. - let normalizedPayload = restoreShedStatusFields( - validatedPayload, - envelope.shedFields, - this.state.lastStatusByPaneKey.get(paneKey)?.payload - ) - const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) - let acceptedCompactCompletion = false - if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { - // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); - // reject it here too so a host on any version cannot drive pane state from it. - if (hookEventName === 'PreCompact' || source !== 'claude') { - return - } - // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact - // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is - // fixed and known — manual produced `done`, auto produced `working` — so the payload state - // stands in for the missing trigger. Trigger substitution only; ownership is still checked. - const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) - // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older - // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so - // without this drop, every auto compact on such a host mints exactly the stuck `working` this - // change removes. - if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { - return - } - if ( - isClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) || - !canAcceptClaudeCompactCompletion(previousStatus, { - source, - connectionId: trimmedConnectionId, - providerPromptId, - providerSession - }) - ) { - return - } - markClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) - // Why: an older relay built this payload before the boundary flag existed, so it arrives as a - // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the - // boundary here so a compact stays silent regardless of which relay normalized it. - if (normalizedPayload.sessionBoundary !== true) { - normalizedPayload = { ...normalizedPayload, sessionBoundary: true } - } - acceptedCompactCompletion = true - } - // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the - // trigger-stripped replay is exactly the shape that arrives without one, and it is still the - // compact's own promptless event, so it still needs the summarized turn's label. - if ( - source === 'claude' && - (compactTrigger !== undefined || acceptedCompactCompletion) && - normalizedPayload.prompt.length === 0 && - previousStatus?.payload.prompt - ) { - normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } - } - if ( - envelope.providerSessionOnly === true && - !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) - ) { - return - } - const applyClaudeBackgroundWork = - normalizedPayload.agentType === 'claude' && - typeof envelope.claudeRunningNonAgentTask === 'boolean' && - // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. - (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) - // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. - warnOnHookEnvOrVersionMismatch(this.state, { - version: envelope.version, - env: envelope.env, - expectedEnv: this.env - }) - const event: AgentHookEventPayload = { - paneKey, - source, - launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, - tabId, - worktreeId, - connectionId: trimmedConnectionId, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey, - hookEventName, - providerPromptId, - compactTrigger, - toolUseId, - toolAgentId, - teammateName, - toolAgentType, - providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, - payload: normalizedPayload - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus( - event, - applyClaudeBackgroundWork - ? () => { - if (envelope.claudeRunningNonAgentTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - } - : undefined - ) - } - - async start(options?: { - env?: string - userDataPath?: string - endpointNamespace?: string - }): Promise { - if (this.server) { - return - } - - if (options?.env) { - this.env = options.env - } - if (options?.userDataPath) { - // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. - this.endpointDir = options.endpointNamespace - ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) - : join(options.userDataPath, 'agent-hooks') - this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) - this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) - } - this.token = randomUUID() - this.endpointFileWritten = false - this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) - } - const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { - if (req.method !== 'POST') { - res.writeHead(404) - res.end() - return - } - - if (req.headers['x-orca-agent-hook-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - - // Why: bound request time so a stalled client can't hold a socket open (slowloris). - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. - let destroyedBySlowlorisCap = false - req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { - destroyedBySlowlorisCap = true - req.destroy() - }) - - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - try { - const body = await readRequestBody(req) - if (pathname === CLAUDE_STATUSLINE_PATHNAME) { - const statusLineEvent = parseClaudeStatusLineBody(body) - if (statusLineEvent) { - this.onClaudeStatusLine?.(statusLineEvent) - } - res.writeHead(204) - res.end() - return - } - const source = resolveHookSource(pathname) - if (!source) { - res.writeHead(404) - res.end() - return - } - - const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - trackEmptyPaneKeyHook(hookBody) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) - const normalized = this.normalizeLocalHookPayload(source, aliasedBody) - const statusDisposition = normalized.event - ? this.getAgentStatusDisposition(normalized.event.paneKey, { - source, - hookEventName: normalized.event.hookEventName, - isReplay: normalized.event.isReplay, - hasExplicitPrompt: normalized.event.hasExplicitPrompt, - launchToken: normalized.event.launchToken - }) - : 'suppress' - if (normalized.event && statusDisposition !== 'suppress') { - const event = - statusDisposition === 'restart' - ? { ...normalized.event, launchToken: undefined } - : normalized.event - if (statusDisposition === 'restart') { - // Why: a retired pane accepting a new turn is a different agent session behind the - // same key — later observations must not be ordered against the retired one. - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) - } - - res.writeHead(204) - res.end() - } catch (error) { - // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut - // by something on the loopback path, not by a bad payload. Fail open as before, but count it — - // this is the one failure mode that silently stops status for every runtime at once. - if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { - this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) - } - // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. - res.writeHead(204) - res.end() - } - } - // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. - this.server = createServer((req, res) => { - void handleRequest(req, res) - }) - - await new Promise((resolve, reject) => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - const onStartupError = (err: Error): void => { - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) - } - - stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() - this.server?.close() - this.server = null - this.port = 0 - this.token = '' - this.env = 'production' - this.onAgentStatus = null - this.onPaneStatusCleared = null - for (const timer of this.assistantMessageRetryTimers.values()) { - clearTimeout(timer) - } - this.assistantMessageRetryTimers.clear() - this.codexSubagentPollScheduler.clearAll() - // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. - this.endpointDir = null - this.endpointFilePathCache = null - this.endpointFileWritten = false - this.lastStatusFilePath = null - this.lastWrittenJson = null - this.runtimeObservedStatusPaneKeys.clear() - this.hydratedAuthorityCommitments = Object.freeze([]) - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - this.revokedHydratedAuthorityCommitments = new WeakSet() - this.currentAuthorityObservations.clear() - this.promptSentDedupeByPaneKey.clear() - this.closedAgentStatusTabIds.clear() - this.closedAgentStatusPaneKeys.clear() - this.restartedStatusLaunchTokenHashByPaneKey.clear() - this.retiredPaneFencesByKey.clear() - this.connectionTimestampWatermarkById.clear() - this.legacyPaneKeyAliases.clear() - clearAllListenerCaches(this.state) - this.notifyStatusChangeListeners() - } - - /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state - * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live - * claims. Returns null when the row has no resumable session to keep. */ - private toRetainedProviderSessionRow( - entry: EnrichedAgentHookEventPayload | null | undefined - ): EnrichedAgentHookEventPayload | null { - if ( - !entry?.providerSession || - !entry.payload.agentType || - entry.payload.agentType === 'unknown' - ) { - return null - } - const { launchToken: _launchToken, ...resumeIdentity } = entry - return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } - } - - /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ - dropStatusEntry(paneKey: string): void { - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (!deleted) { - return - } - const retained = this.toRetainedProviderSessionRow(deleted) - if (retained) { - this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) - } - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitStatusDropped(deleted.paneKey) - } - - /** Retire panes whose owning process is certifiably dead. - * - * The ordinary teardown already does this: every attributable PTY exit reaches - * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that - * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may - * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left - * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, - * so a dead pane is cleaned up identically however its keys were resolved. */ - reconcileEndedProcessForPaneKeys( - paneKeys: Iterable, - options?: { - /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still - * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` - * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to - * resume into, and dropping it matches what `clearProviderPtyState` already does. */ - preserveResumeIdentity?: boolean - } - ): number { - let cleared = 0 - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { - continue - } - const retained = options?.preserveResumeIdentity - ? this.toRetainedProviderSessionRow( - this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - ) - : null - this.clearPaneState(resolvedPaneKey) - if (retained) { - this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - cleared += 1 - } - return cleared - } - - /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through - * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list - * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a - * different file. */ - private hasLiveClaimsForPaneKey(paneKey: string): boolean { - return paneHasStateClaims(this.state, paneKey) - } - - /** Clear statuses proven to belong to one lost SSH transport. */ - clearStatusEntriesForConnection(connectionId: string): void { - const normalizedConnectionId = connectionId.trim() - if (normalizedConnectionId.length === 0) { - return - } - const clearedAt = Math.max( - Date.now(), - (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 - ) - this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) - let statusChanged = false - for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { - const entry = rawEntry as EnrichedAgentHookEventPayload - // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. - if (entry.connectionId !== normalizedConnectionId) { - continue - } - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (deleted) { - statusChanged = true - if (deleted.payload.agentType === 'codex') { - // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. - this.state.codexSubagentRosterByPaneKey.delete(paneKey) - this.state.codexLeadStateByPaneKey.delete(paneKey) - } else if (deleted.payload.agentType === 'claude') { - this.state.claudeSubagentRosterByPaneKey.delete(paneKey) - this.state.claudeLeadStateByPaneKey.delete(paneKey) - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - this.state.claudeSessionOwnerByPaneKey.delete(paneKey) - } - } - } - for (const [paneKey, evidence] of this.currentAuthorityObservations) { - if (evidence.connectionId === normalizedConnectionId) { - this.currentAuthorityObservations.delete(paneKey) - } - } - if (statusChanged) { - // Why: persist/notify once — one disconnect can own many panes. - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. - this.emitPaneStatusCleared({ - transient: true, - connectionId: normalizedConnectionId, - clearedAt - }) - } - - private deleteStatusEntry( - paneKey: string, - options?: { preserveAuthority?: boolean } - ): EnrichedAgentHookEventPayload | null { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return null - } - this.state.lastStatusByPaneKey.delete(resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - if (!options?.preserveAuthority) { - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - } - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - if (existing.payload.state === 'done') { - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - } - return existing - } - - dropStatusEntriesByTabPrefix(tabId: string): void { - this.markTabClosedForAgentStatus(tabId) - const paneKeysToClear = new Set() - for (const key of this.state.lastStatusByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key) - } - } - for (const key of this.state.lastPromptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.lastToolByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.ampCompletedCacheKeys) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const paneKey of this.runtimeObservedStatusPaneKeys) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const commitment of this.hydratedAuthorityCommitments) { - if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { - paneKeysToClear.add(commitment.paneKey) - } - } - - let aliasChanged = false - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId) - if (ownerMatches) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeysToClear.add(legacyPaneKey) - paneKeysToClear.add(entry.stablePaneKey) - this.markPaneClosedForAgentStatus(legacyPaneKey) - this.markPaneClosedForAgentStatus(entry.stablePaneKey) - aliasChanged = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) - - let statusChanged = false - for (const paneKey of paneKeysToClear) { - if (this.state.lastStatusByPaneKey.has(paneKey)) { - statusChanged = true - } - this.clearAssistantMessageRetry(paneKey) - this.clearCodexSubagentPoll(paneKey) - clearPaneCacheState(this.state, paneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) - this.currentAuthorityObservations.delete(paneKey) - this.promptSentDedupeByPaneKey.delete(paneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - clearPaneState(paneKey: string): void { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, resolvedPaneKey]) - // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - clearPaneCacheState(this.state, resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - let clearedAlias = false - for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { - if (stablePaneKey.stablePaneKey === resolvedPaneKey) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeys.add(legacyPaneKey) - paneKeys.add(stablePaneKey.stablePaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) - clearedAlias = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - if (clearedAlias) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) - } - } - - /** Second reap path for restored Claude subagent rows: drop the ones whose pane - * has no live local agent process behind it any more. A PTY that dies while Orca - * is down never runs the teardown that clears pane state, so hydrate rebuilds a - * roster nothing can ever retire — the inventory reap needs the parent to emit a - * complete `background_tasks` list and an idle parent never does. The row then - * gates the pane 'working' for the rest of its life and hibernation, which - * requires 'done', can never reclaim the agent's heap. - * - * Both the execution host and relay binding must prove local ownership before - * targeted PTY liveness is consulted. Panes that reported in this runtime are - * also skipped. Returns the number of panes changed. */ - async reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalExecutionHost: (worktreeId: string | undefined) => boolean, - isLocalPaneAgentLive: (paneKey: string) => Promise, - isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean - ): Promise { - const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if ( - enriched.payload.agentType === 'claude' && - enriched.connectionId === null && - isLocalExecutionHost(enriched.worktreeId) && - // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, - // or a background-task/cron latch nothing will refresh, strands the pane just as - // permanently — and unlike the roster case there is no child event left to reap it. - (claudeRosterHasRestoredSnapshotSubagent( - this.state.claudeSubagentRosterByPaneKey.get(paneKey) - ) || - enriched.payload.state !== 'done' || - this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && - !this.runtimeObservedStatusPaneKeys.has(paneKey) - ) { - candidates.push({ paneKey, entry: enriched }) - } - } - const liveness = await Promise.all( - candidates.map(async (candidate) => { - try { - return await isLocalPaneAgentLive(candidate.paneKey) - } catch { - return true - } - }) - ) - let changedPanes = 0 - for (const [index, candidate] of candidates.entries()) { - const { paneKey, entry: enriched } = candidate - if ( - liveness[index] || - !isLocalPaneLivenessEvidenceCurrent(paneKey) || - this.state.lastStatusByPaneKey.get(paneKey) !== enriched || - this.runtimeObservedStatusPaneKeys.has(paneKey) || - !isLocalExecutionHost(enriched.worktreeId) - ) { - continue - } - if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { - // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably - // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the - // pane the same way an observed exit would — otherwise the widened candidate set is inert. - // - // Why delete rather than downgrade to `done` like the reap branch below: that branch has a - // real turn to describe — a parent whose children it just reaped — while these panes' only - // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would - // invent a completion that never happened, and leaving it non-terminal keeps the bug. This - // sweep stands in for the exit Orca never observed, so it does what that exit does: - // `clearProviderPtyState` -> `clearPaneState`. - if (this.hasLiveClaimsForPaneKey(paneKey)) { - this.clearPaneState(paneKey) - changedPanes += 1 - } - continue - } - changedPanes += 1 - const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) - const subagents = claudeRosterToSnapshots(roster) - // Why: the pane's persisted 'working' was the child gate holding a finished - // lead open (subagent events never set lead state). With the last working row - // gone and no process left to report, 'done' is the only truthful state — and - // the one hibernation needs once this pane's agent is restored. - const state = - enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) - ? 'done' - : enriched.payload.state - const stateChanged = state !== enriched.payload.state - const reconciledAt = stateChanged - ? Math.max(Date.now(), enriched.receivedAt + 1) - : enriched.receivedAt - // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying - // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. - const { restoredUnconfirmed, ...reconciledBase } = enriched - const reconciled: EnrichedAgentHookEventPayload = { - ...reconciledBase, - ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - receivedAt: reconciledAt, - stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { - ...enriched.payload, - state, - workingMode: state === 'working' ? enriched.payload.workingMode : undefined, - subagents - } - } - this.state.lastStatusByPaneKey.set(paneKey, reconciled) - } - if (changedPanes > 0) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - return changedPanes - } - - buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { - return {} - } - - const env: Record = { - ORCA_AGENT_HOOK_PORT: String(this.port), - ORCA_AGENT_HOOK_TOKEN: this.token, - ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, - ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT - } - // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. - if (this.endpointFileWritten && this.endpointFilePathCache) { - env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache - } - return env - } - - get endpointFilePath(): string | null { - return this.endpointFilePathCache - } - - /** Test/diagnostic accessor for the on-disk last-status file path. */ - get lastStatusPath(): string | null { - return this.lastStatusFilePath - } - - private maybeWriteEndpointFile(): void { - if (!this.endpointDir || !this.endpointFilePathCache) { - return - } - this.endpointFileWritten = false - const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { - port: this.port, - token: this.token, - env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION, - transport: ORCA_HOOK_RAW_JSON_TRANSPORT - }) - this.endpointFileWritten = ok - } - - private hydrateLastStatusFromDisk(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. - this.state.lastStatusByPaneKey.clear() - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - let raw: string - try { - raw = readFileSync(this.lastStatusFilePath, 'utf8') - } catch (err) { - // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[agent-hooks] failed to read last-status file:', err) - } - return - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') - return - } - if (typeof parsed !== 'object' || parsed === null) { - console.warn('[agent-hooks] last-status file is not an object; ignoring') - return - } - const file = parsed as Partial - if (file.version !== LAST_STATUS_FILE_VERSION) { - console.warn( - `[agent-hooks] last-status file version mismatch (${String( - file.version - )} != ${LAST_STATUS_FILE_VERSION}); ignoring` - ) - return - } - const entries = file.entries - if (typeof entries !== 'object' || entries === null) { - console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') - return - } - let hydrated = 0 - let dropped = 0 - let prunedLegacyClaudeSubagents = 0 - let scrubbedLegacyLaunchTokens = 0 - // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). - const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS - for (const [paneKey, rawEntry] of Object.entries(entries)) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const rawResolvedEntry = - resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null - ? rawEntry - : { ...(rawEntry as Record), paneKey: resolvedPaneKey } - const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) - if (entry && entry.receivedAt >= ttlCutoff) { - const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) - if (launchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) - const evidence = this.toAuthorityEvidence(entry, launchTokenHash) - if (evidence) { - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) - } - } - if ( - typeof rawResolvedEntry === 'object' && - rawResolvedEntry !== null && - typeof (rawResolvedEntry as Record).launchToken === 'string' - ) { - scrubbedLegacyLaunchTokens += 1 - } - const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) - if (hydratedPayload !== entry.payload) { - prunedLegacyClaudeSubagents += - (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) - entry.payload = hydratedPayload - } - if (entry.payload.state !== 'done') { - // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. - entry.restoredUnconfirmed = true - } - this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) - if (entry.connectionId) { - // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. - const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) - this.connectionTimestampWatermarkById.set( - entry.connectionId, - Math.max(previousWatermark ?? -1, entry.receivedAt) - ) - } - // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. - if (entry.payload.agentType === 'codex') { - seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) - } else if (entry.payload.agentType === 'claude') { - seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true - }) - if (entry.payload.subagents) { - seedClaudeSubagentRosterFromSnapshots( - this.state, - resolvedPaneKey, - entry.payload.subagents - ) - } - } - hydrated += 1 - } else { - dropped += 1 - } - } - for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) - if (!commitment || commitment.observedAt < ttlCutoff) { - dropped += 1 - continue - } - const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - dropped += 1 - continue - } - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) - } - if (dropped > 0) { - console.warn( - `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` - ) - } - if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { - // Why: persist load-time pruning and bearer scrubbing once. - this.runStatusPersist() - } else if (hydrated > 0) { - // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. - this.lastWrittenJson = raw - } - } - - private captureHydratedAuthorityCommitments(): void { - this.revokedHydratedAuthorityCommitments = new WeakSet() - for (const entry of this.state.lastStatusByPaneKey.values()) { - const evidence = this.toAuthorityEvidence( - entry as EnrichedAgentHookEventPayload, - this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) - ) - if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { - this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) - } - } - this.hydratedAuthorityCommitments = Object.freeze( - Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) - ) - } - - private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { - const evidence = this.toAuthorityEvidence(payload) - if (evidence) { - this.currentAuthorityObservations.set(evidence.paneKey, evidence) - this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) - this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) - } - } - - private toAuthorityEvidence( - payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, - launchTokenHashOverride?: string - ): AgentHookAuthorityEvidence | null { - const launchToken = payload.launchToken?.trim() - const launchTokenHash = - launchTokenHashOverride ?? - (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) - if (!launchTokenHash) { - return null - } - return Object.freeze({ - paneKey: payload.paneKey, - launchTokenHash, - connectionId: payload.connectionId, - ...(payload.tabId ? { tabId: payload.tabId } : {}), - ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), - observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() - }) - } - - private serializeStatusFile(): string { - const entries: Record = {} - const authorityCommitments: Record = {} - const conflictedCommitments = new Set() - for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { - authorityCommitments[paneKey] = { ...commitment } - } - for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { - // Why: never persist invalid keys (matches the hydrate-path invariant). - if (!isValidPaneKey(paneKey)) { - continue - } - const enrichedPayload = payload as EnrichedAgentHookEventPayload - const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true - const { - claudeRunningNonAgentTask: _claudeRunningNonAgentTask, - promptInteractionKey: _promptInteractionKey, - // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. - restoredUnconfirmed: _restoredUnconfirmed, - // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). - observation: _observation, - // Replay provenance is runtime-only and must not survive another restart. - isReplay: _isReplay, - launchToken, - ...persistedPayload - } = enrichedPayload - const launchTokenHash = launchToken?.trim() - ? createHash('sha256').update(launchToken.trim()).digest('hex') - : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - entries[paneKey] = { - ...persistedPayload, - ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), - ...(launchTokenHash ? { launchTokenHash } : {}) - } - const commitment = this.toAuthorityEvidence(payload, launchTokenHash) - if (commitment && !conflictedCommitments.has(paneKey)) { - const existing = authorityCommitments[paneKey] - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - delete authorityCommitments[paneKey] - conflictedCommitments.add(paneKey) - } else { - authorityCommitments[paneKey] = { ...commitment } - } - } - } - const file: LastStatusFile = { - version: LAST_STATUS_FILE_VERSION, - entries, - authorityCommitments - } - return JSON.stringify(file) - } - - private scheduleStatusPersist(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: reset the timer each call so the write fires only after the last event in a burst. - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - } - this.statusPersistTimer = setTimeout(() => { - this.statusPersistTimer = null - this.runStatusPersist() - }, STATUS_PERSIST_DEBOUNCE_MS) - // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. - if (typeof this.statusPersistTimer.unref === 'function') { - this.statusPersistTimer.unref() - } - } - - flushStatusPersistSync(): void { - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - this.statusPersistTimer = null - } - if (!this.lastStatusFilePath) { - return - } - this.runStatusPersist() - } - - private runStatusPersist(): void { - if (!this.lastStatusFilePath || !this.endpointDir) { - return - } - const json = this.serializeStatusFile() - if (json === this.lastWrittenJson) { - return - } - const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) - let tmpWritten = false - try { - mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - try { - chmodSync(this.endpointDir, 0o700) - } catch { - // best-effort - } - } - writeFileSync(tmpPath, json, { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, this.lastStatusFilePath) - this.lastWrittenJson = json - } catch (err) { - console.warn('[agent-hooks] failed to write last-status file:', err) - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // tmp already gone - } - } - } - } - - /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ - _getStateForTests(): HookListenerState { - return this.state - } - - _resetPromptSentDedupeForTests(): void { - this.promptSentDedupeByPaneKey.clear() - } - - _resetConnectionTimestampWatermarksForTests(): void { - this.connectionTimestampWatermarkById.clear() - } -} +export { + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + PANE_KEY_ALIASES_MAX, + RETIRED_PANE_FENCES_MAX +} from './server/server-constants' +export { isValidPaneKey } + +/** Public composition seam for the loopback hook listener and relay status adapter. */ +export class AgentHookServer extends AgentHookServerLifecycle {} export const agentHookServer = new AgentHookServer() @@ -3540,3 +44,5 @@ export const _internals = { agentHookServer._resetConnectionTimestampWatermarksForTests() } } + +export type { HookListenerState } from '../../shared/agent-hook-listener/listener-state' diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts new file mode 100644 index 00000000000..18756cb459c --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -0,0 +1,222 @@ +import { movePaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../../shared/pane-key-alias' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { PANE_KEY_ALIASES_MAX } from './server-constants' +import type { EnrichedAgentHookEventPayload, PaneKeyAliasPersistenceListener } from './server-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import { isValidPaneKey } from './server-status-identity' +import { AgentHookServerAuthorityEvidence } from './server-authority-evidence' + +export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAuthorityEvidence { + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { + this.paneKeyAliasPersistenceListener = listener + } + + protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { + return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => + entry.ptyId + ? [ + { + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.stablePaneKey, + updatedAt: entry.updatedAt + } + ] + : [] + ) + } + + protected notifyPaneKeyAliasPersistenceListener(): void { + this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) + } + + protected boundPaneKeyAliases(): void { + while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { + // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. + const oldestKey = this.legacyPaneKeyAliases.keys().next().value + if (!oldestKey) { + break + } + this.legacyPaneKeyAliases.delete(oldestKey) + } + } + + protected getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + let fallbackPaneKey = paneKey + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if ( + entry.stablePaneKey === ownerPaneKey && + (!ptyId || !entry.ptyId || entry.ptyId === ptyId) + ) { + if (entry.authorityVerified) { + return physicalPaneKey + } + fallbackPaneKey = physicalPaneKey + } + } + return fallbackPaneKey + } + + canTransferPaneAuthority( + fromPaneKey: string, + ptyId: string | undefined, + ownsPty: (physicalPaneKey: string, ptyId: string) => boolean + ): boolean { + if (!isValidPaneKey(fromPaneKey)) { + return false + } + const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) + if (ptyId) { + return Boolean( + (alias?.authorityVerified && alias.ptyId === ptyId) || + ownsPty(physicalPaneKey, ptyId) || + (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) + ) + } + // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. + return alias?.authorityVerified === true + } + + registerPaneKeyAlias( + legacyPaneKey: string, + stablePaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { overwriteExisting?: boolean; authorityVerified?: boolean } + ): void { + const fromPaneKey = legacyPaneKey.trim() + const toPaneKey = stablePaneKey.trim() + if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { + return + } + const existing = this.legacyPaneKeyAliases.get(fromPaneKey) + if (existing && options?.overwriteExisting === false) { + return + } + // Why: remint tokens have no embedded tab id; first pane wins so a later spawn + // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. + if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { + return + } + const normalizedPtyId = + typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId + const normalizedUpdatedAt = + Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + const authorityVerified = options?.authorityVerified ?? false + if ( + existing && + existing.stablePaneKey === toPaneKey && + existing.ptyId === (normalizedPtyId ?? null) && + existing.updatedAt === normalizedUpdatedAt && + existing.authorityVerified === authorityVerified + ) { + return + } + this.legacyPaneKeyAliases.set(fromPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId ?? null, + updatedAt: normalizedUpdatedAt, + authorityVerified + }) + this.boundPaneKeyAliases() + if (normalizedPtyId) { + this.notifyPaneKeyAliasPersistenceListener() + } + } + + transferPaneAuthority( + fromPaneKey: string, + toPaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { authorityVerified?: boolean } + ): void { + if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { + return + } + const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) + const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null + const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) + const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (movedStatus) { + const owner = parsePaneKey(toPaneKey) + this.state.lastStatusByPaneKey.set(toPaneKey, { + ...movedStatus, + paneKey: toPaneKey, + tabId: owner?.tabId + }) + } + const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + if (hydratedLaunchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) + } + const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) + if (persistedAuthority) { + const owner = parsePaneKey(toPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.set( + toPaneKey, + Object.freeze({ + ...persistedAuthority, + paneKey: toPaneKey, + ...(owner?.tabId ? { tabId: owner.tabId } : {}) + }) + ) + } + if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { + this.runtimeObservedStatusPaneKeys.add(toPaneKey) + } + const restartedTokenHash = + this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) + if (restartedTokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) + } + const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) + if (activeTurnCompletedAt !== undefined) { + this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) + this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) + } + const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) + if (authorityObservation) { + const owner = parsePaneKey(toPaneKey) + this.currentAuthorityObservations.delete(previousOwnerPaneKey) + this.currentAuthorityObservations.set( + toPaneKey, + Object.freeze({ ...authorityObservation, paneKey: toPaneKey, tabId: owner?.tabId }) + ) + } + const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) + if (promptDedupe !== undefined) { + this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) + this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) + } + this.clearAssistantMessageRetry(previousOwnerPaneKey) + this.clearCodexSubagentPoll(previousOwnerPaneKey) + // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. + this.legacyPaneKeyAliases.set(physicalPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId, + updatedAt, + authorityVerified: options?.authorityVerified ?? true + }) + this.boundPaneKeyAliases() + this.closedAgentStatusPaneKeys.delete(toPaneKey) + this.notifyPaneKeyAliasPersistenceListener() + if (hadStatus || persistedAuthority) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } +} diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts new file mode 100644 index 00000000000..8cda3426629 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + EnrichedAgentHookEventPayload +} from './server-types' +import { AgentHookServerStatusRetries } from './server-status-retries' + +export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerStatusRetries { + attestCompatibilityAuthority(candidate: { + paneKey: string + launchTokenHash: string + connectionId: string | null + terminalProvenance: 'current_runtime' | 'restored' + }): AgentHookAuthorityAttestation | null { + const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) + const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => + entry.launchTokenHash === candidate.launchTokenHash && + entry.connectionId === candidate.connectionId + const commitments = this.hydratedAuthorityCommitments.filter( + (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) + ) + const current = Array.from(this.currentAuthorityObservations.values()) + const observations = current.filter(matchesCandidate) + const paneObservations = current.filter( + (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey + ) + const hasUniqueCurrentObservation = + observations.length === 1 && + paneObservations.length === 1 && + this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey + if (candidate.terminalProvenance === 'current_runtime') { + return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null + } + if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { + return null + } + if (observations.length === 0 && paneObservations.length === 0) { + return Object.freeze({ paneKey, source: 'hydrated_commitment' }) + } + if (!hasUniqueCurrentObservation) { + return null + } + return Object.freeze({ paneKey, source: 'current_hook' }) + } + + protected captureHydratedAuthorityCommitments(): void { + this.revokedHydratedAuthorityCommitments = new WeakSet() + for (const entry of this.state.lastStatusByPaneKey.values()) { + const evidence = this.toAuthorityEvidence( + entry as EnrichedAgentHookEventPayload, + this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) + ) + if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { + this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) + } + } + this.hydratedAuthorityCommitments = Object.freeze( + Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) + ) + } + + protected recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { + const evidence = this.toAuthorityEvidence(payload) + if (evidence) { + this.currentAuthorityObservations.set(evidence.paneKey, evidence) + this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) + this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) + } + } + + protected toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null { + const launchToken = payload.launchToken?.trim() + const launchTokenHash = + launchTokenHashOverride ?? + (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) + if (!launchTokenHash) { + return null + } + return Object.freeze({ + paneKey: payload.paneKey, + launchTokenHash, + connectionId: payload.connectionId, + ...(payload.tabId ? { tabId: payload.tabId } : {}), + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), + observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() + }) + } +} diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts new file mode 100644 index 00000000000..0ad1bdeba62 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -0,0 +1,193 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { AgentHookServerAuthorityAliases } from './server-authority-aliases' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' + +export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + retirePaneAuthority(paneKey: string): void { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, ownerPaneKey]) + const retiredAliases: RetiredPaneAlias[] = [] + let aliasChanged = false + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { + this.legacyPaneKeyAliases.delete(physicalPaneKey) + retiredAliases.push({ physicalPaneKey, entry }) + paneKeys.add(physicalPaneKey) + paneKeys.add(entry.stablePaneKey) + aliasChanged = true + } + } + this.recordRetiredPaneFence(paneKeys, retiredAliases) + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + for (const key of paneKeys) { + this.markPaneClosedForAgentStatus(key) + this.restartedStatusLaunchTokenHashByPaneKey.delete(key) + this.clearAssistantMessageRetry(key) + this.clearCodexSubagentPoll(key) + clearPaneCacheState(this.state, key) + this.activeHookTurnCompletedAtByPaneKey.delete(key) + this.runtimeObservedStatusPaneKeys.delete(key) + this.currentAuthorityObservations.delete(key) + this.promptSentDedupeByPaneKey.delete(key) + this.observations.forget(key) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + // Lifting only the key we are handed strands the rest — a detached pane's process + // keeps posting the key it launched under, so it would stay suppressed forever with + // the fence apparently lifted. Replay the recorded fence instead: same key set, same + // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger + // claim survives and a live process is never routed back into a closed tab. + protected restoreRetiredPaneFence(fence: RetiredPaneFence): void { + let aliasChanged = false + for (const { physicalPaneKey, entry } of fence.aliases) { + if ( + this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || + this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || + // Why: the pane was rebound in the meantime; the newer alias is the truth. + this.legacyPaneKeyAliases.has(physicalPaneKey) + ) { + continue + } + this.legacyPaneKeyAliases.set(physicalPaneKey, entry) + aliasChanged = true + } + for (const key of fence.paneKeys) { + if (this.retiredPaneFencesByKey.get(key) === fence) { + this.retiredPaneFencesByKey.delete(key) + } + } + if (aliasChanged) { + this.boundPaneKeyAliases() + this.notifyPaneKeyAliasPersistenceListener() + } + } + + restorePaneAuthority(paneKey: string): boolean { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { + return false + } + // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that + // exact pane disproves the claim at the moment it stops being true, so the fence + // lifts here instead of waiting for the agent to speak again — an agent re-attached + // mid-turn or left idle would otherwise stay suppressed for the rest of its life + // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. + const fence = + this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) + let restored = false + for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { + if (this.isClosedAgentStatusTabForPaneKey(key)) { + continue + } + if (this.closedAgentStatusPaneKeys.delete(key)) { + restored = true + } + } + if (fence) { + this.restoreRetiredPaneFence(fence) + } + return restored + } + + clearPaneKeyAliasesForPty( + ptyId: string, + options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } + ): void { + let aliasChanged = false + let statusChanged = false + const clearedStatusPaneKeys = new Set() + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (entry.ptyId !== ptyId) { + continue + } + const shouldClearStablePaneKey = + options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true + const revokedPaneKeys = new Set([legacyPaneKey]) + if (shouldClearStablePaneKey) { + revokedPaneKeys.add(entry.stablePaneKey) + } + if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { + statusChanged = true + } + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { + statusChanged = true + clearedStatusPaneKeys.add(entry.stablePaneKey) + } + if (shouldClearStablePaneKey) { + // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. + clearPaneCacheState(this.state, entry.stablePaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) + this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) + this.currentAuthorityObservations.delete(entry.stablePaneKey) + this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) + } + aliasChanged = true + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + for (const paneKey of clearedStatusPaneKeys) { + this.emitPaneStatusCleared({ paneKey }) + } + } + } + + protected resolvePaneKeyAlias(paneKey: string): string { + return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey + } + + protected revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { + let changed = false + for (const commitment of this.hydratedAuthorityCommitments) { + if ( + paneKeys.has(commitment.paneKey) || + paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) + ) { + this.revokedHydratedAuthorityCommitments.add(commitment) + changed = true + } + } + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed + changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed + } + return changed + } + + protected normalizeHookBodyPaneKeyAlias(body: unknown): unknown { + if (typeof body !== 'object' || body === null) { + return body + } + const record = body as Record + const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey + if (!stablePaneKey) { + return body + } + // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. + return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } + } +} diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts new file mode 100644 index 00000000000..9607e4a9dc8 --- /dev/null +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -0,0 +1,191 @@ +import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' +import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export function attachClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { + const establishesBoundary = + next.payload.agentType === 'claude' && + (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && + !next.toolAgentId && + next.payload.state === 'working' && + next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && + next.claudeRunningNonAgentTask === false + const carriesBoundary = + previous?.claudeLeadBoundaryChildOnly === true && + next.payload.agentType === 'claude' && + next.claudeRunningNonAgentTask === false && + (next.toolAgentId !== undefined || + next.hookEventName === 'SubagentStart' || + next.hookEventName === 'SubagentStop' || + next.hookEventName === 'TeammateIdle') + return establishesBoundary || carriesBoundary + ? { ...next, claudeLeadBoundaryChildOnly: true } + : next +} + +export function invalidateClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): EnrichedAgentHookEventPayload | undefined { + if ( + previous?.claudeLeadBoundaryChildOnly !== true || + attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true + ) { + return previous + } + const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous + return withoutBoundary +} + +export function shouldKeepClaudePermissionVisible( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if (previous?.restoredUnconfirmed) { + return false + } + if ( + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || + next.payload.agentType !== 'claude' || + next.payload.state !== 'working' + ) { + return false + } + if (next.hasExplicitPrompt === true) { + return false + } + if (isClaudePermissionOwningChildEnding(previous, next)) { + return false + } + if (isClaudePermissionResumingApprovedTool(previous, next)) { + return false + } + // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. + if (isAskUserQuestionTool(previous.payload.toolName)) { + return false + } + return true +} + +function isClaudePermissionOwningChildEnding( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const ownerId = previous.toolAgentId?.trim() + if (!ownerId) { + return false + } + if (next.hookEventName === 'SubagentStop') { + return ownerId === next.toolAgentId?.trim() + } + return ( + next.hookEventName === 'TeammateIdle' && + next.teammateName !== undefined && + claudeTeammateIdMatchesName(ownerId, next.teammateName) + ) +} + +function isClaudePermissionResumingApprovedTool( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const previousToolUseId = previous.toolUseId?.trim() || undefined + const nextToolUseId = next.toolUseId?.trim() || undefined + const previousAgentId = previous.toolAgentId?.trim() || undefined + const nextAgentId = next.toolAgentId?.trim() || undefined + const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined + const previousAgentType = previous.toolAgentType?.trim() || undefined + const nextAgentType = next.toolAgentType?.trim() || undefined + const hasMatchingConcreteAgentId = + previousAgentId !== undefined && previousAgentId === nextAgentId + const hasSameExplicitAgentType = + !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType + const sameToolName = + previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownInputFromConcreteAgent = + hasMatchingConcreteAgentId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + const hasMatchingToolUseId = + previousToolUseId !== undefined && previousToolUseId === nextToolUseId + const hasConflictingToolUseId = + previousToolUseId !== undefined && + nextToolUseId !== undefined && + previousToolUseId !== nextToolUseId + const sameUnknownInputFromToolUseId = + hasMatchingToolUseId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + + return ( + (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && + nextToolUseId !== undefined && + !hasConflictingToolUseId && + // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. + (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && + sameToolName && + (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) + ) +} + +export function shouldInheritClaudeToolUseIdForPermission( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if ( + previous?.restoredUnconfirmed || + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'working' || + previous.hookEventName !== 'PreToolUse' || + typeof previous.toolUseId !== 'string' || + previous.toolUseId.trim().length === 0 || + next.payload.agentType !== 'claude' || + next.payload.state !== 'waiting' || + next.hookEventName !== 'PermissionRequest' || + next.toolUseId !== undefined + ) { + return false + } + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownToolInput = + previous.payload.toolInput === undefined && next.payload.toolInput === undefined + if ( + previous.toolAgentId !== next.toolAgentId || + previous.toolAgentType !== next.toolAgentType || + previous.payload.toolName === undefined || + previous.payload.toolName !== next.payload.toolName || + (!sameKnownToolInput && !sameUnknownToolInput) + ) { + return false + } + return true +} + +export function attachClaudePermissionToolUseId( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload { + const inheritedToolUseId = previous?.toolUseId + if ( + !shouldInheritClaudeToolUseIdForPermission(previous, next) || + typeof inheritedToolUseId !== 'string' + ) { + return next + } + return { + ...next, + // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. + toolUseId: inheritedToolUseId + } +} diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts new file mode 100644 index 00000000000..04acc058dea --- /dev/null +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -0,0 +1,167 @@ +import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerAuthorityFences } from './server-authority-fences' + +export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences { + /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state + * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live + * claims. Returns null when the row has no resumable session to keep. */ + protected toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null { + if ( + !entry?.providerSession || + !entry.payload.agentType || + entry.payload.agentType === 'unknown' + ) { + return null + } + const { launchToken: _launchToken, ...resumeIdentity } = entry + return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } + } + + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ + dropStatusEntry(paneKey: string): void { + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (!deleted) { + return + } + const retained = this.toRetainedProviderSessionRow(deleted) + if (retained) { + this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) + } + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitStatusDropped(deleted.paneKey) + } + + /** Retire panes whose owning process is certifiably dead. + * + * The ordinary teardown already does this: every attributable PTY exit reaches + * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that + * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may + * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left + * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, + * so a dead pane is cleaned up identically however its keys were resolved. */ + reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still + * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` + * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to + * resume into, and dropping it matches what `clearProviderPtyState` already does. */ + preserveResumeIdentity?: boolean + } + ): number { + // A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant. + let cleared = 0 + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { + continue + } + const retained = options?.preserveResumeIdentity + ? this.toRetainedProviderSessionRow( + this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + ) + : null + this.clearPaneState(resolvedPaneKey) + if (retained) { + this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + cleared += 1 + } + return cleared + } + + /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through + * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list + * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a + * different file. */ + protected hasLiveClaimsForPaneKey(paneKey: string): boolean { + return paneHasStateClaims(this.state, paneKey) + } + + /** Clear statuses proven to belong to one lost SSH transport. */ + clearStatusEntriesForConnection(connectionId: string): void { + const normalizedConnectionId = connectionId.trim() + if (normalizedConnectionId.length === 0) { + return + } + const clearedAt = Math.max( + Date.now(), + (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 + ) + this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) + let statusChanged = false + for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { + const entry = rawEntry as EnrichedAgentHookEventPayload + // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. + if (entry.connectionId !== normalizedConnectionId) { + continue + } + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (deleted) { + statusChanged = true + if (deleted.payload.agentType === 'codex') { + // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. + this.state.codexSubagentRosterByPaneKey.delete(paneKey) + this.state.codexLeadStateByPaneKey.delete(paneKey) + } else if (deleted.payload.agentType === 'claude') { + this.state.claudeSubagentRosterByPaneKey.delete(paneKey) + this.state.claudeLeadStateByPaneKey.delete(paneKey) + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeSessionOwnerByPaneKey.delete(paneKey) + } + } + } + for (const [paneKey, evidence] of this.currentAuthorityObservations) { + if (evidence.connectionId === normalizedConnectionId) { + this.currentAuthorityObservations.delete(paneKey) + } + } + if (statusChanged) { + // Why: persist/notify once — one disconnect can own many panes. + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. + this.emitPaneStatusCleared({ + transient: true, + connectionId: normalizedConnectionId, + clearedAt + }) + } + + protected deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return null + } + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + if (!options?.preserveAuthority) { + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + } + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + if (existing.payload.state === 'done') { + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + } + return existing + } +} diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts new file mode 100644 index 00000000000..7fa7901ae73 --- /dev/null +++ b/src/main/agent-hooks/server/server-constants.ts @@ -0,0 +1,29 @@ +import { AGENT_KIND_VALUES, type AgentKind } from '../../../shared/telemetry-events' + +// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. +export const LAST_STATUS_FILE_NAME = 'last-status.json' +export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 +export const ASSISTANT_MESSAGE_RETRY_MS = 50 +export const CODEX_SUBAGENT_POLL_MS = 1_000 +export const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 + +// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). +export const LAST_STATUS_FILE_VERSION = 2 + +// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. +export const STATUS_PERSIST_DEBOUNCE_MS = 250 +export const TOOL_PROGRESS_HOOK_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure' +]) +export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) + +// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. +export const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 +export const PANE_KEY_ALIASES_MAX = 1024 +export const RETIRED_PANE_FENCES_MAX = 1024 diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts new file mode 100644 index 00000000000..70da93b7c3b --- /dev/null +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -0,0 +1,162 @@ +import { readFileSync } from 'node:fs' + +import { + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' +import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' +import type { LastStatusFile } from './server-types' +import { + authorityCommitmentsMatch, + dropHydratedIdleClaudeSubagents, + readPersistedLaunchTokenHash, + sanitizeHydratedEntry, + sanitizePersistedAuthorityCommitment +} from './server-persistence-validation' +import { AgentHookServerReaping } from './server-reaping' + +export abstract class AgentHookServerHydration extends AgentHookServerReaping { + /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ + protected hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. + this.state.lastStatusByPaneKey.clear() + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + let raw: string + try { + raw = readFileSync(this.lastStatusFilePath, 'utf8') + } catch (err) { + // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to read last-status file:', err) + } + return + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + return + } + if (typeof parsed !== 'object' || parsed === null) { + console.warn('[agent-hooks] last-status file is not an object; ignoring') + return + } + const file = parsed as Partial + if (file.version !== LAST_STATUS_FILE_VERSION) { + console.warn( + `[agent-hooks] last-status file version mismatch (${String( + file.version + )} != ${LAST_STATUS_FILE_VERSION}); ignoring` + ) + return + } + const entries = file.entries + if (typeof entries !== 'object' || entries === null) { + console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') + return + } + let hydrated = 0 + let dropped = 0 + let prunedLegacyClaudeSubagents = 0 + let scrubbedLegacyLaunchTokens = 0 + // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS + for (const [paneKey, rawEntry] of Object.entries(entries)) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const rawResolvedEntry = + resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null + ? rawEntry + : { ...(rawEntry as Record), paneKey: resolvedPaneKey } + const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) + if (entry && entry.receivedAt >= ttlCutoff) { + const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) + if (launchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) + const evidence = this.toAuthorityEvidence(entry, launchTokenHash) + if (evidence) { + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) + } + } + if ( + typeof rawResolvedEntry === 'object' && + rawResolvedEntry !== null && + typeof (rawResolvedEntry as Record).launchToken === 'string' + ) { + scrubbedLegacyLaunchTokens += 1 + } + const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) + if (hydratedPayload !== entry.payload) { + prunedLegacyClaudeSubagents += + (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) + entry.payload = hydratedPayload + } + if (entry.payload.state !== 'done') { + // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + entry.restoredUnconfirmed = true + } + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + if (entry.connectionId) { + // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. + const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) + this.connectionTimestampWatermarkById.set( + entry.connectionId, + Math.max(previousWatermark ?? -1, entry.receivedAt) + ) + } + // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. + if (entry.payload.agentType === 'codex') { + seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) + } else if (entry.payload.agentType === 'claude') { + seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { + childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true + }) + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } + } + hydrated += 1 + } else { + dropped += 1 + } + } + for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) + if (!commitment || commitment.observedAt < ttlCutoff) { + dropped += 1 + continue + } + const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + dropped += 1 + continue + } + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) + } + if (dropped > 0) { + console.warn( + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` + ) + } + if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { + // Why: persist load-time pruning and bearer scrubbing once. + this.runStatusPersist() + } else if (hydrated > 0) { + // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. + this.lastWrittenJson = raw + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts new file mode 100644 index 00000000000..0a1e8d761d6 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -0,0 +1,81 @@ +import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import type { NormalizedLocalHook } from './server-types' +import { AgentHookServerPersistence } from './server-persistence' + +export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence { + protected setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void { + if (hasRunningTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + if (hasActiveCron) { + this.state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } + + protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { + if (source !== 'claude' || typeof body !== 'object' || body === null) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const rawPaneKey = (body as Record).paneKey + const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' + if (!paneKey) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const event = normalizeHookPayload(this.state, source, body, this.env) + const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) + if (!event || event.paneKey !== paneKey) { + return { event } + } + // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + return { + event, + onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) + } + } + + // Spool records are durable replay evidence, not a live observation. + protected ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts new file mode 100644 index 00000000000..18b0a837c32 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -0,0 +1,282 @@ +import { track } from '../../telemetry/client' +import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { isAgentHookSource, restoreShedStatusFields } from '../../../shared/agent-hook-relay' +import { + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../../shared/agent-hook-listener/listener-limits' +import { + canAcceptClaudeCompactCompletion, + isClaudeCompactCompletionConsumed, + markClaudeCompactCompletionConsumed, + resolveLegacyCompactTrigger +} from '../../../shared/claude-compact-completion' +import { launchTokenHash } from '../../../shared/agent-hook-spool' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { isValidPiProviderSessionOnly } from './server-status-identity' +import { AgentHookServerIngestTerminal } from './server-ingest-terminal' + +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestTerminal { + /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hasExplicitPrompt?: boolean + promptInteractionKey?: string + hookEventName?: string + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown + providerSessionOnly?: unknown + isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown + payload: unknown + }, + connectionId: string | null + ): void { + // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. + if (connectionId !== null && typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string') { + return + } + // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. + const physicalPaneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. + const reportedTabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const hookEventName = + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined + const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && + (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') + ? envelope.compactTrigger + : undefined + const statusDisposition = this.getAgentStatusDisposition(paneKey, { + source, + rawSource: envelope.source, + hookEventName, + isReplay: envelope.isReplay === true, + hasExplicitPrompt: envelope.hasExplicitPrompt === true, + launchToken: envelope.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + if (statusDisposition === 'restart') { + // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. + // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the + // stable pane, so the rebind cannot land on a legacy key. + this.observations.rebind(paneKey) + } + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + const promptInteractionKey = + typeof envelope.promptInteractionKey === 'string' && + envelope.promptInteractionKey.trim().length > 0 + ? envelope.promptInteractionKey.trim() + : undefined + const toolUseId = + typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 + ? envelope.toolUseId.trim() + : undefined + const toolAgentId = + typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 + ? envelope.toolAgentId.trim() + : undefined + const teammateName = + typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 + ? envelope.teammateName.trim() + : undefined + const toolAgentType = + typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 + ? envelope.toolAgentType.trim() + : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined + // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { + return + } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + let normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) + const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) + let acceptedCompactCompletion = false + if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { + // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); + // reject it here too so a host on any version cannot drive pane state from it. + if (hookEventName === 'PreCompact' || source !== 'claude') { + return + } + // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact + // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is + // fixed and known — manual produced `done`, auto produced `working` — so the payload state + // stands in for the missing trigger. Trigger substitution only; ownership is still checked. + const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) + // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older + // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so + // without this drop, every auto compact on such a host mints exactly the stuck `working` this + // change removes. + if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { + return + } + if ( + isClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) || + !canAcceptClaudeCompactCompletion(previousStatus, { + source, + connectionId: trimmedConnectionId, + providerPromptId, + providerSession + }) + ) { + return + } + markClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) + // Why: an older relay built this payload before the boundary flag existed, so it arrives as a + // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the + // boundary here so a compact stays silent regardless of which relay normalized it. + if (normalizedPayload.sessionBoundary !== true) { + normalizedPayload = { ...normalizedPayload, sessionBoundary: true } + } + acceptedCompactCompletion = true + } + // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the + // trigger-stripped replay is exactly the shape that arrives without one, and it is still the + // compact's own promptless event, so it still needs the summarized turn's label. + if ( + source === 'claude' && + (compactTrigger !== undefined || acceptedCompactCompletion) && + normalizedPayload.prompt.length === 0 && + previousStatus?.payload.prompt + ) { + normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } + } + if ( + envelope.providerSessionOnly === true && + !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) + ) { + return + } + const applyClaudeBackgroundWork = + normalizedPayload.agentType === 'claude' && + typeof envelope.claudeRunningNonAgentTask === 'boolean' && + // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. + (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) + // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. + warnOnHookEnvOrVersionMismatch(this.state, { + version: envelope.version, + env: envelope.env, + expectedEnv: this.env + }) + const event = { + paneKey, + source, + launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, + promptInteractionKey, + hookEventName, + providerPromptId, + compactTrigger, + toolUseId, + toolAgentId, + teammateName, + toolAgentType, + providerSession, + providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + isReplay: envelope.isReplay === true ? true : undefined, + claudeRunningNonAgentTask: + typeof envelope.claudeRunningNonAgentTask === 'boolean' + ? envelope.claudeRunningNonAgentTask + : undefined, + payload: normalizedPayload + } as AgentHookEventPayload + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus( + event, + applyClaudeBackgroundWork + ? () => { + if (envelope.claudeRunningNonAgentTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + } + : undefined + ) + } +} diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts new file mode 100644 index 00000000000..822c7e76f02 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -0,0 +1,104 @@ +import { track } from '../../telemetry/client' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerIngestNormalization } from './server-ingest-normalization' + +export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { + ingestTerminalStatus(event: { + paneKey: string + tabId?: string + worktreeId?: string + connectionId?: string | null + payload: ParsedAgentStatusPayload + }): void { + const physicalPaneKey = event.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + const reportedTabId = + event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + return + } + const worktreeId = + event.worktreeId !== undefined && event.worktreeId.trim().length > 0 + ? event.worktreeId.trim() + : undefined + const connectionId = + typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 + ? event.connectionId.trim() + : null + const previous = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + previous?.claudeLeadBoundaryChildOnly === true && + previous.payload.agentType === 'claude' && + event.payload.agentType === 'claude' + ) { + // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + return + } + // Why: preserve the hook-completed turn stamp while OSC repaints the current state. + const preserveActiveTurnStamp = + previous?.payload.turnCompletedAt !== undefined && + previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) + if ( + !previous?.restoredUnconfirmed && + previous?.connectionId === connectionId && + previous.tabId === tabId && + previous.worktreeId === worktreeId && + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) + ) { + return + } + // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is + // never evidence that the session ended — yet overwriting the row dropped the cached identity. + // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which + // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). + // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. + // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an + // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so + // it must not be read as a mismatch and strip the session the renderer would have kept. + const claimedAgentType = + event.payload.agentType && event.payload.agentType !== 'unknown' + ? event.payload.agentType + : undefined + const preservedProviderSession = + previous?.providerSession && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && + (previous.payload.state !== 'done' || event.payload.state === 'done') + ? previous.providerSession + : undefined + // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. + this.applyNormalizedStatus( + { + paneKey, + tabId, + worktreeId, + connectionId, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: event.payload + }, + undefined, + 'osc' + ) + } +} diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts new file mode 100644 index 00000000000..9beb0ad0bbb --- /dev/null +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -0,0 +1,197 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID } from 'node:crypto' + +import { + CLAUDE_STATUSLINE_PATHNAME, + parseClaudeStatusLineBody +} from '../../../shared/claude-statusline-rate-limits' +import { mergeAgentHookRequestHeaders } from '../../../shared/agent-hook-listener/hook-envelope' +import { readRequestBody } from '../../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' +import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' +import { trackEmptyPaneKeyHook } from './server-transport-rules' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { + /** Start the loopback listener after hydration and spool replay have settled. */ + async start(options?: { + env?: string + userDataPath?: string + endpointNamespace?: string + }): Promise { + if (this.server) { + return + } + + if (options?.env) { + this.env = options.env + } + if (options?.userDataPath) { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.configureEndpointPaths(options.userDataPath, options.endpointNamespace) + } + this.token = randomUUID() + this.endpointFileWritten = false + this.lastWrittenJson = null + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + // Why: authenticate before spending work reading an untrusted body. + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + // Why: bound request time so a stalled client can't hold a socket open (slowloris). + // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + let destroyedBySlowlorisCap = false + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + destroyedBySlowlorisCap = true + req.destroy() + }) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + try { + const body = await readRequestBody(req) + if (pathname === CLAUDE_STATUSLINE_PATHNAME) { + const statusLineEvent = parseClaudeStatusLineBody(body) + if (statusLineEvent) { + this.onClaudeStatusLine?.(statusLineEvent) + } + res.writeHead(204) + res.end() + return + } + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + // Why: merge transport headers before normalization so relay-compatible fields have one canonical path. + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) + const normalized = this.normalizeLocalHookPayload(source, aliasedBody) + const statusDisposition = normalized.event + ? this.getAgentStatusDisposition(normalized.event.paneKey, { + source, + hookEventName: normalized.event.hookEventName, + isReplay: normalized.event.isReplay, + hasExplicitPrompt: normalized.event.hasExplicitPrompt, + launchToken: normalized.event.launchToken + }) + : 'suppress' + if (normalized.event && statusDisposition !== 'suppress') { + const event = + statusDisposition === 'restart' + ? { ...normalized.event, launchToken: undefined } + : normalized.event + if (statusDisposition === 'restart') { + // Why: a retired pane accepting a new turn is a different agent session behind the + // same key — later observations must not be ordered against the retired one. + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) + this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + } + res.writeHead(204) + res.end() + } catch (error) { + // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut + // by something on the loopback path, not by a bad payload. Fail open as before, but count it — + // this is the one failure mode that silently stops status for every runtime at once. + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) + } + // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. + res.writeHead(204) + res.end() + } + } + // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + this.server = createServer((req, res) => { + void handleRequest(req, res) + }) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. + this.flushStatusPersistSync() + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.env = 'production' + this.onAgentStatus = null + this.onPaneStatusCleared = null + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllCodexSubagentPolls() + this.endpointDir = null + this.endpointFilePathCache = null + this.endpointFileWritten = false + this.lastStatusFilePath = null + this.lastWrittenJson = null + this.runtimeObservedStatusPaneKeys.clear() + this.hydratedAuthorityCommitments = Object.freeze([]) + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + this.revokedHydratedAuthorityCommitments = new WeakSet() + this.currentAuthorityObservations.clear() + this.promptSentDedupeByPaneKey.clear() + this.closedAgentStatusTabIds.clear() + this.closedAgentStatusPaneKeys.clear() + this.restartedStatusLaunchTokenHashByPaneKey.clear() + this.retiredPaneFencesByKey.clear() + this.connectionTimestampWatermarkById.clear() + this.legacyPaneKeyAliases.clear() + // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. + clearAllListenerCaches(this.state) + this.notifyStatusChangeListeners() + } +} diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts new file mode 100644 index 00000000000..08d2ef21a70 --- /dev/null +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -0,0 +1,218 @@ +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../shared/agent-status-types' +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference' +import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload, + StatusDropListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerState } from './server-state' + +export abstract class AgentHookServerListeners extends AgentHookServerState { + /** + * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). + * Why: the listener fails open on every request error, so without this the only symptom is + * agent status quietly going stale — for every runtime at once, since they share this transport. + */ + setTransportInterferenceListener( + listener: ((report: HookTransportInterferenceReport) => void) | null + ): void { + this.onTransportInterference = listener + } + + setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { + this.onAgentStatus = listener + if (!listener) { + return + } + // Why: replay is best-effort per pane so one throwing listener can't starve the rest. + for (const payload of this.state.lastStatusByPaneKey.values()) { + try { + // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. + listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) + } catch (err) { + console.error('[agent-hooks] replay listener threw', err) + } + } + } + + // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + setClaudeStatusLineListener( + listener: ((event: ClaudeStatusLineRateLimits) => void) | null + ): void { + this.onClaudeStatusLine = listener + } + + subscribeStatusChanges(listener: (statuses: AgentHookStatusChangeEntry[]) => void): () => void { + this.statusChangeListeners.add(listener) + return () => { + this.statusChangeListeners.delete(listener) + } + } + + subscribeProviderSessionChanges( + listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void + ): () => void { + this.providerSessionChangeListeners.add(listener) + return () => { + this.providerSessionChangeListeners.delete(listener) + } + } + + /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user + * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned + * cleanup (synthetic spinners) still has to retire with the row it was driving. */ + subscribeStatusDrop(listener: StatusDropListener): () => void { + this.statusDropListeners.add(listener) + return () => { + this.statusDropListeners.delete(listener) + } + } + + protected emitStatusDropped(paneKey: string): void { + for (const listener of this.statusDropListeners) { + // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. + try { + listener(paneKey) + } catch (err) { + console.error('[agent-hooks] status-drop listener threw', err) + } + } + } + + /** Multi-subscriber tap on every enriched status change (no replay). */ + subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { + this.enrichedStatusListeners.add(listener) + return () => { + this.enrichedStatusListeners.delete(listener) + } + } + + /** Replay is durable evidence from a prior runtime, not a live observation. */ + protected withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + setPaneStatusClearListener(listener: ((clear: AgentStatusClearIpcPayload) => void) | null): void { + this.onPaneStatusCleared = listener + } + + /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` + * (a single slot the main window owns and drops on close) this survives window + * teardown and exists at all under headless serve, which never opens one. */ + subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void { + this.paneStatusClearListeners.add(listener) + return () => { + this.paneStatusClearListeners.delete(listener) + } + } + + protected emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { + this.onPaneStatusCleared?.(clear) + for (const listener of this.paneStatusClearListeners) { + // Why: callers are pane/connection teardown paths; one throwing subscriber must + // not strand the rest, matching every other fan-out here. + try { + listener(clear) + } catch (err) { + console.error('[agent-hooks] pane-status-clear listener threw', err) + } + } + } + + /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the + * dashboard catches up on hook events that fired during startup. */ + getStatusSnapshot(): AgentStatusIpcPayload[] { + return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => + toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) + ) + } + + /** Provider-session identities, including Pi's metadata-only rows. */ + getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { + return this.buildStatusChangeNotification().providerSessions + } + + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + const entry = this.state.lastStatusByPaneKey.get(paneKey) + return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + } + + getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { + return this.hydratedAuthorityCommitments + } + + getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { + return Object.freeze( + Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) + ) + } + + protected buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } { + const statuses: AgentHookStatusChangeEntry[] = [] + const providerSessions: AgentHookProviderSessionIdentity[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if (enriched.providerSession) { + providerSessions.push({ + paneKey, + sessionId: enriched.providerSession.id, + ...(enriched.providerSession.transcriptPath + ? { transcriptPath: enriched.providerSession.transcriptPath } + : {}), + ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) + }) + } + if (!enriched.providerSessionOnly) { + statuses.push({ + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + }) + } + } + return { statuses, providerSessions } + } + + protected notifyStatusChangeListeners(): void { + if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { + return + } + const { statuses, providerSessions } = this.buildStatusChangeNotification() + for (const listener of this.statusChangeListeners) { + try { + listener(statuses) + } catch (err) { + console.error('[agent-hooks] status-change listener threw', err) + } + } + for (const listener of this.providerSessionChangeListeners) { + try { + listener(providerSessions) + } catch (err) { + console.error('[agent-hooks] provider-session listener threw', err) + } + } + } + + getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { + return this.buildStatusChangeNotification().statuses + } + + /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ + _getStateForTests(): HookListenerState { + return this.state + } +} diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts new file mode 100644 index 00000000000..061646731ee --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto' + +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../../shared/agent-status-types' +import { isAgentHookSource } from '../../../shared/agent-hook-relay' +import { normalizeClaudePromptId } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookAuthorityEvidence, EnrichedAgentHookEventPayload } from './server-types' +import { isValidPaneKey, isValidPiProviderSessionOnly } from './server-status-identity' + +export function dropHydratedIdleClaudeSubagents( + payload: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !payload.subagents?.some((subagent) => subagent.state === 'idle') + ) { + return payload + } + const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') + // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. + return { + ...payload, + subagents: activeSubagents.length > 0 ? activeSubagents : undefined + } +} + +export function sanitizeHydratedEntry( + paneKey: string, + rawEntry: unknown +): EnrichedAgentHookEventPayload | null { + const parsedPaneKey = parsePaneKey(paneKey) + if (!parsedPaneKey) { + return null + } + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + if (record.paneKey !== paneKey) { + return null + } + const tabId = record.tabId + if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { + return null + } + // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. + if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { + return null + } + const worktreeId = record.worktreeId + if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { + return null + } + const receivedAt = record.receivedAt + if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { + return null + } + const stateStartedAt = record.stateStartedAt + if ( + typeof stateStartedAt !== 'number' || + !Number.isFinite(stateStartedAt) || + stateStartedAt <= 0 + ) { + return null + } + // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. + const connectionIdRaw = record.connectionId + let connectionId: string | null + if (connectionIdRaw === null || connectionIdRaw === undefined) { + connectionId = null + } else if (typeof connectionIdRaw === 'string') { + connectionId = connectionIdRaw + } else { + return null + } + const payload = normalizeAgentStatusPayload(record.payload) + if (!payload) { + return null + } + const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined + const providerSessionOnly = record.providerSessionOnly === true + const retainedForLiveness = record.retainedForLiveness === true + const validRetainedIdentity = Boolean( + retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' + ) + if ( + providerSessionOnly && + !isValidPiProviderSessionOnly(providerSession, payload.agentType) && + !validRetainedIdentity + ) { + return null + } + const source = isAgentHookSource(record.source) ? record.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') + ? record.compactTrigger + : undefined + return { + paneKey, + source, + tabId: typeof tabId === 'string' ? tabId : undefined, + worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + connectionId, + hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, + hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, + providerPromptId, + compactTrigger, + toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, + toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, + teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, + toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, + providerSession, + providerSessionOnly: providerSessionOnly ? true : undefined, + retainedForLiveness: retainedForLiveness ? true : undefined, + payload, + receivedAt, + stateStartedAt + } +} + +export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { + return launchTokenHash + } + const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' + return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null +} + +export function sanitizePersistedAuthorityCommitment( + paneKey: string, + value: unknown +): AgentHookAuthorityEvidence | null { + if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + const connectionId = record.connectionId + const observedAt = record.observedAt + if ( + !/^[a-f0-9]{64}$/.test(launchTokenHash) || + (connectionId !== null && typeof connectionId !== 'string') || + typeof observedAt !== 'number' || + !Number.isFinite(observedAt) + ) { + return null + } + return Object.freeze({ + paneKey, + launchTokenHash, + connectionId: connectionId as string | null, + ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), + ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), + observedAt + }) +} + +export function authorityCommitmentsMatch( + left: AgentHookAuthorityEvidence, + right: AgentHookAuthorityEvidence +): boolean { + return ( + left.paneKey === right.paneKey && + left.launchTokenHash === right.launchTokenHash && + left.connectionId === right.connectionId && + left.tabId === right.tabId && + left.worktreeId === right.worktreeId + ) +} diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts new file mode 100644 index 00000000000..ecb33c44d37 --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -0,0 +1,141 @@ +import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createHash, randomUUID } from 'node:crypto' + +import { isValidPaneKey } from './server-status-identity' +import { LAST_STATUS_FILE_VERSION, STATUS_PERSIST_DEBOUNCE_MS } from './server-constants' +import type { + EnrichedAgentHookEventPayload, + LastStatusFile, + PersistedAgentHookAuthorityCommitment, + PersistedAgentHookEventPayload +} from './server-types' +import { authorityCommitmentsMatch } from './server-persistence-validation' +import { AgentHookServerHydration } from './server-hydration' + +export abstract class AgentHookServerPersistence extends AgentHookServerHydration { + protected serializeStatusFile(): string { + const entries: Record = {} + const authorityCommitments: Record = {} + const conflictedCommitments = new Set() + for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { + authorityCommitments[paneKey] = { ...commitment } + } + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { + // Why: never persist invalid keys (matches the hydrate-path invariant). + if (!isValidPaneKey(paneKey)) { + continue + } + const enrichedPayload = payload as EnrichedAgentHookEventPayload + const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true + const { + claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + promptInteractionKey: _promptInteractionKey, + // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + restoredUnconfirmed: _restoredUnconfirmed, + // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). + observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, + launchToken, + ...persistedPayload + } = enrichedPayload + const launchTokenHash = launchToken?.trim() + ? createHash('sha256').update(launchToken.trim()).digest('hex') + : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + entries[paneKey] = { + ...persistedPayload, + ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), + ...(launchTokenHash ? { launchTokenHash } : {}) + } + const commitment = this.toAuthorityEvidence(payload, launchTokenHash) + if (commitment && !conflictedCommitments.has(paneKey)) { + const existing = authorityCommitments[paneKey] + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + delete authorityCommitments[paneKey] + conflictedCommitments.add(paneKey) + } else { + authorityCommitments[paneKey] = { ...commitment } + } + } + } + const file: LastStatusFile = { + version: LAST_STATUS_FILE_VERSION, + entries, + authorityCommitments + } + return JSON.stringify(file) + } + + protected scheduleStatusPersist(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: reset the timer each call so the write fires only after the last event in a burst. + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + } + this.statusPersistTimer = setTimeout(() => { + this.statusPersistTimer = null + this.runStatusPersist() + }, STATUS_PERSIST_DEBOUNCE_MS) + // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. + if (typeof this.statusPersistTimer.unref === 'function') { + this.statusPersistTimer.unref() + } + } + + flushStatusPersistSync(): void { + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + this.statusPersistTimer = null + } + if (!this.lastStatusFilePath) { + return + } + this.runStatusPersist() + } + + protected runStatusPersist(): void { + if (!this.lastStatusFilePath || !this.endpointDir) { + return + } + const json = this.serializeStatusFile() + if (json === this.lastWrittenJson) { + return + } + const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) + let tmpWritten = false + try { + mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + try { + chmodSync(this.endpointDir, 0o700) + } catch { + // best-effort + } + } + writeFileSync(tmpPath, json, { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, this.lastStatusFilePath) + this.lastWrittenJson = json + } catch (err) { + console.warn('[agent-hooks] failed to write last-status file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp already gone + } + } + } + } + + _resetPromptSentDedupeForTests(): void { + this.promptSentDedupeByPaneKey.clear() + } + + _resetConnectionTimestampWatermarksForTests(): void { + this.connectionTimestampWatermarkById.clear() + } +} diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts new file mode 100644 index 00000000000..7805303ced1 --- /dev/null +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -0,0 +1,124 @@ +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasWorkingSubagent, + claudeRosterToSnapshots +} from '../../../shared/claude-subagent-roster' +import { reapRestoredClaudeSubagentsForDeadPane } from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { AgentHookServerTabCleanup } from './server-tab-cleanup' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { + /** Second reap path for restored Claude subagent rows: drop the ones whose pane + * has no live local agent process behind it any more. A PTY that dies while Orca + * is down never runs the teardown that clears pane state, so hydrate rebuilds a + * roster nothing can ever retire — the inventory reap needs the parent to emit a + * complete `background_tasks` list and an idle parent never does. The row then + * gates the pane 'working' for the rest of its life and hibernation, which + * requires 'done', can never reclaim the agent's heap. + * + * Both the execution host and relay binding must prove local ownership before + * targeted PTY liveness is consulted. Panes that reported in this runtime are + * also skipped. Returns the number of panes changed. */ + async reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalExecutionHost: (worktreeId: string | undefined) => boolean, + isLocalPaneAgentLive: (paneKey: string) => Promise, + isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean + ): Promise { + const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if ( + enriched.payload.agentType === 'claude' && + enriched.connectionId === null && + isLocalExecutionHost(enriched.worktreeId) && + // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, + // or a background-task/cron latch nothing will refresh, strands the pane just as + // permanently — and unlike the roster case there is no child event left to reap it. + (claudeRosterHasRestoredSnapshotSubagent( + this.state.claudeSubagentRosterByPaneKey.get(paneKey) + ) || + enriched.payload.state !== 'done' || + this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && + !this.runtimeObservedStatusPaneKeys.has(paneKey) + ) { + candidates.push({ paneKey, entry: enriched }) + } + } + const liveness = await Promise.all( + candidates.map(async (candidate) => { + try { + return await isLocalPaneAgentLive(candidate.paneKey) + } catch { + return true + } + }) + ) + let changedPanes = 0 + for (const [index, candidate] of candidates.entries()) { + const { paneKey, entry: enriched } = candidate + if ( + liveness[index] || + !isLocalPaneLivenessEvidenceCurrent(paneKey) || + this.state.lastStatusByPaneKey.get(paneKey) !== enriched || + this.runtimeObservedStatusPaneKeys.has(paneKey) || + !isLocalExecutionHost(enriched.worktreeId) + ) { + continue + } + if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably + // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the + // pane the same way an observed exit would — otherwise the widened candidate set is inert. + // + // Why delete rather than downgrade to `done` like the reap branch below: that branch has a + // real turn to describe — a parent whose children it just reaped — while these panes' only + // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would + // invent a completion that never happened, and leaving it non-terminal keeps the bug. This + // sweep stands in for the exit Orca never observed, so it does what that exit does: + // `clearProviderPtyState` -> `clearPaneState`. + if (this.hasLiveClaimsForPaneKey(paneKey)) { + this.clearPaneState(paneKey) + changedPanes += 1 + } + continue + } + changedPanes += 1 + const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) + const subagents = claudeRosterToSnapshots(roster) + // Why: the pane's persisted 'working' was the child gate holding a finished + // lead open (subagent events never set lead state). With the last working row + // gone and no process left to report, 'done' is the only truthful state — and + // the one hibernation needs once this pane's agent is restored. + const state = + enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) + ? 'done' + : enriched.payload.state + const stateChanged = state !== enriched.payload.state + const reconciledAt = stateChanged + ? Math.max(Date.now(), enriched.receivedAt + 1) + : enriched.receivedAt + // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying + // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. + const { restoredUnconfirmed, ...reconciledBase } = enriched + const reconciled: EnrichedAgentHookEventPayload = { + ...reconciledBase, + ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + receivedAt: reconciledAt, + stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } + } + this.state.lastStatusByPaneKey.set(paneKey, reconciled) + } + if (changedPanes > 0) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + return changedPanes + } +} diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts new file mode 100644 index 00000000000..e5b9e4c1363 --- /dev/null +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' +import { + getEndpointFileName, + writeEndpointFile +} from '../../../shared/agent-hook-listener/endpoint-publication' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../../shared/agent-hook-types' +import { AgentHookServerIngestRemote } from './server-ingest-remote' + +export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT + } + // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. + if (this.endpointFileWritten && this.endpointFilePathCache) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache + } + return env + } + + get endpointFilePath(): string | null { + return this.endpointFilePathCache + } + + /** Test/diagnostic accessor for the on-disk last-status file path. */ + get lastStatusPath(): string | null { + return this.lastStatusFilePath + } + + protected maybeWriteEndpointFile(): void { + if (!this.endpointDir || !this.endpointFilePathCache) { + return + } + this.endpointFileWritten = false + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT + }) + this.endpointFileWritten = ok + } + + protected configureEndpointPaths(userDataPath: string, endpointNamespace?: string): void { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.endpointDir = endpointNamespace + ? join(userDataPath, 'agent-hooks', endpointNamespace) + : join(userDataPath, 'agent-hooks') + this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) + this.lastStatusFilePath = join(this.endpointDir, 'last-status.json') + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts new file mode 100644 index 00000000000..956be136ca6 --- /dev/null +++ b/src/main/agent-hooks/server/server-state.ts @@ -0,0 +1,217 @@ +import type { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' + +import { + createHookListenerState, + type HookListenerState +} from '../../../shared/agent-hook-listener/listener-state' +import { + createHookTransportInterferenceTracker, + describeHookTransportInterference, + type HookTransportInterferenceReport +} from '../../../shared/agent-hook-transport-interference' +import { + AgentStatusObservationSequencer, + createAgentStatusAuthorityId, + type AgentStatusObservation, + type AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { SpoolRecord } from '../../../shared/agent-hook-spool' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + AgentPromptSentDedupeEntry, + EnrichedAgentHookEventPayload, + NormalizedLocalHook, + PaneKeyAliasEntry, + PaneKeyAliasPersistenceListener, + PaneStatusClearListener, + ProviderSessionChangeListener, + RetiredPaneAlias, + RetiredPaneFence, + ServerAgentStatusListener, + ServerStatusLineListener, + StatusChangeListener, + StatusDropListener +} from './server-types' + +/** Shared mutable state for the layered hook-server implementation. */ +export abstract class AgentHookServerState { + protected server: ReturnType | null = null + protected port = 0 + protected token = '' + // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. + protected env = 'production' + protected onAgentStatus: ServerAgentStatusListener = null + protected onClaudeStatusLine: ServerStatusLineListener = null + protected onPaneStatusCleared: PaneStatusClearListener | null = null + protected paneStatusClearListeners = new Set() + protected statusDropListeners = new Set() + protected statusChangeListeners = new Set() + protected providerSessionChangeListeners = new Set() + // Why: setListener is a single slot owned by the main-window fanout; the + // plugin event bus (and future consumers) need an additive subscription + // that also works in headless serve, where no window listener exists. + protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() + // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). + protected endpointDir: string | null = null + protected endpointFilePathCache: string | null = null + protected endpointFileWritten = false + // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. + protected state: HookListenerState = createHookListenerState() + protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = + null + protected transportInterference = createHookTransportInterferenceTracker( + (report: HookTransportInterferenceReport) => { + console.warn(describeHookTransportInterference(report)) + this.onTransportInterference?.(report) + } + ) + // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. + protected runtimeObservedStatusPaneKeys = new Set() + protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) + protected hydratedLaunchTokenHashByPaneKey = new Map() + protected persistedAuthorityCommitmentsByPaneKey = new Map() + protected revokedHydratedAuthorityCommitments = new WeakSet() + protected currentAuthorityObservations = new Map() + protected legacyPaneKeyAliases = new Map() + // Why: indexed by every key the retirement fenced, so a re-attach on any of them + // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps + // it mirrors; an evicted record simply degrades to lifting the key it was handed. + protected retiredPaneFencesByKey = new Map() + protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null + // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. + protected lastStatusFilePath: string | null = null + // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. + protected statusPersistTimer: ReturnType | null = null + protected assistantMessageRetryTimers = new Map>() + protected promptSentDedupeByPaneKey = new Map() + protected activeHookTurnCompletedAtByPaneKey = new Map() + protected promptSentHashSalt = randomBytes(16).toString('hex') + protected closedAgentStatusTabIds = new Set() + protected closedAgentStatusPaneKeys = new Set() + protected restartedStatusLaunchTokenHashByPaneKey = new Map() + protected connectionTimestampWatermarkById = new Map() + // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. + protected lastWrittenJson: string | null = null + // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own + // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. + protected readonly observations = new AgentStatusObservationSequencer( + createAgentStatusAuthorityId('main-agent-hooks') + ) + + protected abstract withdrawReplayObservation(paneKey: string): void + protected abstract ingestSpoolRecord(record: SpoolRecord): void + protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void + protected abstract buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } + protected abstract notifyStatusChangeListeners(): void + protected abstract markTabClosedForAgentStatus(tabId: string): void + protected abstract getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' + protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean + protected abstract recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void + protected abstract markPaneClosedForAgentStatus(paneKey: string): void + protected abstract attachStatusTiming( + payload: AgentHookEventPayload, + now?: number + ): EnrichedAgentHookEventPayload + protected abstract hashPromptForTelemetryDedupe(prompt: string): string + protected abstract maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void + protected abstract stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation + protected abstract applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin?: AgentStatusObservationOrigin + ): EnrichedAgentHookEventPayload + protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void + protected abstract clearAssistantMessageRetry(paneKey: string): void + protected abstract clearCodexSubagentPoll(paneKey: string): void + protected abstract clearAllCodexSubagentPolls(): void + protected abstract scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void + protected abstract scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt?: number, + discoveryReady?: boolean + ): void + protected abstract applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void + protected abstract getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] + protected abstract notifyPaneKeyAliasPersistenceListener(): void + protected abstract boundPaneKeyAliases(): void + protected abstract getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string + protected abstract restoreRetiredPaneFence(fence: RetiredPaneFence): void + protected abstract revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean + protected abstract resolvePaneKeyAlias(paneKey: string): string + protected abstract normalizeHookBodyPaneKeyAlias(body: unknown): unknown + protected abstract normalizeLocalHookPayload( + source: AgentHookSource, + body: unknown + ): NormalizedLocalHook + protected abstract setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void + protected abstract toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null + protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + protected abstract clearPaneState(paneKey: string): void + protected abstract deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null + protected abstract maybeWriteEndpointFile(): void + protected abstract hydrateLastStatusFromDisk(): void + protected abstract captureHydratedAuthorityCommitments(): void + protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void + protected abstract toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null + protected abstract serializeStatusFile(): string + protected abstract scheduleStatusPersist(): void + protected abstract runStatusPersist(): void + + abstract _getStateForTests(): HookListenerState + abstract _resetPromptSentDedupeForTests(): void + abstract _resetConnectionTimestampWatermarksForTests(): void +} diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts new file mode 100644 index 00000000000..7fbb6a96bc1 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' + +import { getCohortAtEmit } from '../../telemetry/cohort-classifier' +import { track } from '../../telemetry/client' +import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary' +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusObservation, + AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { agentTypeToPromptSentAgentKind } from './server-status-identity' +import { AgentHookServerStatusDisposition } from './server-status-disposition' + +export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { + protected attachStatusTiming( + payload: AgentHookEventPayload, + now = Date.now() + ): EnrichedAgentHookEventPayload { + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const commandCodeNewTurn = + previous !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: payload.payload.agentType, + previousState: previous.payload.state, + incomingState: payload.payload.state, + previousPrompt: previous.payload.prompt, + incomingPrompt: payload.payload.prompt, + hasExplicitPrompt: payload.hasExplicitPrompt, + previousPromptInteractionKey: previous.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) + const stateStartedAt = + previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn + ? previous.stateStartedAt + : now + // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. + return { + ...payload, + receivedAt: now, + stateStartedAt + } + } + + protected hashPromptForTelemetryDedupe(prompt: string): string { + return createHash('sha256') + .update(this.promptSentHashSalt) + .update('\0') + .update(prompt) + .digest('hex') + } + + protected maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void { + if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { + return + } + const prompt = payload.payload.prompt?.trim() ?? '' + if (prompt.length === 0) { + return + } + const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) + const promptHash = this.hashPromptForTelemetryDedupe(prompt) + const promptInteractionKey = + typeof payload.promptInteractionKey === 'string' && + payload.promptInteractionKey.trim().length > 0 + ? payload.promptInteractionKey.trim() + : undefined + const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) + const isCompletedTurnBoundary = + previousStatus?.payload.state === 'done' && payload.payload.state === 'working' + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey === promptInteractionKey && + (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) + ) { + return + } + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptHash === promptHash && + !( + previousStatus?.payload.state === 'done' && + payload.payload.state === 'done' && + previousDedupe.promptInteractionKey !== undefined && + promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey !== promptInteractionKey + ) && + !isCompletedTurnBoundary + ) { + return + } + this.promptSentDedupeByPaneKey.set(payload.paneKey, { + agentKind, + promptHash, + promptInteractionKey + }) + try { + // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. + track('agent_prompt_sent', { + agent_kind: agentKind, + launch_source: 'unknown', + request_kind: 'followup', + ...getCohortAtEmit() + }) + } catch (err) { + console.error('[agent-hooks] prompt-sent telemetry failed', err) + } + } + + /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet + * (STA-4293) — it is stamped here because every main-side ingress funnels through + * applyNormalizedStatus, so no origin can silently arrive untagged. */ + protected stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation { + return this.observations.observe(payload.paneKey, { + origin, + observedAt, + // Why: reuse the listener's own per-provider classifier; a second list of raw event-name + // literals here would strand the providers whose boundary event is named anything else. + boundary: + payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), + kind: payload.providerSessionOnly + ? 'identity-only' + : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the + // current state rather than announcing a change — neither is a fresh transition. + payload.isReplay === true || origin === 'osc' + ? 'snapshot' + : 'transition' + }) + } +} diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts new file mode 100644 index 00000000000..b6c69967280 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -0,0 +1,161 @@ +import { createHash } from 'node:crypto' + +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + RETIRED_PANE_FENCES_MAX +} from './server-constants' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import { AgentHookServerStatusInference } from './server-status-inference' + +export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { + protected markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. + this.closedAgentStatusTabIds.delete(tabId) + this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } + } + + protected getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneRetired = + this.closedAgentStatusPaneKeys.has(paneKey) || + this.closedAgentStatusPaneKeys.has(ownerPaneKey) + const tabId = parsePaneKey(ownerPaneKey)?.tabId + if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + return 'suppress' + } + if (!paneRetired) { + const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) + // Why: deferred retirement lets a new process start in a still-authorized pane, so + // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + if ( + event?.hookEventName === 'SessionStart' && + event.isReplay !== true && + tokenFence !== undefined + ) { + const startedLaunchToken = event.launchToken?.trim() + if (startedLaunchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(startedLaunchToken).digest('hex') + ) + return 'accept' + } + } + if (event && tokenFence) { + const launchToken = event.launchToken?.trim() + if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + return 'suppress' + } + } + return 'accept' + } + // Why: command completion retires launch authority but leaves its shell pane reusable. + // A live new-turn event proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + // Why the classifier, not literals: only 5 of 18 sources name their boundary + // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. + // Why four branches: `source` collapses to undefined when an older relay omits the field, + // when a newer host sends an unknown string, and when the wire value is malformed. Only an + // unknown string is valid future-provider evidence. Unreachable from the local path, which + // 404s an unresolvable source. + const isNewTurn = + event?.source !== undefined + ? isNewTurnEvent(event.source, event.hookEventName) + : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 + ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and + // the costs are asymmetric — a stranded pane is invisible and permanent with no user + // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. + true + : event?.rawSource === undefined + ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it + // cannot revive a provider whose boundary event is named anything else. + event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' + : false + // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in + // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no + // SessionStart at all, so without this its retired panes never come back. + const freshOpenCodeFamilyPrompt = + (event?.source === 'opencode' || event?.source === 'mimo-code') && + event.hookEventName === 'MessagePart' && + event.hasExplicitPrompt === true + // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up + // status on that launch token stops a stale process reclaiming the pane's row without + // restoring retired orchestration authority. + if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + const launchToken = event?.launchToken?.trim() + if (launchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(launchToken).digest('hex') + ) + } else { + this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) + } + return 'restart' + } + return 'suppress' + } + + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric + // keys never parse as stable ones — resolve both forms so neither slips the tab check. + protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { + const tabId = + parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined + return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) + } + + protected recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void { + const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } + for (const key of paneKeys) { + // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. + this.retiredPaneFencesByKey.delete(key) + this.retiredPaneFencesByKey.set(key, fence) + } + while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { + const oldest = this.retiredPaneFencesByKey.keys().next().value + if (oldest === undefined) { + break + } + this.retiredPaneFencesByKey.delete(oldest) + } + } + + protected markPaneClosedForAgentStatus(paneKey: string): void { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.add(paneKey) + while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { + const oldest = this.closedAgentStatusPaneKeys.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusPaneKeys.delete(oldest) + } + } +} diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts new file mode 100644 index 00000000000..41a87b4d7de --- /dev/null +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentKind } from '../../../shared/telemetry-events' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AGENT_PROMPT_SENT_AGENT_KINDS, TOOL_PROGRESS_HOOK_EVENTS } from './server-constants' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' + +export function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { + const normalized = agentType?.trim().toLowerCase() + if (!normalized || normalized === 'unknown') { + return 'other' + } + if (normalized === 'claude') { + return 'claude-code' + } + return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) + ? (normalized as AgentKind) + : 'other' +} + +export function equivalentInterruptAgentType( + actual: AgentType | undefined, + baseline: AgentType | undefined +): boolean { + const normalizedActual = actual === 'unknown' ? undefined : actual + const normalizedBaseline = baseline === 'unknown' ? undefined : baseline + return normalizedActual === normalizedBaseline +} + +// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. +export function isValidPaneKey(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null + ) +} + +// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. +export function isValidPiProviderSessionOnly( + providerSession: AgentProviderSessionMetadata | undefined, + agentType: AgentType | undefined +): boolean { + return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) +} + +export function toAgentStatusIpcPayload( + entry: EnrichedAgentHookEventPayload +): AgentStatusIpcPayload { + return { + paneKey: entry.paneKey, + ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + receivedAt: entry.receivedAt, + stateStartedAt: entry.stateStartedAt, + ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), + ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(entry.observation ? { observation: entry.observation } : {}), + ...entry.payload + } +} + +export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { + if (next.payload.state !== 'working') { + return false + } + if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { + return false + } + // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. + return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) +} + +export function paneCacheKeyTabId(key: string): string | null { + const paneKey = key.split('\0', 1)[0] ?? key + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +export function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { + return paneCacheKeyTabId(key) === tabId +} + +export function hashLaunchToken(value: string): string { + return createHash('sha256').update(value).digest('hex') +} diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts new file mode 100644 index 00000000000..ec651691982 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -0,0 +1,175 @@ +import { + markClaudeLeadTurnInterrupted, + clearClaudeAnsweredQuestionWait +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' +import { + isAgentInterruptInputIntent, + type AgentInterruptInferenceRequest +} from '../../../shared/agent-interrupt-intent' +import { + isAskUserQuestionTool, + type AgentQuestionAnsweredInferenceRequest +} from '../../../shared/agent-question-answered-intent' +import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + if (!isAgentInterruptInputIntent(request.intent)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + if (existing.providerSessionOnly) { + return false + } + // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + const agentType: AgentType | undefined = payload.agentType + // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. + if (agentType === 'droid' && request.intent === 'ctrl-c') { + return false + } + // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. + if ( + (agentType === 'opencode' || agentType === 'copilot') && + request.intent === 'plain-escape' && + request.inputCount !== 2 + ) { + return false + } + const dismissesClaudeQuestion = + agentType === 'claude' && + request.intent === 'plain-escape' && + payload.state === 'waiting' && + isAskUserQuestionTool(payload.toolName) + if (dismissesClaudeQuestion) { + return this.inferQuestionAnswered(request) + } + // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. + if ( + payload.state !== 'working' || + !equivalentInterruptAgentType(agentType, request.baselineAgentType) || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. + if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { + return false + } + // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + if ( + agentType === 'claude' && + (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) + ) { + return false + } + // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } + if (agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, existing.paneKey) + } + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: 'done', + prompt: payload.prompt, + agentType, + ...(payload.model ? { model: payload.model } : {}), + interrupted: true, + // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred interrupted agent status', { + paneKey: inferred.paneKey, + agentType, + intent: request.intent + }) + return true + } + + /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ + inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. + if ( + payload.agentType !== 'claude' || + payload.state !== 'waiting' || + !isAskUserQuestionTool(payload.toolName) + ) { + return false + } + if ( + payload.agentType !== request.baselineAgentType || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. + const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), + prompt: payload.prompt, + agentType: payload.agentType, + ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred resolved question status', { + paneKey: inferred.paneKey, + state: inferred.payload.state + }) + return true + } +} diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts new file mode 100644 index 00000000000..2757806b293 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -0,0 +1,155 @@ +import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../../shared/agent-hook-listener/grok-result-discovery' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { + ASSISTANT_MESSAGE_RETRY_ATTEMPTS, + ASSISTANT_MESSAGE_RETRY_MS, + CODEX_SUBAGENT_POLL_MS +} from './server-constants' +import { AgentHookServerStatusUpdate } from './server-status-update' + +type CodexSubagentPoll = { + source: AgentHookSource + body: unknown + original: EnrichedAgentHookEventPayload +} + +export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { + private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler( + CODEX_SUBAGENT_POLL_MS, + (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + ) + + protected clearAllCodexSubagentPolls(): void { + this.codexSubagentPollScheduler.clearAll() + } + + protected clearAssistantMessageRetry(paneKey: string): void { + const timer = this.assistantMessageRetryTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.assistantMessageRetryTimers.delete(paneKey) + } + + protected clearCodexSubagentPoll(paneKey: string): void { + this.codexSubagentPollScheduler.clear(paneKey) + } + + protected scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.codexSubagentPollScheduler.clear(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + } + + private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + const { source, body, original } = poll + // Keep the identity check at callback time: a newer event supersedes this + // payload even when its pane still has transcript children. + if ( + paneKey !== original.paneKey || + !this.server || + this.state.lastStatusByPaneKey.get(original.paneKey) !== original + ) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { + return + } + const subagentsChanged = + JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + this.scheduleCodexSubagentPoll(source, body, next) + } + + protected scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt = 1, + discoveryReady = false + ): void { + if ( + original.payload.lastAssistantMessage || + !hasPendingAgentResultText(source, body) || + attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS + ) { + return + } + this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, 1, true) + } + }) + .catch((err) => { + console.error('[agent-hooks] Grok result discovery failed:', err) + }) + return + } + } + const timer = setTimeout(() => { + try { + this.assistantMessageRetryTimers.delete(original.paneKey) + this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) + } catch (err) { + console.error('[agent-hooks] assistant message retry failed:', err) + } + }, ASSISTANT_MESSAGE_RETRY_MS) + this.assistantMessageRetryTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + protected applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const normalized = this.normalizeLocalHookPayload(source, body) + if (!normalized.event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) + return + } + // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. + this.applyNormalizedStatus(normalized.event, normalized.onAccepted) + } +} diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts new file mode 100644 index 00000000000..981da873e3d --- /dev/null +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -0,0 +1,219 @@ +import { + reconcileRemoteCodexState, + markCodexLeadTurnInterrupted +} from '../../../shared/agent-hook-listener/providers/codex-state' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../shared/agent-status-identity' +import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' +import type { EnrichedAgentHookEventPayload } from './server-types' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import { + attachClaudeChildOnlyBoundary, + attachClaudePermissionToolUseId, + invalidateClaudeChildOnlyBoundary, + shouldKeepClaudePermissionVisible +} from './server-claude-status-rules' +import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' +import { AgentHookServerStatusApplication } from './server-status-application' + +export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { + protected applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin: AgentStatusObservationOrigin = 'hook' + ): EnrichedAgentHookEventPayload { + if (payload.hookEventName === 'UserPromptSubmit') { + // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) + } + let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const connectionClearWatermark = payload.connectionId + ? this.connectionTimestampWatermarkById.get(payload.connectionId) + : undefined + // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. + const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const now = Math.max( + Date.now(), + (connectionClearWatermark ?? -1) + 1, + (restoredStatusWatermark ?? -1) + 1 + ) + if (payload.connectionId) { + this.connectionTimestampWatermarkById.set(payload.connectionId, now) + } + if (payload.providerSessionOnly) { + // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. + onAccepted?.() + const enriched = { + ...this.attachStatusTiming(payload, now), + observation: this.stampObservation(payload, origin, now) + } + this.clearAssistantMessageRetry(enriched.paneKey) + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + const stateReconciledPayload = + payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + ? { + ...payload, + payload: reconcileRemoteCodexState( + this.state, + payload.paneKey, + payload.hookEventName, + payload.toolAgentId, + payload.payload, + previous?.payload + ) + } + : payload + const previousCodexRoot = + stateReconciledPayload.payload.agentType === 'codex' && + stateReconciledPayload.toolAgentId && + previous?.payload.agentType === 'codex' + ? previous + : undefined + const preservedProviderSession = !stateReconciledPayload.providerSession + ? previousCodexRoot?.providerSession + : undefined + const preservedRootModel = !stateReconciledPayload.payload.model + ? previousCodexRoot?.payload.model + : undefined + // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. + const rootContextPreservingPayload = + preservedProviderSession || preservedRootModel + ? { + ...stateReconciledPayload, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: preservedRootModel + ? { ...stateReconciledPayload.payload, model: preservedRootModel } + : stateReconciledPayload.payload + } + : stateReconciledPayload + const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( + previous, + rootContextPreservingPayload + ) + if (boundaryReconciledPrevious !== previous) { + previous = boundaryReconciledPrevious + if (previous) { + this.state.lastStatusByPaneKey.set(previous.paneKey, previous) + this.scheduleStatusPersist() + } + } + const identity = resolveAgentStatusIdentity({ + existing: previous + ? { + agentType: previous.payload.agentType, + state: previous.payload.state, + updatedAt: previous.receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed + } + : undefined, + incoming: rootContextPreservingPayload.payload.agentType, + now + }) + if ( + previous && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: rootContextPreservingPayload.payload.state + }) + ) { + return previous + } + const identityResolvedPayload = + identity.agentType === rootContextPreservingPayload.payload.agentType + ? rootContextPreservingPayload + : { + ...rootContextPreservingPayload, + payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } + } + const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) + if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + return previous + } + // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'done' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + ) { + return previous + } + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'working' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + (effectivePayload.isReplay === true || + isToolProgressWorkingAfterInterrupt(effectivePayload) || + (effectivePayload.hasExplicitPrompt !== true && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) + ) { + if (effectivePayload.payload.agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) + } + return previous + } + if ( + effectivePayload.payload.state !== 'done' || + effectivePayload.payload.lastAssistantMessage + ) { + this.clearAssistantMessageRetry(effectivePayload.paneKey) + } + onAccepted?.() + if (!identity.inheritedFromActivePane) { + this.maybeTrackAgentPromptSent(effectivePayload, previous) + } + const enriched = { + ...this.attachStatusTiming(boundaryAwarePayload, now), + observation: this.stampObservation(boundaryAwarePayload, origin, now) + } + if ( + typeof enriched.payload.turnCompletedAt === 'number' && + Number.isFinite(enriched.payload.turnCompletedAt) + ) { + this.activeHookTurnCompletedAtByPaneKey.set( + enriched.paneKey, + enriched.payload.turnCompletedAt + ) + } + // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. + if (enriched.restoredUnconfirmed) { + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + } else { + this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) + } + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + + // Why: every status emit must reach plugins too, so a new early-return path + // upstream cannot silently leave the plugin tap behind the main-window fanout. + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } +} diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts new file mode 100644 index 00000000000..4abacfc81d0 --- /dev/null +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -0,0 +1,122 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { paneCacheKeyMatchesTab } from './server-status-identity' +import { AgentHookServerCleanup } from './server-cleanup' + +export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { + /** Drop every status/cache claim attributable to a closed tab prefix. */ + dropStatusEntriesByTabPrefix(tabId: string): void { + this.markTabClosedForAgentStatus(tabId) + const paneKeysToClear = new Set() + for (const key of this.state.lastStatusByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key) + } + } + for (const key of this.state.lastPromptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.lastToolByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.ampCompletedCacheKeys) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const paneKey of this.runtimeObservedStatusPaneKeys) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const commitment of this.hydratedAuthorityCommitments) { + if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { + paneKeysToClear.add(commitment.paneKey) + } + } + let aliasChanged = false + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeysToClear.add(legacyPaneKey) + paneKeysToClear.add(entry.stablePaneKey) + this.markPaneClosedForAgentStatus(legacyPaneKey) + this.markPaneClosedForAgentStatus(entry.stablePaneKey) + aliasChanged = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) + let statusChanged = false + for (const paneKey of paneKeysToClear) { + if (this.state.lastStatusByPaneKey.has(paneKey)) { + statusChanged = true + } + this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) + clearPaneCacheState(this.state, paneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) + this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.currentAuthorityObservations.delete(paneKey) + this.promptSentDedupeByPaneKey.delete(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + clearPaneState(paneKey: string): void { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, resolvedPaneKey]) + // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. + const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + clearPaneCacheState(this.state, resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + let clearedAlias = false + for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) { + if (alias.stablePaneKey === resolvedPaneKey) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeys.add(legacyPaneKey) + paneKeys.add(alias.stablePaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) + clearedAlias = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + if (clearedAlias) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) + } + } +} diff --git a/src/main/agent-hooks/server/server-transport-rules.ts b/src/main/agent-hooks/server/server-transport-rules.ts new file mode 100644 index 00000000000..2f1aacc370e --- /dev/null +++ b/src/main/agent-hooks/server/server-transport-rules.ts @@ -0,0 +1,13 @@ +import { track } from '../../telemetry/client' + +/** Keep unattributed hook deliveries visible in telemetry without rejecting the request. */ +export function trackEmptyPaneKeyHook(body: unknown): void { + if (typeof body !== 'object' || body === null) { + return + } + const paneKey = (body as Record).paneKey + if (typeof paneKey === 'string' && paneKey.trim().length > 0) { + return + } + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) +} diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts new file mode 100644 index 00000000000..913bcd7067e --- /dev/null +++ b/src/main/agent-hooks/server/server-types.ts @@ -0,0 +1,113 @@ +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusClearIpcPayload, + AgentStatusState +} from '../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../shared/agent-status-observation' +import type { AgentKind } from '../../../shared/telemetry-events' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' + +// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). +export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { + receivedAt: number + stateStartedAt: number + /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ + observation?: AgentStatusObservation + /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + restoredUnconfirmed?: true + /** User-hidden resume identity retained solely for destructive liveness checks. */ + retainedForLiveness?: true + /** Persisted proof that a lead boundary was held working only by child agents. */ + claudeLeadBoundaryChildOnly?: true +} + +export type PersistedAgentHookEventPayload = Omit< + EnrichedAgentHookEventPayload, + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + // Why: revision counters are in-memory and the authority id is regenerated per process, so + // a stored observation could only rehydrate as a stale ordering claim from a dead authority. + | 'observation' +> & { + launchTokenHash?: string +} + +export type PersistedAgentHookAuthorityCommitment = { + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +} + +export type AgentHookStatusChangeEntry = { + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export type AgentHookProviderSessionIdentity = { + paneKey: string + sessionId: string + transcriptPath?: string + worktreeId?: string +} + +export type AgentHookAuthorityEvidence = Readonly<{ + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +}> + +export type AgentHookAuthorityAttestation = Readonly<{ + paneKey: string + source: 'current_hook' | 'hydrated_commitment' +}> + +export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type ProviderSessionChangeListener = ( + providerSessions: AgentHookProviderSessionIdentity[] +) => void +export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void +export type StatusDropListener = (paneKey: string) => void +export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void + +export type PaneKeyAliasEntry = { + stablePaneKey: string + ptyId: string | null + updatedAt: number + authorityVerified: boolean +} +export type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } +/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ +export type RetiredPaneFence = { + paneKeys: readonly string[] + aliases: readonly RetiredPaneAlias[] +} + +export type LastStatusFile = { + version: number + entries: Record + authorityCommitments?: Record +} + +export type AgentPromptSentDedupeEntry = { + agentKind: AgentKind + promptHash: string + promptInteractionKey?: string +} + +export type NormalizedLocalHook = { + event: AgentHookEventPayload | null + onAccepted?: () => void +} + +export type ServerStatusLineListener = ((event: ClaudeStatusLineRateLimits) => void) | null +export type ServerAgentStatusListener = ((payload: EnrichedAgentHookEventPayload) => void) | null diff --git a/src/main/browser/agent-browser-bridge-capture-commands.ts b/src/main/browser/agent-browser-bridge-capture-commands.ts new file mode 100644 index 00000000000..5719bfbc7b6 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-capture-commands.ts @@ -0,0 +1,185 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { BrowserScreenshotResult, BrowserEvalResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { captureFullPageScreenshot } from './cdp-screenshot' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeUtilityCommands } from './agent-browser-bridge-utility-commands' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' + +export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBridgeUtilityCommands { + async screenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) + }, + { ensureVisible: false } + ) + } + + async fullPageScreenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + return this.captureFullPageScreenshotCommand( + sessionName, + target.webContentsId, + 500, + format === 'jpeg' ? 'jpeg' : 'png' + ) + }, + { ensureVisible: false } + ) + } + + private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { + const parsed = raw as { path?: string } | undefined + if (!parsed?.path) { + throw new BrowserError('browser_error', 'Screenshot returned no file path') + } + if (!existsSync(parsed.path)) { + throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) + } + const data = readFileSync(parsed.path).toString('base64') + return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult + } + + private async captureScreenshotCommand( + sessionName: string, + commandArgs: string[], + settleMs: number, + format?: string + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. + await new Promise((r) => setTimeout(r, settleMs)) + const raw = await this.execAgentBrowser(sessionName, commandArgs) + return this.readScreenshotFromResult(raw, format) + } finally { + restore() + } + }) + } + + private async captureFullPageScreenshotCommand( + sessionName: string, + webContentsId: number, + settleMs: number, + format: 'png' | 'jpeg' + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. + await new Promise((r) => setTimeout(r, settleMs)) + const wc = this.getWebContents(webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + return await captureFullPageScreenshot(wc, format) + } catch (error) { + throw new BrowserError('browser_error', (error as Error).message) + } finally { + restore() + } + }) + } + + private async withSerializedScreenshotAccess(execute: () => Promise): Promise { + const previousTurn = this.screenshotTurn.catch(() => {}) + let releaseTurn!: () => void + this.screenshotTurn = new Promise((resolve) => { + releaseTurn = resolve + }) + await previousTurn + try { + return await execute() + } finally { + releaseTurn() + } + } + + async evaluate( + expression: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + })) as { + result: { value?: unknown; description?: string } + exceptionDetails?: { text: string; exception?: { description?: string } } + } + if (exceptionDetails) { + throw new BrowserError( + 'browser_eval_error', + exceptionDetails.exception?.description ?? exceptionDetails.text + ) + } + + const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (currentTarget.webContentsId !== target.webContentsId) { + throw new BrowserError( + 'browser_tab_changed', + `Browser page ${target.browserPageId} changed while evaluating; retry the command` + ) + } + return { + result: + result.value !== undefined + ? typeof result.value === 'object' && result.value !== null + ? JSON.stringify(result.value) + : String(result.value) + : (result.description ?? ''), + origin: wc.getURL() + } + } catch (error) { + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + throw new BrowserError( + 'browser_error', + `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-core-commands.ts b/src/main/browser/agent-browser-bridge-core-commands.ts new file mode 100644 index 00000000000..aac82f77487 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-core-commands.ts @@ -0,0 +1,186 @@ +import type { + BrowserSnapshotResult, + BrowserClickResult, + BrowserGotoResult, + BrowserFillResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { focusedValueSetExpression } from './agent-browser-bridge-input' +import { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + EMBEDDED_NAVIGATION_TIMEOUT_MS +} from './agent-browser-bridge-types' +import { + isAbortedNavigationError, + waitForAbortedNavigationReplacement +} from './agent-browser-bridge-process' +import { AgentBrowserBridgeQueue } from './agent-browser-bridge-queue' + +export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQueue { + async snapshot(worktreeId?: string, browserPageId?: string): Promise { + // Why: snapshot creates fresh refs so it must bypass the stale-ref guard + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'snapshot' + ])) as BrowserSnapshotResult + return { + ...result, + browserPageId: target.browserPageId + } + }) + } + + async click( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult + }) + } + + async dblclick( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult + }) + } + + async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + const navigationUrl = normalizeBrowserNavigationUrl(url) + if (!navigationUrl) { + throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) + } + const navigationState: { preventUnloadEvent: Electron.Event | null } = { + preventUnloadEvent: null + } + const onWillPreventUnload = (event: Electron.Event): void => { + navigationState.preventUnloadEvent = event + } + wc.on('will-prevent-unload', onWillPreventUnload) + let navigationAborted = false + const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS + let navigationTimeout: ReturnType | null = null + try { + await Promise.race([ + wc.loadURL(navigationUrl), + new Promise((_resolve, reject) => { + navigationTimeout = setTimeout( + () => + reject( + new Error( + `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + EMBEDDED_NAVIGATION_TIMEOUT_MS + ) + navigationTimeout.unref?.() + }) + ]) + } catch (error) { + if (navigationTimeout) { + clearTimeout(navigationTimeout) + navigationTimeout = null + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. + if ( + !isAbortedNavigationError(error) || + (navigationState.preventUnloadEvent !== null && + !navigationState.preventUnloadEvent.defaultPrevented) + ) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } + navigationAborted = true + // Why: a superseding navigation rejects the first load before its replacement has landed. + await waitForAbortedNavigationReplacement( + wc, + target.browserPageId, + Math.max(0, navigationDeadline - Date.now()) + ) + } finally { + wc.removeListener('will-prevent-unload', onWillPreventUnload) + if (navigationTimeout) { + clearTimeout(navigationTimeout) + } + } + + // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. + const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) + const loadError = navigationAborted + ? this.browserManager.getBrowserPageLoadError(target.browserPageId) + : null + if (loadError) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` + ) + } + return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } + }, + { ensureSession: false } + ) + } + + async fill( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(value) + // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + await this.execAgentBrowser(sessionName, ['focus', element]) + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify('')) + ]) + for (const chunk of iterateBrowserTextInsertionChunks( + value, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + ]) + } + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) + ]) + return { filled: element } as BrowserFillResult + } + + await this.fillExplicitContentEditable(sessionName, element, value) + return { filled: element } as BrowserFillResult + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-execution.ts b/src/main/browser/agent-browser-bridge-execution.ts new file mode 100644 index 00000000000..65f64b6fb08 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-execution.ts @@ -0,0 +1,263 @@ +import { execFile } from 'node:child_process' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { + focusedRichTextEditExpression, + isExplicitContentEditableResult +} from './agent-browser-bridge-input' +import { + isTabClosedTransportError, + pageUnavailableMessageForSession +} from './agent-browser-bridge-process' +import { translateResult } from './agent-browser-bridge-result' +import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { + STALE_SESSION_CLOSE_TIMEOUT_MS, + type AgentBrowserExecOptions, + type SessionState, + type ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected abstract runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise + + protected requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) + } + return wc + } + + /** + * Notice that the daemon retired itself between two commands. + * + * A replacement daemon still serves the page (every call reasserts `--cdp`) + * but carries none of the session's network routes, so without this the + * interception the caller configured is silently gone (#16367). + */ + protected reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { + if ( + this.agentBrowserIdleTimeoutMs === null || + Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs + ) { + return + } + session.initialized = false + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + } + + protected assertCommandAdmission(): void { + if (this.shutdownStarted) { + throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') + } + } + + protected async execAgentBrowser( + sessionName: string, + commandArgs: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + const session = this.sessions.get(sessionName) + if (!session) { + // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. + throw this.createPageUnavailableError(sessionName) + } + + // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. + if (!this.getWebContents(session.webContentsId)) { + await this.destroySession(sessionName) + throw this.createPageUnavailableError(sessionName) + } + + this.reinitializeIfDaemonIdledOut(sessionName, session) + session.lastCommandAt = Date.now() + + const args = ['--session', sessionName] + const managesInterceptRoutes = + commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') + + const needsInit = !session.initialized + // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. + args.push('--cdp', String(session.proxy.getPort())) + + // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. + for (const commandArg of commandArgs) { + args.push(commandArg) + } + args.push('--json') + + const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) + const translated = translateResult(stdout) + + if (!translated.ok) { + throw this.createCommandError( + sessionName, + translated.error.message, + translated.error.code, + session.webContentsId + ) + } + + // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. + if (needsInit) { + session.initialized = true + + // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. + const pendingPatterns = managesInterceptRoutes + ? undefined + : this.pendingInterceptRestore.get(sessionName) + if (pendingPatterns && pendingPatterns.length > 0) { + this.pendingInterceptRestore.delete(sessionName) + try { + const urlPattern = pendingPatterns[0] ?? '**/*' + await this.runAgentBrowserRaw(sessionName, [ + '--session', + sessionName, + '--cdp', + String(session.proxy.getPort()), + 'network', + 'route', + urlPattern, + '--json' + ]) + session.activeInterceptPatterns = pendingPatterns + } catch { + // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. + } + } + } + + return translated.result + } + + protected async isExplicitContentEditableTarget( + sessionName: string, + element: string + ): Promise { + const result = await this.execAgentBrowser(sessionName, [ + 'get', + 'attr', + element, + 'contenteditable' + ]) + return isExplicitContentEditableResult(result) + } + + protected async fillExplicitContentEditable( + sessionName: string, + element: string, + value: string + ): Promise { + await this.execAgentBrowser(sessionName, ['focus', element]) + // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) + }) + } + + protected createPageUnavailableError(sessionName: string): BrowserError { + return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) + } + + protected closeStaleAgentBrowserSession(sessionName: string): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + } else { + resolve() + } + } + + // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. + const timeout = setTimeout(() => { + child?.kill() + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` + ) + ) + }, STALE_SESSION_CLOSE_TIMEOUT_MS) + + try { + child = execFile( + this.agentBrowserBin, + ['--session', sessionName, 'close'], + // Why windowsHide: agent-browser is console-subsystem and Orca's main + // process owns no console, so each spawn gets a fresh visible conhost + // that takes foreground -- keystrokes typed into a terminal at that + // moment land in the black box (#14543). + { + env: this.agentBrowserEnv, + timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, + windowsHide: true + }, + (error) => + finish( + error + ? new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error.message}` + ) + : undefined + ) + ) + } catch (error) { + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` + ) + ) + } + }) + } + + protected createCommandError( + sessionName: string, + message: string, + fallbackCode: string, + webContentsId?: number + ): BrowserError { + // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. + if ( + fallbackCode === 'browser_error' && + isTabClosedTransportError(message) && + this.isSessionTargetClosed(sessionName, webContentsId) + ) { + return this.createPageUnavailableError(sessionName) + } + return new BrowserError(fallbackCode, message) + } + + protected isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { + const session = this.sessions.get(sessionName) + if (!session) { + return true + } + const targetWebContentsId = webContentsId ?? session.webContentsId + return !this.getWebContents(targetWebContentsId) + } +} diff --git a/src/main/browser/agent-browser-bridge-input-commands.ts b/src/main/browser/agent-browser-bridge-input-commands.ts new file mode 100644 index 00000000000..7eef4a1ddf3 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input-commands.ts @@ -0,0 +1,124 @@ +import type { + BrowserTypeResult, + BrowserSelectResult, + BrowserScrollResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCoreCommands } from './agent-browser-bridge-core-commands' + +export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridgeCoreCommands { + async type( + input: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(input) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + for (const chunk of iterateBrowserTextInsertionChunks( + input, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) + } + return { typed: true } as BrowserTypeResult + }, + { requireScopedTarget: true } + ) + } + + async select( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'select', + element, + value + ])) as BrowserSelectResult + }) + } + + async scroll( + direction: string, + amount?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['scroll', direction] + if (amount != null) { + args.push(String(amount)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult + }) + } + + async scrollIntoView( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) + }) + } + + async get( + what: string, + selector?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['get', what] + if (selector) { + args.push(selector) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async is( + what: string, + selector: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['is', what, selector]) + }) + } + + // ── Keyboard commands ── + + async keyboardInsertText( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + let result: unknown = { inserted: true } + for (const chunk of iterateBrowserTextInsertionChunks( + text, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) + } + return result + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-input.ts b/src/main/browser/agent-browser-bridge-input.ts new file mode 100644 index 00000000000..cc4869b90bf --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input.ts @@ -0,0 +1,67 @@ +export function focusedValueSetExpression( + valueExpression: string, + options?: { append?: boolean; dispatchEvents?: boolean } +): string { + const nextValue = options?.append + ? ["String(target.value ?? '') + ", valueExpression].join('') + : valueExpression + const dispatchEvents = options?.dispatchEvents + ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" + : '' + return [ + '(() => { const el = document.activeElement; if (el) {', + // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. + " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", + " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", + ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', + ' let target = el;', + " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", + " const controls = target.getAttribute('aria-controls');", + ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', + ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', + ' }', + " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", + ' const nextValue = ', + nextValue, + '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', + dispatchEvents, + ' } })()' + ].join('') +} + +// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. +export function focusedRichTextEditExpression( + valueExpression: string, + options?: { selectAll?: boolean } +): string { + const selectAll = options?.selectAll ? 'true' : 'false' + return [ + '(() => {', + ' const target = document.activeElement;', + ' const value = ', + valueExpression, + ';', + ` const selectAll = ${selectAll};`, + " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", + " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", + ' if (selectAll) {', + " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", + ' const selection = window.getSelection();', + " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", + ' selection.selectAllChildren(target);', + ' }', + " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", + ' let edited = false;', + ' try {', + ' edited = document.execCommand(editCommand, false, value) === true;', + ' } catch { edited = false; }', + " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", + ' })()' + ].join('') +} + +export function isExplicitContentEditableResult(result: unknown): boolean { + const value = + result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined + return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) +} diff --git a/src/main/browser/agent-browser-bridge-interaction-commands.ts b/src/main/browser/agent-browser-bridge-interaction-commands.ts new file mode 100644 index 00000000000..51854878c0b --- /dev/null +++ b/src/main/browser/agent-browser-bridge-interaction-commands.ts @@ -0,0 +1,192 @@ +import type { + BrowserHoverResult, + BrowserDragResult, + BrowserUploadResult, + BrowserWaitResult, + BrowserCheckResult, + BrowserFocusResult, + BrowserClearResult, + BrowserSelectAllResult, + BrowserKeypressResult, + BrowserPdfResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands' + +export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands { + async hover( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult + }) + } + + async drag( + from: string, + to: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult + }) + } + + async upload( + element: string, + filePaths: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'upload', + element, + ...filePaths + ])) as BrowserUploadResult + }) + } + + async wait( + options?: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + }, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['wait'] + const hasCondition = + !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn + if (options?.selector) { + args.push(options.selector) + } else if (options?.timeout != null && !hasCondition) { + args.push(String(options.timeout)) + } + if (options?.text) { + args.push('--text', options.text) + } + if (options?.url) { + args.push('--url', options.url) + } + if (options?.load) { + args.push('--load', options.load) + } + if (options?.fn) { + args.push('--fn', options.fn) + } + const normalizedState = options?.state === 'visible' ? undefined : options?.state + if (normalizedState) { + args.push('--state', normalizedState) + } + // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. + return (await this.execAgentBrowser(sessionName, args, { + timeoutMs: + options?.timeout != null && hasCondition + ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS + : undefined, + timeoutError: + options?.timeout != null && hasCondition + ? new BrowserError( + 'browser_timeout', + `Timed out waiting for browser condition after ${options.timeout}ms.` + ) + : undefined + })) as BrowserWaitResult + }) + } + + async check( + element: string, + checked: boolean, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = checked ? ['check', element] : ['uncheck', element] + return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult + }) + } + + async focus( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult + }) + } + + async clear( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. + await this.execAgentBrowser(sessionName, ['fill', element, '']) + return { cleared: element } + } + + await this.fillExplicitContentEditable(sessionName, element, '') + return { cleared: element } + }, + { requireScopedTarget: true } + ) + } + + async selectAll( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser has no select-all command — implement as focus + Ctrl+A + await this.execAgentBrowser(sessionName, ['focus', element]) + return (await this.execAgentBrowser(sessionName, [ + 'press', + 'Control+a' + ])) as BrowserSelectAllResult + }) + } + + async keypress( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult + }) + } + + async pdf(worktreeId?: string, browserPageId?: string): Promise { + // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + const buffer = await wc.printToPDF({ + printBackground: true, + preferCSSPageSize: true + }) + return { data: buffer.toString('base64') } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-lifecycle.ts b/src/main/browser/agent-browser-bridge-lifecycle.ts new file mode 100644 index 00000000000..c3ec6b170e9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-lifecycle.ts @@ -0,0 +1,266 @@ +import { CdpWsProxy } from './cdp-ws-proxy' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeRawProcess } from './agent-browser-bridge-raw-process' +import type { AgentBrowserCleanupOptions } from './agent-browser-bridge-types' +import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawProcess { + async onTabClosed(webContentsId: number): Promise { + const browserPageId = this.resolveTabIdSafe(webContentsId) + const owningWorktreeId = browserPageId + ? this.browserManager.getWorktreeIdForTab(browserPageId) + : undefined + let nextWorktreeActiveWebContentsId: number | null = null + if ( + owningWorktreeId && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId + ) { + nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( + owningWorktreeId, + webContentsId + ) + } + if (this.activeWebContentsId === webContentsId) { + this.activeWebContentsId = nextWorktreeActiveWebContentsId + } + if (browserPageId) { + await this.onPageClosed(browserPageId) + } + this.options.onTabsChanged?.(owningWorktreeId) + } + + /** + * Retire a page's daemon by page id. + * + * The headless offscreen backend owns pages by id and unregisters the guest + * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it + * has to say which page closed (#16367). + */ + async onPageClosed(browserPageId: string): Promise { + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + await this.destroySession(sessionName) + this.pendingInterceptRestore.delete(sessionName) + } + + async onProcessSwap( + browserPageId: string, + newWebContentsId: number, + previousWebContentsId?: number + ): Promise { + // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + const session = this.sessions.get(sessionName) + const oldWebContentsId = previousWebContentsId ?? session?.webContentsId + const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) + // Why: save intercept patterns before destroy so the new session can restore them after init. + if (session && session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + await this.destroySession(sessionName) + if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { + this.activeWebContentsId = newWebContentsId + } + if ( + owningWorktreeId && + oldWebContentsId != null && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId + ) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + } + protected async ensureSession( + sessionName: string, + browserPageId: string, + webContentsId: number + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + } + this.assertCommandAdmission() + + if (this.sessions.has(sessionName)) { + return + } + + // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. + const pending = this.pendingSessionCreation.get(sessionName) + if (pending) { + await pending + this.assertCommandAdmission() + return + } + + const createSession = async (): Promise => { + const wc = this.getWebContents(webContentsId) + if (!wc) { + // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. + await this.closeStaleAgentBrowserSession(sessionName) + + const proxy = new CdpWsProxy(wc) + const cdpEndpoint = await proxy.start() + + this.sessions.set(sessionName, { + proxy, + cdpEndpoint, + initialized: false, + consecutiveTimeouts: 0, + activeInterceptPatterns: [], + activeCapture: false, + lastCommandAt: Date.now(), + webContentsId, + activeProcess: null + }) + } + + const promise = createSession() + this.pendingSessionCreation.set(sessionName, promise) + try { + await promise + } finally { + this.pendingSessionCreation.delete(sessionName) + } + } + + protected async restartSessionForTarget( + sessionName: string, + browserPageId: string, + webContentsId: number, + options: { recreate: boolean } = { recreate: true } + ): Promise { + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + await pendingCreation.catch(() => {}) + } + + const session = this.sessions.get(sessionName) + if (session) { + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + if (session.activeProcess) { + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { + timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS + }) + } catch { + // Session may already be dead. + } + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + if (options.recreate) { + await this.ensureSession(sessionName, browserPageId, webContentsId) + } + } + + protected async destroySession( + sessionName: string, + options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + return + } + + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. + try { + await pendingCreation + } catch { + // Creation failures are handled by the original caller; teardown still rejects queued work below. + } + } + + const session = this.sessions.get(sessionName) + if (!session) { + this.rejectQueuedCommandsForClosedSession(sessionName) + return + } + + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + + // Why: queued commands would hang forever if we just delete the queue — drain and reject them. + this.rejectQueuedCommandsForClosedSession(sessionName) + + if (session.activeProcess) { + // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + // Why: each tab has its own named session — close without --session leaves this tab's daemon running. + // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. + await this.runAgentBrowserRaw( + sessionName, + ['--session', sessionName, 'close'], + options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } + ) + } catch { + // Session may already be dead + } + + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + protected rejectQueuedCommandsForClosedSession(sessionName: string): void { + const queue = this.commandQueues.get(sessionName) + this.commandQueues.delete(sessionName) + this.processingQueues.delete(sessionName) + if (queue) { + const err = new BrowserError( + 'browser_tab_closed', + 'Tab was closed while commands were queued' + ) + for (const cmd of queue) { + cmd.reject(err) + } + queue.length = 0 + } + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse-commands.ts b/src/main/browser/agent-browser-bridge-mouse-commands.ts new file mode 100644 index 00000000000..36697978748 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse-commands.ts @@ -0,0 +1,206 @@ +import type { BrowserMouseModifier } from './agent-browser-bridge-types' +import { BrowserError } from './cdp-bridge' +import { + normalizeCdpMouseButton, + cdpMouseButtonMask, + cdpMouseModifierMask, + resolveMobileTouchClickPoint +} from './agent-browser-bridge-mouse' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeInputCommands } from './agent-browser-bridge-input-commands' + +export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridgeInputCommands { + // ── Mouse commands ── + + async mouseMove( + x: number, + y: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) + }) + } + + async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'down'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseClick( + x: number, + y: number, + button?: string, + worktreeId?: string, + browserPageId?: string, + radius?: number, + modifiers?: BrowserMouseModifier[] + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${target.browserPageId} is no longer available` + ) + } + const cdpButton = normalizeCdpMouseButton(button) + const buttons = cdpMouseButtonMask(cdpButton) + const cdpModifiers = cdpMouseModifierMask(modifiers) + const lease = acquireElectronDebugger(wc) + try { + wc.focus() + const point = + cdpButton === 'left' + ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. + await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) + : { x, y, adjusted: false, handled: false } + // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. + // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. + if (!point.handled) { + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: cdpButton, + buttons, + modifiers: cdpModifiers, + clickCount: 1 + }) + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: cdpButton, + buttons: 0, + modifiers: cdpModifiers, + clickCount: 1 + }) + } + return { + clicked: { + x: point.x, + y: point.y, + button: cdpButton, + adjusted: point.adjusted, + handled: point.handled + } + } + } finally { + lease.release() + } + }, + { ensureSession: false } + ) + } + + async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'up'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseWheel( + dy: number, + dx?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'wheel', String(dy)] + if (dx != null) { + args.push(String(dx)) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Find (semantic locators) ── + + async find( + locator: string, + value: string, + action: string, + text?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['find', locator, value, action] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Set commands ── + + async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'device', name]) + }) + } + + async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'offline'] + if (state) { + args.push(state) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async setHeaders( + headersJson: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) + }) + } + + async setCredentials( + user: string, + pass: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) + }) + } + + async setMedia( + colorScheme?: string, + reducedMotion?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'media'] + if (colorScheme) { + args.push(colorScheme) + } + if (reducedMotion) { + args.push(reducedMotion) + } + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse.ts b/src/main/browser/agent-browser-bridge-mouse.ts new file mode 100644 index 00000000000..db2ba55d150 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse.ts @@ -0,0 +1,195 @@ +import type { WebContents } from 'electron' +import type { BrowserMouseModifier } from './agent-browser-bridge-types' + +type CdpMouseButton = 'left' | 'middle' | 'right' + +type BrowserClickPoint = { + x: number + y: number + adjusted: boolean + handled: boolean +} + +export function normalizeCdpMouseButton(button?: string): CdpMouseButton { + return button === 'middle' || button === 'right' ? button : 'left' +} + +export function cdpMouseButtonMask(button: CdpMouseButton): number { + if (button === 'right') { + return 2 + } + if (button === 'middle') { + return 4 + } + return 1 +} + +export function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { + if (!modifiers || modifiers.length === 0) { + return 0 + } + let mask = 0 + for (const modifier of modifiers) { + if (modifier === 'alt') { + mask |= 1 + } else if (modifier === 'ctrl') { + mask |= 2 + } else if (modifier === 'cmd') { + mask |= 4 + } else if (modifier === 'shift') { + mask |= 8 + } + } + return mask +} + +export function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { + const point = value && typeof value === 'object' ? (value as Record) : null + const x = point?.x + const y = point?.y + if ( + typeof x !== 'number' || + !Number.isFinite(x) || + typeof y !== 'number' || + !Number.isFinite(y) + ) { + return fallback + } + return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } +} + +export function mobileTouchClickExpression( + x: number, + y: number, + radius: number, + allowDomActivation: boolean +): string { + return `(() => { + const inputX = ${JSON.stringify(x)}; + const inputY = ${JSON.stringify(y)}; + const radius = ${JSON.stringify(radius)}; + const allowDomActivation = ${JSON.stringify(allowDomActivation)}; + const selector = [ + 'a[href]', + 'button', + 'input', + 'textarea', + 'select', + 'summary', + 'label', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="tab"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[onclick]', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + const isUsable = (el) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && + style.visibility !== 'hidden' && style.pointerEvents !== 'none'; + }; + const dispatchClick = (target, clickX, clickY) => { + try { + if (typeof target.focus === 'function') { + target.focus({ preventScroll: true }); + } + } catch { + try { target.focus(); } catch {} + } + if (typeof target.click === 'function') { + target.click(); + return true; + } + const init = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX: clickX, + clientY: clickY, + screenX: clickX, + screenY: clickY, + button: 0, + buttons: 1 + }; + try { + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); + target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); + } + } catch {} + target.dispatchEvent(new MouseEvent('mousedown', init)); + target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); + target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); + return true; + }; + const clickableFor = (el) => { + for (let node = el; node && node.nodeType === 1; node = node.parentElement) { + if (node.matches(selector)) return node; + if (window.getComputedStyle(node).cursor === 'pointer') return node; + } + return null; + }; + const offsets = [[0, 0]]; + for (const distance of [radius * 0.45, radius, radius * 1.35]) { + for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, + Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { + offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); + } + } + let best = null; + for (const [dx, dy] of offsets) { + const px = inputX + dx; + const py = inputY + dy; + if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; + for (const el of document.elementsFromPoint(px, py)) { + const target = clickableFor(el); + if (!target || !isUsable(target)) continue; + const rect = target.getBoundingClientRect(); + const clickX = clamp(inputX, rect.left + 1, rect.right - 1); + const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); + const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; + if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; + break; + } + } + if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { + return { x: best.x, y: best.y, adjusted: true, handled: true }; + } + if (best) { + return { x: best.x, y: best.y, adjusted: true, handled: false }; + } + return { x: inputX, y: inputY, adjusted: false, handled: false }; + })()` +} + +export async function resolveMobileTouchClickPoint( + dbg: WebContents['debugger'], + x: number, + y: number, + radius: number | undefined, + allowDomActivation: boolean +): Promise { + const fallback = { x, y, adjusted: false, handled: false } + if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { + return fallback + } + try { + const result = await dbg.sendCommand('Runtime.evaluate', { + expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), + returnByValue: true, + silent: true + }) + const raw = result && typeof result === 'object' ? (result as Record) : null + const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null + return readClickPoint((evaluated as Record | null)?.value, fallback) + } catch { + return fallback + } +} diff --git a/src/main/browser/agent-browser-bridge-process.ts b/src/main/browser/agent-browser-bridge-process.ts new file mode 100644 index 00000000000..363721099d9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-process.ts @@ -0,0 +1,183 @@ +import { app } from 'electron' +import { existsSync, accessSync, chmodSync, constants } from 'node:fs' +import { join } from 'node:path' +import { platform, arch } from 'node:os' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types' + +export function agentBrowserNativeName(): string { + const ext = process.platform === 'win32' ? '.exe' : '' + return `agent-browser-${platform()}-${arch()}${ext}` +} + +export function resolveAgentBrowserBinary(): string { + // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. + const bundledResourcesPath = + process.resourcesPath ?? + (process.platform === 'darwin' + ? join(app.getPath('exe'), '..', '..', 'Resources') + : join(app.getPath('exe'), '..', 'resources')) + const bundled = join(bundledResourcesPath, agentBrowserNativeName()) + if (existsSync(bundled)) { + return bundled + } + + // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. + const nmBin = join( + app.getAppPath(), + 'node_modules', + 'agent-browser', + 'bin', + agentBrowserNativeName() + ) + if (existsSync(nmBin)) { + if (process.platform !== 'win32') { + try { + accessSync(nmBin, constants.X_OK) + } catch { + chmodSync(nmBin, 0o755) + } + } + return nmBin + } + + // Last resort: assume it's on PATH + return 'agent-browser' +} + +// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. +export function parseShellArgs(input: string): string[] { + const args: string[] = [] + let current = '' + let inDouble = false + let inSingle = false + + for (let i = 0; i < input.length; i++) { + const ch = input[i] + if (ch === '"' && !inSingle) { + inDouble = !inDouble + } else if (ch === "'" && !inDouble) { + inSingle = !inSingle + } else if (ch === ' ' && !inDouble && !inSingle) { + if (current) { + args.push(current) + current = '' + } + } else { + current += ch + } + } + if (current) { + args.push(current) + } + return args +} + +export function stripAgentBrowserTargetArgs(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--cdp' || arg === '--session') { + index++ + continue + } + if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { + continue + } + stripped.push(arg) + } + return stripped +} + +// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. +export function classifyErrorCode(message: string): string { + if (/unknown ref|ref not found|element not found: @e/i.test(message)) { + return 'browser_stale_ref' + } + return 'browser_error' +} + +export function isAbortedNavigationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const { code, errno } = error as { code?: unknown; errno?: unknown } + return code === 'ERR_ABORTED' || errno === -3 +} + +export function isWebContentsLoading(wc: WebContents): boolean { + try { + return wc.isLoading() + } catch { + // Why: destruction races are resolved against the authoritative page registration after the wait. + return false + } +} + +export function waitForAbortedNavigationReplacement( + wc: WebContents, + browserPageId: string, + timeoutMs: number +): Promise { + if (!isWebContentsLoading(wc)) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType | null = null + const finish = (error?: BrowserError): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-stop-loading', onDidStopLoading) + wc.removeListener('destroyed', onDestroyed) + if (timeout) { + clearTimeout(timeout) + } + if (error) { + reject(error) + } else { + resolve() + } + } + const onDidStopLoading = (): void => finish() + const onDestroyed = (): void => finish() + + wc.on('did-stop-loading', onDidStopLoading) + wc.on('destroyed', onDestroyed) + timeout = setTimeout( + () => + finish( + new BrowserError( + 'browser_error', + `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + timeoutMs + ) + timeout.unref?.() + + // Why: the replacement can finish between loadURL rejecting and listener attachment. + if (!isWebContentsLoading(wc)) { + finish() + } + }) +} + +export function isTabClosedTransportError(message: string): boolean { + return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( + message + ) +} + +export function pageUnavailableMessageForSession(sessionName: string): string { + const prefix = ORCA_TAB_SESSION_PREFIX + const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null + return browserPageId + ? `Browser page ${browserPageId} is no longer available` + : 'Browser tab is no longer available' +} diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts new file mode 100644 index 00000000000..74cdb50c14a --- /dev/null +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -0,0 +1,174 @@ +import type { BrowserTabSwitchResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { AgentBrowserBridgeShutdown } from './agent-browser-bridge-shutdown' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import type { + EnqueueTargetedCommandOptions, + ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { + // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. + async tabSwitch( + index: number | undefined, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueCommand(worktreeId, async () => { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. + const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) + let switchedIndex = index ?? -1 + let resolvedPageId = browserPageId + if (resolvedPageId) { + switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) + } + if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { + const targetLabel = + resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` + throw new BrowserError( + 'browser_tab_not_found', + `${targetLabel} out of range (0-${liveEntries.length - 1})` + ) + } + const [tabId, wcId] = liveEntries[switchedIndex] + this.activeWebContentsId = wcId + // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. + const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) + // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. + if (owningWorktreeId) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + return { switched: switchedIndex, browserPageId: tabId } + }) + } + // ── Internal ── + + protected async enqueueCommand( + worktreeId: string | undefined, + execute: (sessionName: string) => Promise + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + undefined, + async (sessionName) => execute(sessionName), + { ensureVisible: false } + ) + } + + protected async enqueueTargetedCommand( + worktreeId: string | undefined, + browserPageId: string | undefined, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions = {} + ): Promise { + this.assertCommandAdmission() + const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + + if (options.ensureSession !== false) { + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + } + this.assertCommandAdmission() + + return new Promise((resolve, reject) => { + let queue = this.commandQueues.get(sessionName) + if (!queue) { + queue = [] + this.commandQueues.set(sessionName, queue) + } + queue.push({ + execute: (() => + this.executeWithVisibleTarget( + sessionName, + worktreeId, + target, + execute, + options + )) as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + this.processQueue(sessionName) + }) + } + + protected async executeWithVisibleTarget( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions + ): Promise { + if (options.ensureVisible === false) { + return execute(sessionName, target) + } + + // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. + const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) + try { + const visibleTarget = await this.refreshTargetAfterAutomationVisibility( + sessionName, + worktreeId, + target, + options + ) + return await execute(sessionName, visibleTarget) + } finally { + restore() + } + } + + protected async refreshTargetAfterAutomationVisibility( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + options: EnqueueTargetedCommandOptions + ): Promise { + const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (visibleTarget.webContentsId === target.webContentsId) { + return visibleTarget + } + + if (this.activeWebContentsId === target.webContentsId) { + this.activeWebContentsId = visibleTarget.webContentsId + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { + this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) + } + + // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. + await this.restartSessionForTarget( + sessionName, + visibleTarget.browserPageId, + visibleTarget.webContentsId, + { recreate: options.ensureSession !== false } + ) + + return visibleTarget + } + + protected async processQueue(sessionName: string): Promise { + if (this.processingQueues.has(sessionName)) { + return + } + this.processingQueues.add(sessionName) + + const queue = this.commandQueues.get(sessionName) + while (queue && queue.length > 0) { + const cmd = queue.shift()! + try { + const result = await cmd.execute() + cmd.resolve(result) + } catch (error) { + cmd.reject(error) + } + } + + if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { + this.commandQueues.delete(sessionName) + } + this.processingQueues.delete(sessionName) + } +} diff --git a/src/main/browser/agent-browser-bridge-raw-process.ts b/src/main/browser/agent-browser-bridge-raw-process.ts new file mode 100644 index 00000000000..bdc5aadbbd9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-raw-process.ts @@ -0,0 +1,108 @@ +import { execFile, type ChildProcess } from 'node:child_process' +import { BrowserError } from './cdp-bridge' +import { classifyErrorCode } from './agent-browser-bridge-process' +import { AgentBrowserBridgeExecution } from './agent-browser-bridge-execution' +import { + CONSECUTIVE_TIMEOUT_LIMIT, + EXEC_TIMEOUT_MS, + type AgentBrowserExecOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExecution { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionName) + let child: ChildProcess | null = null + child = execFile( + this.agentBrowserBin, + args, + // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). + { + timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, + maxBuffer: 50 * 1024 * 1024, + // Why windowsHide: see the stale-session close above -- every + // agent-browser invocation would otherwise flash a console (#14543). + windowsHide: true, + env: execOptions?.envOverrides + ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } + : this.agentBrowserEnv + }, + (error, stdout, stderr) => { + if (session && session.activeProcess === child) { + session.activeProcess = null + } + if (child && this.cancelledProcesses.has(child)) { + this.cancelledProcesses.delete(child) + reject( + new BrowserError('browser_tab_closed', 'Tab was closed while command was running') + ) + return + } + + const liveSession = this.sessions.get(sessionName) + + if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { + if (execOptions?.timeoutError) { + reject(execOptions.timeoutError) + return + } + if (liveSession) { + liveSession.consecutiveTimeouts++ + if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { + // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate + this.destroySession(sessionName) + } + } + reject(new BrowserError('browser_error', 'Browser command timed out')) + return + } + + if (liveSession) { + liveSession.consecutiveTimeouts = 0 + } + + if (error) { + // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. + if (stdout) { + try { + const parsed = JSON.parse(stdout) + if (parsed.error) { + const code = classifyErrorCode(parsed.error) + reject( + this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) + ) + return + } + } catch { + // stdout not valid JSON — fall through to stderr/error.message + } + } + const message = stderr || error.message + const code = classifyErrorCode(message) + reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) + return + } + + resolve(stdout) + } + ) + if (session) { + session.activeProcess = child + } + if (execOptions?.stdinText !== undefined && child?.stdin) { + // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. + child.stdin.on('error', () => {}) + child.stdin.end(execOptions.stdinText) + } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-result.ts b/src/main/browser/agent-browser-bridge-result.ts new file mode 100644 index 00000000000..9366389c2fd --- /dev/null +++ b/src/main/browser/agent-browser-bridge-result.ts @@ -0,0 +1,29 @@ +import { classifyErrorCode } from './agent-browser-bridge-process' + +export function translateResult( + stdout: string +): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { + let parsed: { success?: boolean; data?: unknown; error?: string } + try { + parsed = JSON.parse(stdout) + } catch { + return { + ok: false, + error: { + code: 'browser_error', + message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` + } + } + } + if (parsed.success) { + return { ok: true, result: parsed.data } + } + const message = parsed.error ?? 'Unknown browser error' + return { + ok: false, + error: { + code: classifyErrorCode(message), + message + } + } +} diff --git a/src/main/browser/agent-browser-bridge-shutdown.ts b/src/main/browser/agent-browser-bridge-shutdown.ts new file mode 100644 index 00000000000..4b53cc74df8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-shutdown.ts @@ -0,0 +1,40 @@ +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' +import { sweepOrphanedAgentBrowserSessions } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeLifecycle } from './agent-browser-bridge-lifecycle' +import { + AGENT_BROWSER_CLEANUP_CONCURRENCY, + type AgentBrowserCleanupOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeShutdown extends AgentBrowserBridgeLifecycle { + // ── Session lifecycle ── + + // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with + // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. + async sweepOrphanedSessions(): Promise { + return sweepOrphanedAgentBrowserSessions({ + binaryPath: this.agentBrowserBin, + env: this.agentBrowserEnv, + ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, + isSessionLive: (sessionName) => + this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) + }) + } + + async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { + this.shutdownStarted = true + // Why the union: a session still being created has already spawned its daemon but is not in + // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). + const sessionNames = new Set([ + ...this.sessions.keys(), + ...this.pendingSessionCreation.keys(), + ...this.pendingSessionDestruction.keys() + ]) + await mapSettledWithConcurrency( + [...sessionNames], + AGENT_BROWSER_CLEANUP_CONCURRENCY, + (sessionName) => this.destroySession(sessionName, options) + ) + this.pendingInterceptRestore.clear() + } +} diff --git a/src/main/browser/agent-browser-bridge-state-commands.ts b/src/main/browser/agent-browser-bridge-state-commands.ts new file mode 100644 index 00000000000..4e03055da57 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state-commands.ts @@ -0,0 +1,266 @@ +import type { + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserCookieDeleteResult, + BrowserCookie, + BrowserViewportResult, + BrowserGeolocationResult, + BrowserInterceptEnableResult, + BrowserInterceptDisableResult, + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleResult, + BrowserNetworkLogResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { parseShellArgs, stripAgentBrowserTargetArgs } from './agent-browser-bridge-process' +import { AgentBrowserBridgeInteractionCommands } from './agent-browser-bridge-interaction-commands' + +export abstract class AgentBrowserBridgeStateCommands extends AgentBrowserBridgeInteractionCommands { + // ── Cookie commands ── + + async cookieGet( + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'cookies', + 'get' + ])) as BrowserCookieGetResult + }) + } + + async cookieSet( + cookie: Partial, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] + if (cookie.domain) { + args.push('--domain', cookie.domain) + } + if (cookie.path) { + args.push('--path', cookie.path) + } + if (cookie.secure) { + args.push('--secure') + } + if (cookie.httpOnly) { + args.push('--httpOnly') + } + if (cookie.sameSite) { + args.push('--sameSite', cookie.sameSite) + } + if (cookie.expires != null) { + args.push('--expires', String(cookie.expires)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult + }) + } + + async cookieDelete( + name?: string, + domain?: string, + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'clear'] + if (name) { + args.push('--name', name) + } + if (domain) { + args.push('--domain', domain) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult + }) + } + + // ── Viewport / emulation commands ── + + async setViewport( + width: number, + height: number, + scale = 1, + mobile = false, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + const dbg = wc.debugger + if (!dbg.isAttached()) { + throw new BrowserError('browser_error', 'Debugger not attached') + } + + // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: scale, + mobile + }) + // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. + await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( + () => {} + ) + + return { + width, + height, + deviceScaleFactor: scale, + mobile + } + }) + } + + async setGeolocation( + lat: number, + lon: number, + _accuracy?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'set', + 'geo', + String(lat), + String(lon) + ])) as BrowserGeolocationResult + }) + } + + // ── Network interception commands ── + + async interceptEnable( + patterns?: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser uses "network route " to intercept. Route each pattern individually. + const urlPattern = patterns?.[0] ?? '**/*' + const args = ['network', 'route', urlPattern] + const result = (await this.execAgentBrowser( + sessionName, + args + )) as BrowserInterceptEnableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = patterns ?? ['*'] + } + return result + }) + } + + async interceptDisable( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'unroute' + ])) as BrowserInterceptDisableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = [] + } + return result + }) + } + + async interceptList( + worktreeId?: string, + browserPageId?: string + ): Promise<{ requests: unknown[] }> { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { + requests: unknown[] + } + }) + } + + // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. + + // ── Capture commands ── + + async captureStart( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'start' + ])) as BrowserCaptureStartResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = true + } + return result + }) + } + + async captureStop( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'stop' + ])) as BrowserCaptureStopResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = false + } + return result + }) + } + + async consoleLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult + }) + } + + async networkLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'network', + 'requests' + ])) as BrowserNetworkLogResult + }) + } + + // ── Generic passthrough ── + + async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. + const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-state.ts b/src/main/browser/agent-browser-bridge-state.ts new file mode 100644 index 00000000000..c23cd8e5ac5 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state.ts @@ -0,0 +1,64 @@ +import { app } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { BrowserManager } from './browser-manager' +import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' +import { resolveAgentBrowserBinary } from './agent-browser-bridge-process' +import type { + AgentBrowserBridgeOptions, + QueuedCommand, + SessionState +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeState { + // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. + protected readonly activeWebContentsPerWorktree = new Map() + protected activeWebContentsId: number | null = null + protected readonly sessions = new Map() + protected readonly commandQueues = new Map() + protected readonly processingQueues = new Set() + // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. + protected screenshotTurn: Promise = Promise.resolve() + protected readonly agentBrowserBin: string + protected readonly agentBrowserEnv: NodeJS.ProcessEnv + protected readonly ownsAgentBrowserSocketDirectory: boolean + // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. + protected readonly agentBrowserIdleTimeoutMs: number | null + // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. + protected readonly pendingInterceptRestore = new Map() + // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. + protected readonly pendingSessionCreation = new Map>() + // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. + protected readonly pendingSessionDestruction = new Map>() + protected readonly cancelledProcesses = new WeakSet() + protected shutdownStarted = false + + constructor( + protected readonly browserManager: BrowserManager, + protected readonly options: AgentBrowserBridgeOptions = {} + ) { + this.agentBrowserBin = resolveAgentBrowserBinary() + const processEnvironment = createAgentBrowserProcessEnvironment({ + inheritedEnv: process.env, + platform: process.platform, + userDataPath: app.getPath('userData') + }) + this.agentBrowserEnv = processEnvironment.env + this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory + const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) + this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null + } + + protected resolveTabIdSafe(webContentsId: number): string | null { + return this.browserManager.getTabIdForWebContentsId(webContentsId) + } + + protected getWebContents(webContentsId: number): Electron.WebContents | null { + try { + const { webContents } = require('electron') + const target = webContents.fromId(webContentsId) + return target && !target.isDestroyed() ? target : null + } catch { + return null + } + } +} diff --git a/src/main/browser/agent-browser-bridge-tabs.ts b/src/main/browser/agent-browser-bridge-tabs.ts new file mode 100644 index 00000000000..0af381f6d96 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-tabs.ts @@ -0,0 +1,232 @@ +import type { BrowserTabInfo, BrowserTabListResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import type { ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' +import { AgentBrowserBridgeState } from './agent-browser-bridge-state' + +export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState { + // ── Tab tracking ── + + setActiveTab(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + + protected selectFallbackActiveWebContents( + worktreeId: string, + excludedWebContentsId?: number + ): number | null { + for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { + if (wcId === excludedWebContentsId) { + continue + } + if (this.getWebContents(wcId)) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + return wcId + } + } + this.activeWebContentsPerWorktree.delete(worktreeId) + return null + } + + getActiveWebContentsId(): number | null { + return this.activeWebContentsId + } + + getPageInfo( + worktreeId?: string, + browserPageId?: string + ): { browserPageId: string; url: string; title: string } | null { + try { + const target = this.resolveCommandTarget(worktreeId, browserPageId) + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + return null + } + return { + browserPageId: target.browserPageId, + url: wc.getURL() ?? '', + title: wc.getTitle() ?? '' + } + } catch { + return null + } + } + onTabChanged(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + getRegisteredTabs(worktreeId?: string): Map { + const all = this.browserManager.getWebContentsIdByTabId() + if (!worktreeId) { + return all + } + + const filtered = new Map() + for (const [tabId, wcId] of all) { + if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { + filtered.set(tabId, wcId) + } + } + return filtered + } + + // ── Tab management ── + + tabList(worktreeId?: string): BrowserTabListResult { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. + let activeWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + const result: BrowserTabInfo[] = [] + let index = 0 + let firstLiveWcId: number | null = null + for (const [tabId, wcId] of tabs) { + const wc = this.getWebContents(wcId) + if (!wc) { + this.browserManager.unregisterGuest(tabId) + continue + } + if (firstLiveWcId === null) { + firstLiveWcId = wcId + } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) + result.push({ + browserPageId: tabId, + index: index++, + // Why: failed WebContents report chrome-error://, not the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', + title: wc.getTitle() ?? '', + active: wcId === activeWcId, + loadError, + certificateFailure + }) + } + // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. + if (activeWcId == null && firstLiveWcId !== null) { + activeWcId = firstLiveWcId + if (result.length > 0) { + result[0].active = true + } + } + return { tabs: result } + } + getActivePageId(worktreeId?: string, browserPageId?: string): string | null { + try { + return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId + } catch { + return null + } + } + + protected resolveCommandTarget( + worktreeId?: string, + browserPageId?: string, + requireScopedTarget = false + ): ResolvedBrowserCommandTarget { + if (!browserPageId) { + return requireScopedTarget + ? this.resolveScopedActiveTab(worktreeId) + : this.resolveActiveTab(worktreeId) + } + + const tabs = this.getRegisteredTabs(worktreeId) + const webContentsId = tabs.get(browserPageId) + if (webContentsId == null) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + + if (!this.getWebContents(webContentsId)) { + this.browserManager.unregisterGuest(browserPageId) + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + return { browserPageId, webContentsId } + } + + protected resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + const tabs = this.getRegisteredTabs(worktreeId) + + if (tabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + + // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. + const preferredWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + + if (preferredWcId != null) { + for (const [tabId, wcId] of tabs) { + if (wcId === preferredWcId && this.getWebContents(wcId)) { + return { browserPageId: tabId, webContentsId: wcId } + } + if (wcId === preferredWcId) { + this.browserManager.unregisterGuest(tabId) + if (this.activeWebContentsId === wcId) { + this.activeWebContentsId = null + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { + this.activeWebContentsPerWorktree.delete(worktreeId) + } + } + } + } + + // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. + for (const [tabId, wcId] of tabs) { + if (this.getWebContents(wcId)) { + this.activeWebContentsId = wcId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + } + return { browserPageId: tabId, webContentsId: wcId } + } + this.browserManager.unregisterGuest(tabId) + } + + throw new BrowserError( + 'browser_no_tab', + 'No live browser tab available — all registered tabs have been destroyed' + ) + } + + // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. + protected resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + if (worktreeId) { + return this.resolveActiveTab(worktreeId) + } + + const worktreesWithLiveTabs = new Set() + for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { + if (this.getWebContents(wcId)) { + worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) + } + } + + if (worktreesWithLiveTabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + if (worktreesWithLiveTabs.size > 1) { + throw new BrowserError( + 'browser_target_ambiguous', + 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' + ) + } + + const [onlyWorktreeId] = worktreesWithLiveTabs + return this.resolveActiveTab(onlyWorktreeId) + } +} diff --git a/src/main/browser/agent-browser-bridge-types.ts b/src/main/browser/agent-browser-bridge-types.ts new file mode 100644 index 00000000000..a89c9fcbca8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-types.ts @@ -0,0 +1,65 @@ +import type { ChildProcess } from 'node:child_process' +import type { CdpWsProxy } from './cdp-ws-proxy' +import type { BrowserError } from './cdp-bridge' + +// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. +export const EXEC_TIMEOUT_MS = 90_000 +export const CONSECUTIVE_TIMEOUT_LIMIT = 3 +export const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 +export const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. +export const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 +export const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 +export const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 +export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 +export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + +export type SessionState = { + proxy: CdpWsProxy + cdpEndpoint: string + initialized: boolean + consecutiveTimeouts: number + // Why: track active interception patterns so they can be re-enabled after session restart + activeInterceptPatterns: string[] + activeCapture: boolean + // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. + lastCommandAt: number + // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. + webContentsId: number + activeProcess: ChildProcess | null +} + +export type QueuedCommand = { + execute: () => Promise + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +export type ResolvedBrowserCommandTarget = { + browserPageId: string + webContentsId: number +} + +export type AgentBrowserCleanupOptions = { + closeTimeoutMs?: number +} + +export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' + +export type AgentBrowserExecOptions = { + envOverrides?: NodeJS.ProcessEnv + timeoutMs?: number + timeoutError?: BrowserError + stdinText?: string +} + +export type EnqueueTargetedCommandOptions = { + ensureSession?: boolean + ensureVisible?: boolean + // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). + requireScopedTarget?: boolean +} + +export type AgentBrowserBridgeOptions = { + onTabsChanged?: (worktreeId?: string) => void +} diff --git a/src/main/browser/agent-browser-bridge-utility-commands.ts b/src/main/browser/agent-browser-bridge-utility-commands.ts new file mode 100644 index 00000000000..f3697168cd8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-utility-commands.ts @@ -0,0 +1,175 @@ +import { BrowserError } from './cdp-bridge' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES } from './agent-browser-bridge-types' +import type { BrowserBackResult, BrowserReloadResult } from '../../shared/runtime-types' +import { AgentBrowserBridgeMouseCommands } from './agent-browser-bridge-mouse-commands' + +export abstract class AgentBrowserBridgeUtilityCommands extends AgentBrowserBridgeMouseCommands { + // ── Clipboard commands ── + + async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) + }) + } + + async clipboardWrite( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text, { + maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + }) + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) + }) + } + + // ── Dialog commands ── + + async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['dialog', 'accept'] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) + }) + } + + // ── Storage commands ── + + async storageLocalGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) + }) + } + + async storageLocalSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) + }) + } + + async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) + }) + } + + async storageSessionGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) + }) + } + + async storageSessionSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) + }) + } + + async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) + }) + } + + // ── Download command ── + + async download( + selector: string, + path: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['download', selector, path]) + }) + } + + // ── Highlight command ── + + async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['highlight', selector]) + }) + } + + async back(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult + }) + } + + async forward(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult + }) + } + + async reload(worktreeId?: string, browserPageId?: string): Promise { + // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + wc.reload() + await new Promise((resolve) => { + let settled = false + let fallbackTimer: ReturnType | null = null + + const finish = (): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-finish-load', onFinish) + wc.removeListener('did-fail-load', onFail) + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + resolve() + } + const onFinish = (): void => finish() + const onFail = (): void => finish() + + wc.on('did-finish-load', onFinish) + wc.on('did-fail-load', onFail) + // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. + fallbackTimer = setTimeout(finish, 10_000) + if (typeof fallbackTimer.unref === 'function') { + fallbackTimer.unref() + } + }) + return { url: wc.getURL(), title: wc.getTitle() } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 01f77b45451..aeeb64385a3 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1,2884 +1,20 @@ -/* eslint-disable max-lines */ -import { execFile, type ChildProcess } from 'node:child_process' -import { existsSync, accessSync, chmodSync, readFileSync, constants } from 'node:fs' -import { join } from 'node:path' -import { platform, arch } from 'node:os' -import { app, type WebContents } from 'electron' -import { CdpWsProxy } from './cdp-ws-proxy' -import { captureFullPageScreenshot } from './cdp-screenshot' -import { acquireElectronDebugger } from './electron-debugger-lease' -import type { BrowserManager } from './browser-manager' -import { BrowserError } from './cdp-bridge' -import type { - BrowserTabInfo, - BrowserTabListResult, - BrowserTabSwitchResult, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserConsoleResult, - BrowserNetworkLogResult, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserCookie -} from '../../shared/runtime-types' -import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' -import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' -import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' -import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' -import { - ORCA_TAB_SESSION_PREFIX, - sweepOrphanedAgentBrowserSessions -} from './agent-browser-orphan-sweep' - -// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. -const EXEC_TIMEOUT_MS = 90_000 -const CONSECUTIVE_TIMEOUT_LIMIT = 3 -const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 -const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 -// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. -const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 -const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 -const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 -export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 -export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - -type SessionState = { - proxy: CdpWsProxy - cdpEndpoint: string - initialized: boolean - consecutiveTimeouts: number - // Why: track active interception patterns so they can be re-enabled after session restart - activeInterceptPatterns: string[] - activeCapture: boolean - // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. - lastCommandAt: number - // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. - webContentsId: number - activeProcess: ChildProcess | null -} - -type QueuedCommand = { - execute: () => Promise - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - -type ResolvedBrowserCommandTarget = { - browserPageId: string - webContentsId: number -} - -type AgentBrowserCleanupOptions = { - closeTimeoutMs?: number -} - -export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' - -function focusedValueSetExpression( - valueExpression: string, - options?: { append?: boolean; dispatchEvents?: boolean } -): string { - const nextValue = options?.append - ? ["String(target.value ?? '') + ", valueExpression].join('') - : valueExpression - const dispatchEvents = options?.dispatchEvents - ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" - : '' - return [ - '(() => { const el = document.activeElement; if (el) {', - // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. - " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", - " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", - ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', - ' let target = el;', - " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", - " const controls = target.getAttribute('aria-controls');", - ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', - ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', - ' }', - " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", - ' const nextValue = ', - nextValue, - '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', - dispatchEvents, - ' } })()' - ].join('') -} - -// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. -function focusedRichTextEditExpression( - valueExpression: string, - options?: { selectAll?: boolean } -): string { - const selectAll = options?.selectAll ? 'true' : 'false' - return [ - '(() => {', - ' const target = document.activeElement;', - ' const value = ', - valueExpression, - ';', - ` const selectAll = ${selectAll};`, - " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", - " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", - ' if (selectAll) {', - " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", - ' const selection = window.getSelection();', - " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", - ' selection.selectAllChildren(target);', - ' }', - " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", - ' let edited = false;', - ' try {', - ' edited = document.execCommand(editCommand, false, value) === true;', - ' } catch { edited = false; }', - " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", - ' })()' - ].join('') -} - -function isExplicitContentEditableResult(result: unknown): boolean { - const value = - result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined - return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) -} - -type AgentBrowserExecOptions = { - envOverrides?: NodeJS.ProcessEnv - timeoutMs?: number - timeoutError?: BrowserError - stdinText?: string -} - -type EnqueueTargetedCommandOptions = { - ensureSession?: boolean - ensureVisible?: boolean - // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). - requireScopedTarget?: boolean -} - -type AgentBrowserBridgeOptions = { - onTabsChanged?: (worktreeId?: string) => void -} - -function agentBrowserNativeName(): string { - const ext = process.platform === 'win32' ? '.exe' : '' - return `agent-browser-${platform()}-${arch()}${ext}` -} - -function resolveAgentBrowserBinary(): string { - // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. - const bundledResourcesPath = - process.resourcesPath ?? - (process.platform === 'darwin' - ? join(app.getPath('exe'), '..', '..', 'Resources') - : join(app.getPath('exe'), '..', 'resources')) - const bundled = join(bundledResourcesPath, agentBrowserNativeName()) - if (existsSync(bundled)) { - return bundled - } - - // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. - const nmBin = join( - app.getAppPath(), - 'node_modules', - 'agent-browser', - 'bin', - agentBrowserNativeName() - ) - if (existsSync(nmBin)) { - if (process.platform !== 'win32') { - try { - accessSync(nmBin, constants.X_OK) - } catch { - chmodSync(nmBin, 0o755) - } - } - return nmBin - } - - // Last resort: assume it's on PATH - return 'agent-browser' -} - -// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. -function parseShellArgs(input: string): string[] { - const args: string[] = [] - let current = '' - let inDouble = false - let inSingle = false - - for (let i = 0; i < input.length; i++) { - const ch = input[i] - if (ch === '"' && !inSingle) { - inDouble = !inDouble - } else if (ch === "'" && !inDouble) { - inSingle = !inSingle - } else if (ch === ' ' && !inDouble && !inSingle) { - if (current) { - args.push(current) - current = '' - } - } else { - current += ch - } - } - if (current) { - args.push(current) - } - return args -} - -function stripAgentBrowserTargetArgs(args: string[]): string[] { - const stripped: string[] = [] - for (let index = 0; index < args.length; index++) { - const arg = args[index] - if (arg === '--cdp' || arg === '--session') { - index++ - continue - } - if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { - continue - } - stripped.push(arg) - } - return stripped -} - -// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. -function classifyErrorCode(message: string): string { - if (/unknown ref|ref not found|element not found: @e/i.test(message)) { - return 'browser_stale_ref' - } - return 'browser_error' -} - -function isAbortedNavigationError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - const { code, errno } = error as { code?: unknown; errno?: unknown } - return code === 'ERR_ABORTED' || errno === -3 -} - -function isWebContentsLoading(wc: WebContents): boolean { - try { - return wc.isLoading() - } catch { - // Why: destruction races are resolved against the authoritative page registration after the wait. - return false - } -} - -function waitForAbortedNavigationReplacement( - wc: WebContents, - browserPageId: string, - timeoutMs: number -): Promise { - if (!isWebContentsLoading(wc)) { - return Promise.resolve() - } - - return new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType | null = null - const finish = (error?: BrowserError): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-stop-loading', onDidStopLoading) - wc.removeListener('destroyed', onDestroyed) - if (timeout) { - clearTimeout(timeout) - } - if (error) { - reject(error) - } else { - resolve() - } - } - const onDidStopLoading = (): void => finish() - const onDestroyed = (): void => finish() - - wc.on('did-stop-loading', onDidStopLoading) - wc.on('destroyed', onDestroyed) - timeout = setTimeout( - () => - finish( - new BrowserError( - 'browser_error', - `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - timeoutMs - ) - timeout.unref?.() - - // Why: the replacement can finish between loadURL rejecting and listener attachment. - if (!isWebContentsLoading(wc)) { - finish() - } - }) -} - -function isTabClosedTransportError(message: string): boolean { - return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( - message - ) -} - -function pageUnavailableMessageForSession(sessionName: string): string { - const prefix = ORCA_TAB_SESSION_PREFIX - const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null - return browserPageId - ? `Browser page ${browserPageId} is no longer available` - : 'Browser tab is no longer available' -} - -type CdpMouseButton = 'left' | 'middle' | 'right' - -type BrowserClickPoint = { - x: number - y: number - adjusted: boolean - handled: boolean -} - -function normalizeCdpMouseButton(button?: string): CdpMouseButton { - return button === 'middle' || button === 'right' ? button : 'left' -} - -function cdpMouseButtonMask(button: CdpMouseButton): number { - if (button === 'right') { - return 2 - } - if (button === 'middle') { - return 4 - } - return 1 -} - -function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { - if (!modifiers || modifiers.length === 0) { - return 0 - } - let mask = 0 - for (const modifier of modifiers) { - if (modifier === 'alt') { - mask |= 1 - } else if (modifier === 'ctrl') { - mask |= 2 - } else if (modifier === 'cmd') { - mask |= 4 - } else if (modifier === 'shift') { - mask |= 8 - } - } - return mask -} - -function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { - const point = value && typeof value === 'object' ? (value as Record) : null - const x = point?.x - const y = point?.y - if ( - typeof x !== 'number' || - !Number.isFinite(x) || - typeof y !== 'number' || - !Number.isFinite(y) - ) { - return fallback - } - return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } -} - -function mobileTouchClickExpression( - x: number, - y: number, - radius: number, - allowDomActivation: boolean -): string { - return `(() => { - const inputX = ${JSON.stringify(x)}; - const inputY = ${JSON.stringify(y)}; - const radius = ${JSON.stringify(radius)}; - const allowDomActivation = ${JSON.stringify(allowDomActivation)}; - const selector = [ - 'a[href]', - 'button', - 'input', - 'textarea', - 'select', - 'summary', - 'label', - '[role="button"]', - '[role="link"]', - '[role="menuitem"]', - '[role="tab"]', - '[role="checkbox"]', - '[role="radio"]', - '[role="switch"]', - '[onclick]', - '[tabindex]:not([tabindex="-1"])' - ].join(','); - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - const isUsable = (el) => { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 && style.display !== 'none' && - style.visibility !== 'hidden' && style.pointerEvents !== 'none'; - }; - const dispatchClick = (target, clickX, clickY) => { - try { - if (typeof target.focus === 'function') { - target.focus({ preventScroll: true }); - } - } catch { - try { target.focus(); } catch {} - } - if (typeof target.click === 'function') { - target.click(); - return true; - } - const init = { - bubbles: true, - cancelable: true, - composed: true, - view: window, - clientX: clickX, - clientY: clickY, - screenX: clickX, - screenY: clickY, - button: 0, - buttons: 1 - }; - try { - if (typeof PointerEvent === 'function') { - target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); - target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); - } - } catch {} - target.dispatchEvent(new MouseEvent('mousedown', init)); - target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); - target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); - return true; - }; - const clickableFor = (el) => { - for (let node = el; node && node.nodeType === 1; node = node.parentElement) { - if (node.matches(selector)) return node; - if (window.getComputedStyle(node).cursor === 'pointer') return node; - } - return null; - }; - const offsets = [[0, 0]]; - for (const distance of [radius * 0.45, radius, radius * 1.35]) { - for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, - Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { - offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); - } - } - let best = null; - for (const [dx, dy] of offsets) { - const px = inputX + dx; - const py = inputY + dy; - if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; - for (const el of document.elementsFromPoint(px, py)) { - const target = clickableFor(el); - if (!target || !isUsable(target)) continue; - const rect = target.getBoundingClientRect(); - const clickX = clamp(inputX, rect.left + 1, rect.right - 1); - const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); - const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; - if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; - break; - } - } - if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { - return { x: best.x, y: best.y, adjusted: true, handled: true }; - } - if (best) { - return { x: best.x, y: best.y, adjusted: true, handled: false }; - } - return { x: inputX, y: inputY, adjusted: false, handled: false }; - })()` -} - -async function resolveMobileTouchClickPoint( - dbg: WebContents['debugger'], - x: number, - y: number, - radius: number | undefined, - allowDomActivation: boolean -): Promise { - const fallback = { x, y, adjusted: false, handled: false } - if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { - return fallback - } - try { - const result = await dbg.sendCommand('Runtime.evaluate', { - expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), - returnByValue: true, - silent: true - }) - const raw = result && typeof result === 'object' ? (result as Record) : null - const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null - return readClickPoint((evaluated as Record | null)?.value, fallback) - } catch { - return fallback - } -} - -function translateResult( - stdout: string -): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { - let parsed: { success?: boolean; data?: unknown; error?: string } - try { - parsed = JSON.parse(stdout) - } catch { - return { - ok: false, - error: { - code: 'browser_error', - message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` - } - } - } - if (parsed.success) { - return { ok: true, result: parsed.data } - } - const message = parsed.error ?? 'Unknown browser error' - return { - ok: false, - error: { - code: classifyErrorCode(message), - message - } - } -} - -export class AgentBrowserBridge { - // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. - private readonly activeWebContentsPerWorktree = new Map() - private activeWebContentsId: number | null = null - private readonly sessions = new Map() - private readonly commandQueues = new Map() - private readonly processingQueues = new Set() - // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. - private screenshotTurn: Promise = Promise.resolve() - private readonly agentBrowserBin: string - private readonly agentBrowserEnv: NodeJS.ProcessEnv - private readonly ownsAgentBrowserSocketDirectory: boolean - // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. - private readonly agentBrowserIdleTimeoutMs: number | null - // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. - private readonly pendingInterceptRestore = new Map() - // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. - private readonly pendingSessionCreation = new Map>() - // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. - private readonly pendingSessionDestruction = new Map>() - private readonly cancelledProcesses = new WeakSet() - private shutdownStarted = false - - constructor( - private readonly browserManager: BrowserManager, - private readonly options: AgentBrowserBridgeOptions = {} - ) { - this.agentBrowserBin = resolveAgentBrowserBinary() - const processEnvironment = createAgentBrowserProcessEnvironment({ - inheritedEnv: process.env, - platform: process.platform, - userDataPath: app.getPath('userData') - }) - this.agentBrowserEnv = processEnvironment.env - this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory - const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) - this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null - } - - // ── Tab tracking ── - - setActiveTab(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - private selectFallbackActiveWebContents( - worktreeId: string, - excludedWebContentsId?: number - ): number | null { - for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { - if (wcId === excludedWebContentsId) { - continue - } - if (this.getWebContents(wcId)) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - return wcId - } - } - this.activeWebContentsPerWorktree.delete(worktreeId) - return null - } - - getActiveWebContentsId(): number | null { - return this.activeWebContentsId - } - - getPageInfo( - worktreeId?: string, - browserPageId?: string - ): { browserPageId: string; url: string; title: string } | null { - try { - const target = this.resolveCommandTarget(worktreeId, browserPageId) - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - return null - } - return { - browserPageId: target.browserPageId, - url: wc.getURL() ?? '', - title: wc.getTitle() ?? '' - } - } catch { - return null - } - } - - onTabChanged(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - async onTabClosed(webContentsId: number): Promise { - const browserPageId = this.resolveTabIdSafe(webContentsId) - const owningWorktreeId = browserPageId - ? this.browserManager.getWorktreeIdForTab(browserPageId) - : undefined - let nextWorktreeActiveWebContentsId: number | null = null - if ( - owningWorktreeId && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId - ) { - nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( - owningWorktreeId, - webContentsId - ) - } - if (this.activeWebContentsId === webContentsId) { - this.activeWebContentsId = nextWorktreeActiveWebContentsId - } - if (browserPageId) { - await this.onPageClosed(browserPageId) - } - this.options.onTabsChanged?.(owningWorktreeId) - } - - /** - * Retire a page's daemon by page id. - * - * The headless offscreen backend owns pages by id and unregisters the guest - * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it - * has to say which page closed (#16367). - */ - async onPageClosed(browserPageId: string): Promise { - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - await this.destroySession(sessionName) - this.pendingInterceptRestore.delete(sessionName) - } - - async onProcessSwap( - browserPageId: string, - newWebContentsId: number, - previousWebContentsId?: number - ): Promise { - // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - const session = this.sessions.get(sessionName) - const oldWebContentsId = previousWebContentsId ?? session?.webContentsId - const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) - // Why: save intercept patterns before destroy so the new session can restore them after init. - if (session && session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - await this.destroySession(sessionName) - if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { - this.activeWebContentsId = newWebContentsId - } - if ( - owningWorktreeId && - oldWebContentsId != null && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId - ) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - } - - // ── Worktree-scoped tab queries ── - - getRegisteredTabs(worktreeId?: string): Map { - const all = this.browserManager.getWebContentsIdByTabId() - if (!worktreeId) { - return all - } - - const filtered = new Map() - for (const [tabId, wcId] of all) { - if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { - filtered.set(tabId, wcId) - } - } - return filtered - } - - // ── Tab management ── - - tabList(worktreeId?: string): BrowserTabListResult { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. - let activeWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - const result: BrowserTabInfo[] = [] - let index = 0 - let firstLiveWcId: number | null = null - for (const [tabId, wcId] of tabs) { - const wc = this.getWebContents(wcId) - if (!wc) { - this.browserManager.unregisterGuest(tabId) - continue - } - if (firstLiveWcId === null) { - firstLiveWcId = wcId - } - const loadError = this.browserManager.getBrowserPageLoadError(tabId) - const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) - result.push({ - browserPageId: tabId, - index: index++, - // Why: failed WebContents report chrome-error://, not the address the user asked to load. - url: loadError?.validatedUrl ?? wc.getURL() ?? '', - title: wc.getTitle() ?? '', - active: wcId === activeWcId, - loadError, - certificateFailure - }) - } - // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. - if (activeWcId == null && firstLiveWcId !== null) { - activeWcId = firstLiveWcId - if (result.length > 0) { - result[0].active = true - } - } - return { tabs: result } - } - - // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. - async tabSwitch( - index: number | undefined, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueCommand(worktreeId, async () => { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. - const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) - let switchedIndex = index ?? -1 - let resolvedPageId = browserPageId - if (resolvedPageId) { - switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) - } - if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { - const targetLabel = - resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` - throw new BrowserError( - 'browser_tab_not_found', - `${targetLabel} out of range (0-${liveEntries.length - 1})` - ) - } - const [tabId, wcId] = liveEntries[switchedIndex] - this.activeWebContentsId = wcId - // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. - const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) - // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. - if (owningWorktreeId) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - return { switched: switchedIndex, browserPageId: tabId } - }) - } - - // ── Core commands (typed) ── - - async snapshot(worktreeId?: string, browserPageId?: string): Promise { - // Why: snapshot creates fresh refs so it must bypass the stale-ref guard - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'snapshot' - ])) as BrowserSnapshotResult - return { - ...result, - browserPageId: target.browserPageId - } - }) - } - - async click( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult - }) - } - - async dblclick( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult - }) - } - - async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - const navigationUrl = normalizeBrowserNavigationUrl(url) - if (!navigationUrl) { - throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) - } - const navigationState: { preventUnloadEvent: Electron.Event | null } = { - preventUnloadEvent: null - } - const onWillPreventUnload = (event: Electron.Event): void => { - navigationState.preventUnloadEvent = event - } - wc.on('will-prevent-unload', onWillPreventUnload) - let navigationAborted = false - const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS - let navigationTimeout: ReturnType | null = null - try { - await Promise.race([ - wc.loadURL(navigationUrl), - new Promise((_resolve, reject) => { - navigationTimeout = setTimeout( - () => - reject( - new Error( - `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - EMBEDDED_NAVIGATION_TIMEOUT_MS - ) - navigationTimeout.unref?.() - }) - ]) - } catch (error) { - if (navigationTimeout) { - clearTimeout(navigationTimeout) - navigationTimeout = null - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. - if ( - !isAbortedNavigationError(error) || - (navigationState.preventUnloadEvent !== null && - !navigationState.preventUnloadEvent.defaultPrevented) - ) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } - navigationAborted = true - // Why: a superseding navigation rejects the first load before its replacement has landed. - await waitForAbortedNavigationReplacement( - wc, - target.browserPageId, - Math.max(0, navigationDeadline - Date.now()) - ) - } finally { - wc.removeListener('will-prevent-unload', onWillPreventUnload) - if (navigationTimeout) { - clearTimeout(navigationTimeout) - } - } - - // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. - const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) - const loadError = navigationAborted - ? this.browserManager.getBrowserPageLoadError(target.browserPageId) - : null - if (loadError) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` - ) - } - return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } - }, - { ensureSession: false } - ) - } - - async fill( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(value) - // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) - ]) - } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) - return { filled: element } as BrowserFillResult - } - - await this.fillExplicitContentEditable(sessionName, element, value) - return { filled: element } as BrowserFillResult - }, - { requireScopedTarget: true } - ) - } - - async type( - input: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(input) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - for (const chunk of iterateBrowserTextInsertionChunks( - input, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) - } - return { typed: true } as BrowserTypeResult - }, - { requireScopedTarget: true } - ) - } - - async select( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'select', - element, - value - ])) as BrowserSelectResult - }) - } - - async scroll( - direction: string, - amount?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['scroll', direction] - if (amount != null) { - args.push(String(amount)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult - }) - } - - async scrollIntoView( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) - }) - } - - async get( - what: string, - selector?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['get', what] - if (selector) { - args.push(selector) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async is( - what: string, - selector: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['is', what, selector]) - }) - } - - // ── Keyboard commands ── - - async keyboardInsertText( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - let result: unknown = { inserted: true } - for (const chunk of iterateBrowserTextInsertionChunks( - text, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) - } - return result - }, - { requireScopedTarget: true } - ) - } - - // ── Mouse commands ── - - async mouseMove( - x: number, - y: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) - }) - } - - async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'down'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseClick( - x: number, - y: number, - button?: string, - worktreeId?: string, - browserPageId?: string, - radius?: number, - modifiers?: BrowserMouseModifier[] - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${target.browserPageId} is no longer available` - ) - } - const cdpButton = normalizeCdpMouseButton(button) - const buttons = cdpMouseButtonMask(cdpButton) - const cdpModifiers = cdpMouseModifierMask(modifiers) - const lease = acquireElectronDebugger(wc) - try { - wc.focus() - const point = - cdpButton === 'left' - ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. - await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) - : { x, y, adjusted: false, handled: false } - // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. - // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. - if (!point.handled) { - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: point.x, - y: point.y, - button: cdpButton, - buttons, - modifiers: cdpModifiers, - clickCount: 1 - }) - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: point.x, - y: point.y, - button: cdpButton, - buttons: 0, - modifiers: cdpModifiers, - clickCount: 1 - }) - } - return { - clicked: { - x: point.x, - y: point.y, - button: cdpButton, - adjusted: point.adjusted, - handled: point.handled - } - } - } finally { - lease.release() - } - }, - { ensureSession: false } - ) - } - - async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'up'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseWheel( - dy: number, - dx?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'wheel', String(dy)] - if (dx != null) { - args.push(String(dx)) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Find (semantic locators) ── - - async find( - locator: string, - value: string, - action: string, - text?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['find', locator, value, action] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Set commands ── - - async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'device', name]) - }) - } - - async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'offline'] - if (state) { - args.push(state) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async setHeaders( - headersJson: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) - }) - } - - async setCredentials( - user: string, - pass: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) - }) - } - - async setMedia( - colorScheme?: string, - reducedMotion?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'media'] - if (colorScheme) { - args.push(colorScheme) - } - if (reducedMotion) { - args.push(reducedMotion) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Clipboard commands ── - - async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) - }) - } - - async clipboardWrite( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text, { - maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES - }) - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) - }) - } - - // ── Dialog commands ── - - async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['dialog', 'accept'] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) - }) - } - - // ── Storage commands ── - - async storageLocalGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) - }) - } - - async storageLocalSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) - }) - } - - async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) - }) - } - - async storageSessionGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) - }) - } - - async storageSessionSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) - }) - } - - async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) - }) - } - - // ── Download command ── - - async download( - selector: string, - path: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['download', selector, path]) - }) - } - - // ── Highlight command ── - - async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['highlight', selector]) - }) - } - - async back(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult - }) - } - - async forward(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult - }) - } - - async reload(worktreeId?: string, browserPageId?: string): Promise { - // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - wc.reload() - await new Promise((resolve) => { - let settled = false - let fallbackTimer: ReturnType | null = null - - const finish = (): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-finish-load', onFinish) - wc.removeListener('did-fail-load', onFail) - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - resolve() - } - const onFinish = (): void => finish() - const onFail = (): void => finish() - - wc.on('did-finish-load', onFinish) - wc.on('did-fail-load', onFail) - // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. - fallbackTimer = setTimeout(finish, 10_000) - if (typeof fallbackTimer.unref === 'function') { - fallbackTimer.unref() - } - }) - return { url: wc.getURL(), title: wc.getTitle() } - }) - } - - async screenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) - }, - { ensureVisible: false } - ) - } - - async fullPageScreenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName, target) => { - return this.captureFullPageScreenshotCommand( - sessionName, - target.webContentsId, - 500, - format === 'jpeg' ? 'jpeg' : 'png' - ) - }, - { ensureVisible: false } - ) - } - - private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { - const parsed = raw as { path?: string } | undefined - if (!parsed?.path) { - throw new BrowserError('browser_error', 'Screenshot returned no file path') - } - if (!existsSync(parsed.path)) { - throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) - } - const data = readFileSync(parsed.path).toString('base64') - return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult - } - - private async captureScreenshotCommand( - sessionName: string, - commandArgs: string[], - settleMs: number, - format?: string - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. - await new Promise((r) => setTimeout(r, settleMs)) - const raw = await this.execAgentBrowser(sessionName, commandArgs) - return this.readScreenshotFromResult(raw, format) - } finally { - restore() - } - }) - } - - private async captureFullPageScreenshotCommand( - sessionName: string, - webContentsId: number, - settleMs: number, - format: 'png' | 'jpeg' - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. - await new Promise((r) => setTimeout(r, settleMs)) - const wc = this.getWebContents(webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - return await captureFullPageScreenshot(wc, format) - } catch (error) { - throw new BrowserError('browser_error', (error as Error).message) - } finally { - restore() - } - }) - } - - private async withSerializedScreenshotAccess(execute: () => Promise): Promise { - const previousTurn = this.screenshotTurn.catch(() => {}) - let releaseTurn!: () => void - this.screenshotTurn = new Promise((resolve) => { - releaseTurn = resolve - }) - await previousTurn - try { - return await execute() - } finally { - releaseTurn() - } - } - - async evaluate( - expression: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - let releaseDebugger = (): void => {} - try { - releaseDebugger = acquireElectronDebugger(wc).release - const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true - })) as { - result: { value?: unknown; description?: string } - exceptionDetails?: { text: string; exception?: { description?: string } } - } - if (exceptionDetails) { - throw new BrowserError( - 'browser_eval_error', - exceptionDetails.exception?.description ?? exceptionDetails.text - ) - } - - const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (currentTarget.webContentsId !== target.webContentsId) { - throw new BrowserError( - 'browser_tab_changed', - `Browser page ${target.browserPageId} changed while evaluating; retry the command` - ) - } - return { - result: - result.value !== undefined - ? typeof result.value === 'object' && result.value !== null - ? JSON.stringify(result.value) - : String(result.value) - : (result.description ?? ''), - origin: wc.getURL() - } - } catch (error) { - if (error instanceof BrowserError) { - throw error - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - throw new BrowserError( - 'browser_error', - `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } finally { - releaseDebugger() - } - }, - { ensureSession: false } - ) - } - - async hover( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult - }) - } - - async drag( - from: string, - to: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult - }) - } - - async upload( - element: string, - filePaths: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'upload', - element, - ...filePaths - ])) as BrowserUploadResult - }) - } - - async wait( - options?: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - }, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['wait'] - const hasCondition = - !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn - if (options?.selector) { - args.push(options.selector) - } else if (options?.timeout != null && !hasCondition) { - args.push(String(options.timeout)) - } - if (options?.text) { - args.push('--text', options.text) - } - if (options?.url) { - args.push('--url', options.url) - } - if (options?.load) { - args.push('--load', options.load) - } - if (options?.fn) { - args.push('--fn', options.fn) - } - const normalizedState = options?.state === 'visible' ? undefined : options?.state - if (normalizedState) { - args.push('--state', normalizedState) - } - // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. - return (await this.execAgentBrowser(sessionName, args, { - timeoutMs: - options?.timeout != null && hasCondition - ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS - : undefined, - timeoutError: - options?.timeout != null && hasCondition - ? new BrowserError( - 'browser_timeout', - `Timed out waiting for browser condition after ${options.timeout}ms.` - ) - : undefined - })) as BrowserWaitResult - }) - } - - async check( - element: string, - checked: boolean, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = checked ? ['check', element] : ['uncheck', element] - return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult - }) - } - - async focus( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult - }) - } - - async clear( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. - await this.execAgentBrowser(sessionName, ['fill', element, '']) - return { cleared: element } - } - - await this.fillExplicitContentEditable(sessionName, element, '') - return { cleared: element } - }, - { requireScopedTarget: true } - ) - } - - async selectAll( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser has no select-all command — implement as focus + Ctrl+A - await this.execAgentBrowser(sessionName, ['focus', element]) - return (await this.execAgentBrowser(sessionName, [ - 'press', - 'Control+a' - ])) as BrowserSelectAllResult - }) - } - - async keypress( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult - }) - } - - async pdf(worktreeId?: string, browserPageId?: string): Promise { - // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - const buffer = await wc.printToPDF({ - printBackground: true, - preferCSSPageSize: true - }) - return { data: buffer.toString('base64') } - }) - } - - // ── Cookie commands ── - - async cookieGet( - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'cookies', - 'get' - ])) as BrowserCookieGetResult - }) - } - - async cookieSet( - cookie: Partial, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] - if (cookie.domain) { - args.push('--domain', cookie.domain) - } - if (cookie.path) { - args.push('--path', cookie.path) - } - if (cookie.secure) { - args.push('--secure') - } - if (cookie.httpOnly) { - args.push('--httpOnly') - } - if (cookie.sameSite) { - args.push('--sameSite', cookie.sameSite) - } - if (cookie.expires != null) { - args.push('--expires', String(cookie.expires)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult - }) - } - - async cookieDelete( - name?: string, - domain?: string, - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'clear'] - if (name) { - args.push('--name', name) - } - if (domain) { - args.push('--domain', domain) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult - }) - } - - // ── Viewport / emulation commands ── - - async setViewport( - width: number, - height: number, - scale = 1, - mobile = false, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - const dbg = wc.debugger - if (!dbg.isAttached()) { - throw new BrowserError('browser_error', 'Debugger not attached') - } - - // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor: scale, - mobile - }) - // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. - await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( - () => {} - ) - - return { - width, - height, - deviceScaleFactor: scale, - mobile - } - }) - } - - async setGeolocation( - lat: number, - lon: number, - _accuracy?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'set', - 'geo', - String(lat), - String(lon) - ])) as BrowserGeolocationResult - }) - } - - // ── Network interception commands ── - - async interceptEnable( - patterns?: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser uses "network route " to intercept. Route each pattern individually. - const urlPattern = patterns?.[0] ?? '**/*' - const args = ['network', 'route', urlPattern] - const result = (await this.execAgentBrowser( - sessionName, - args - )) as BrowserInterceptEnableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = patterns ?? ['*'] - } - return result - }) - } - - async interceptDisable( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'unroute' - ])) as BrowserInterceptDisableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = [] - } - return result - }) - } - - async interceptList( - worktreeId?: string, - browserPageId?: string - ): Promise<{ requests: unknown[] }> { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { - requests: unknown[] - } - }) - } - - // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. - - // ── Capture commands ── - - async captureStart( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'start' - ])) as BrowserCaptureStartResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = true - } - return result - }) - } - - async captureStop( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'stop' - ])) as BrowserCaptureStopResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = false - } - return result - }) - } - - async consoleLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult - }) - } - - async networkLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'network', - 'requests' - ])) as BrowserNetworkLogResult - }) - } - - // ── Generic passthrough ── - - async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. - const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Session lifecycle ── - - // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with - // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. - async sweepOrphanedSessions(): Promise { - return sweepOrphanedAgentBrowserSessions({ - binaryPath: this.agentBrowserBin, - env: this.agentBrowserEnv, - ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, - isSessionLive: (sessionName) => - this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) - }) - } - - async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { - this.shutdownStarted = true - // Why the union: a session still being created has already spawned its daemon but is not in - // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). - const sessionNames = new Set([ - ...this.sessions.keys(), - ...this.pendingSessionCreation.keys(), - ...this.pendingSessionDestruction.keys() - ]) - await mapSettledWithConcurrency( - [...sessionNames], - AGENT_BROWSER_CLEANUP_CONCURRENCY, - (sessionName) => this.destroySession(sessionName, options) - ) - this.pendingInterceptRestore.clear() - } - - // ── Internal ── - - private async enqueueCommand( - worktreeId: string | undefined, - execute: (sessionName: string) => Promise - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - undefined, - async (sessionName) => execute(sessionName), - { ensureVisible: false } - ) - } - - private async enqueueTargetedCommand( - worktreeId: string | undefined, - browserPageId: string | undefined, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions = {} - ): Promise { - this.assertCommandAdmission() - const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - - if (options.ensureSession !== false) { - await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) - } - this.assertCommandAdmission() - - return new Promise((resolve, reject) => { - let queue = this.commandQueues.get(sessionName) - if (!queue) { - queue = [] - this.commandQueues.set(sessionName, queue) - } - queue.push({ - execute: (() => - this.executeWithVisibleTarget( - sessionName, - worktreeId, - target, - execute, - options - )) as () => Promise, - resolve: resolve as (value: unknown) => void, - reject - }) - this.processQueue(sessionName) - }) - } - - private async executeWithVisibleTarget( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions - ): Promise { - if (options.ensureVisible === false) { - return execute(sessionName, target) - } - - // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. - const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) - try { - const visibleTarget = await this.refreshTargetAfterAutomationVisibility( - sessionName, - worktreeId, - target, - options - ) - return await execute(sessionName, visibleTarget) - } finally { - restore() - } - } - - private async refreshTargetAfterAutomationVisibility( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - options: EnqueueTargetedCommandOptions - ): Promise { - const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (visibleTarget.webContentsId === target.webContentsId) { - return visibleTarget - } - - if (this.activeWebContentsId === target.webContentsId) { - this.activeWebContentsId = visibleTarget.webContentsId - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { - this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) - } - - // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. - await this.restartSessionForTarget( - sessionName, - visibleTarget.browserPageId, - visibleTarget.webContentsId, - { recreate: options.ensureSession !== false } - ) - - return visibleTarget - } - - private async processQueue(sessionName: string): Promise { - if (this.processingQueues.has(sessionName)) { - return - } - this.processingQueues.add(sessionName) - - const queue = this.commandQueues.get(sessionName) - while (queue && queue.length > 0) { - const cmd = queue.shift()! - try { - const result = await cmd.execute() - cmd.resolve(result) - } catch (error) { - cmd.reject(error) - } - } - - if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { - this.commandQueues.delete(sessionName) - } - this.processingQueues.delete(sessionName) - } - - getActivePageId(worktreeId?: string, browserPageId?: string): string | null { - try { - return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId - } catch { - return null - } - } - - private resolveCommandTarget( - worktreeId?: string, - browserPageId?: string, - requireScopedTarget = false - ): ResolvedBrowserCommandTarget { - if (!browserPageId) { - return requireScopedTarget - ? this.resolveScopedActiveTab(worktreeId) - : this.resolveActiveTab(worktreeId) - } - - const tabs = this.getRegisteredTabs(worktreeId) - const webContentsId = tabs.get(browserPageId) - if (webContentsId == null) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - - if (!this.getWebContents(webContentsId)) { - this.browserManager.unregisterGuest(browserPageId) - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - return { browserPageId, webContentsId } - } - - private resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - const tabs = this.getRegisteredTabs(worktreeId) - - if (tabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - - // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. - const preferredWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - - if (preferredWcId != null) { - for (const [tabId, wcId] of tabs) { - if (wcId === preferredWcId && this.getWebContents(wcId)) { - return { browserPageId: tabId, webContentsId: wcId } - } - if (wcId === preferredWcId) { - this.browserManager.unregisterGuest(tabId) - if (this.activeWebContentsId === wcId) { - this.activeWebContentsId = null - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { - this.activeWebContentsPerWorktree.delete(worktreeId) - } - } - } - } - - // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. - for (const [tabId, wcId] of tabs) { - if (this.getWebContents(wcId)) { - this.activeWebContentsId = wcId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - } - return { browserPageId: tabId, webContentsId: wcId } - } - this.browserManager.unregisterGuest(tabId) - } - - throw new BrowserError( - 'browser_no_tab', - 'No live browser tab available — all registered tabs have been destroyed' - ) - } - - // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. - private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - if (worktreeId) { - return this.resolveActiveTab(worktreeId) - } - - const worktreesWithLiveTabs = new Set() - for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { - if (this.getWebContents(wcId)) { - worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) - } - } - - if (worktreesWithLiveTabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - if (worktreesWithLiveTabs.size > 1) { - throw new BrowserError( - 'browser_target_ambiguous', - 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' - ) - } - - const [onlyWorktreeId] = worktreesWithLiveTabs - return this.resolveActiveTab(onlyWorktreeId) - } - - private async ensureSession( - sessionName: string, - browserPageId: string, - webContentsId: number - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - } - this.assertCommandAdmission() - - if (this.sessions.has(sessionName)) { - return - } - - // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. - const pending = this.pendingSessionCreation.get(sessionName) - if (pending) { - await pending - this.assertCommandAdmission() - return - } - - const createSession = async (): Promise => { - const wc = this.getWebContents(webContentsId) - if (!wc) { - // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. - await this.closeStaleAgentBrowserSession(sessionName) - - const proxy = new CdpWsProxy(wc) - const cdpEndpoint = await proxy.start() - - this.sessions.set(sessionName, { - proxy, - cdpEndpoint, - initialized: false, - consecutiveTimeouts: 0, - activeInterceptPatterns: [], - activeCapture: false, - lastCommandAt: Date.now(), - webContentsId, - activeProcess: null - }) - } - - const promise = createSession() - this.pendingSessionCreation.set(sessionName, promise) - try { - await promise - } finally { - this.pendingSessionCreation.delete(sessionName) - } - } - - private async restartSessionForTarget( - sessionName: string, - browserPageId: string, - webContentsId: number, - options: { recreate: boolean } = { recreate: true } - ): Promise { - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - await pendingCreation.catch(() => {}) - } - - const session = this.sessions.get(sessionName) - if (session) { - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - if (session.activeProcess) { - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { - timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS - }) - } catch { - // Session may already be dead. - } - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - if (options.recreate) { - await this.ensureSession(sessionName, browserPageId, webContentsId) - } - } - - private async destroySession( - sessionName: string, - options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - return - } - - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. - try { - await pendingCreation - } catch { - // Creation failures are handled by the original caller; teardown still rejects queued work below. - } - } - - const session = this.sessions.get(sessionName) - if (!session) { - this.rejectQueuedCommandsForClosedSession(sessionName) - return - } - - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - - // Why: queued commands would hang forever if we just delete the queue — drain and reject them. - this.rejectQueuedCommandsForClosedSession(sessionName) - - if (session.activeProcess) { - // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - // Why: each tab has its own named session — close without --session leaves this tab's daemon running. - // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. - await this.runAgentBrowserRaw( - sessionName, - ['--session', sessionName, 'close'], - options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } - ) - } catch { - // Session may already be dead - } - - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - private rejectQueuedCommandsForClosedSession(sessionName: string): void { - const queue = this.commandQueues.get(sessionName) - this.commandQueues.delete(sessionName) - this.processingQueues.delete(sessionName) - if (queue) { - const err = new BrowserError( - 'browser_tab_closed', - 'Tab was closed while commands were queued' - ) - for (const cmd of queue) { - cmd.reject(err) - } - queue.length = 0 - } - } - - /** - * Notice that the daemon retired itself between two commands. - * - * A replacement daemon still serves the page (every call reasserts `--cdp`) - * but carries none of the session's network routes, so without this the - * interception the caller configured is silently gone (#16367). - */ - private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { - if ( - this.agentBrowserIdleTimeoutMs === null || - Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs - ) { - return - } - session.initialized = false - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - } - - private assertCommandAdmission(): void { - if (this.shutdownStarted) { - throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') - } - } - - private async execAgentBrowser( - sessionName: string, - commandArgs: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - const session = this.sessions.get(sessionName) - if (!session) { - // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. - throw this.createPageUnavailableError(sessionName) - } - - // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. - if (!this.getWebContents(session.webContentsId)) { - await this.destroySession(sessionName) - throw this.createPageUnavailableError(sessionName) - } - - this.reinitializeIfDaemonIdledOut(sessionName, session) - session.lastCommandAt = Date.now() - - const args = ['--session', sessionName] - const managesInterceptRoutes = - commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') - - const needsInit = !session.initialized - // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. - args.push('--cdp', String(session.proxy.getPort())) - - // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. - for (const commandArg of commandArgs) { - args.push(commandArg) - } - args.push('--json') - - const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) - const translated = translateResult(stdout) - - if (!translated.ok) { - throw this.createCommandError( - sessionName, - translated.error.message, - translated.error.code, - session.webContentsId - ) - } - - // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. - if (needsInit) { - session.initialized = true - - // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. - const pendingPatterns = managesInterceptRoutes - ? undefined - : this.pendingInterceptRestore.get(sessionName) - if (pendingPatterns && pendingPatterns.length > 0) { - this.pendingInterceptRestore.delete(sessionName) - try { - const urlPattern = pendingPatterns[0] ?? '**/*' - await this.runAgentBrowserRaw(sessionName, [ - '--session', - sessionName, - '--cdp', - String(session.proxy.getPort()), - 'network', - 'route', - urlPattern, - '--json' - ]) - session.activeInterceptPatterns = pendingPatterns - } catch { - // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. - } - } - } - - return translated.result - } - - private async isExplicitContentEditableTarget( - sessionName: string, - element: string - ): Promise { - const result = await this.execAgentBrowser(sessionName, [ - 'get', - 'attr', - element, - 'contenteditable' - ]) - return isExplicitContentEditableResult(result) - } - - private async fillExplicitContentEditable( - sessionName: string, - element: string, - value: string - ): Promise { - await this.execAgentBrowser(sessionName, ['focus', element]) - // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. - await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { - stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) - }) - } - - private createPageUnavailableError(sessionName: string): BrowserError { - return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) - } - - private closeStaleAgentBrowserSession(sessionName: string): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - } else { - resolve() - } - } - - // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. - const timeout = setTimeout(() => { - child?.kill() - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` - ) - ) - }, STALE_SESSION_CLOSE_TIMEOUT_MS) - - try { - child = execFile( - this.agentBrowserBin, - ['--session', sessionName, 'close'], - // Why windowsHide: agent-browser is console-subsystem and Orca's main - // process owns no console, so each spawn gets a fresh visible conhost - // that takes foreground -- keystrokes typed into a terminal at that - // moment land in the black box (#14543). - { - env: this.agentBrowserEnv, - timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, - windowsHide: true - }, - (error) => - finish( - error - ? new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error.message}` - ) - : undefined - ) - ) - } catch (error) { - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` - ) - ) - } - }) - } - - private createCommandError( - sessionName: string, - message: string, - fallbackCode: string, - webContentsId?: number - ): BrowserError { - // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. - if ( - fallbackCode === 'browser_error' && - isTabClosedTransportError(message) && - this.isSessionTargetClosed(sessionName, webContentsId) - ) { - return this.createPageUnavailableError(sessionName) - } - return new BrowserError(fallbackCode, message) - } - - private isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { - const session = this.sessions.get(sessionName) - if (!session) { - return true - } - const targetWebContentsId = webContentsId ?? session.webContentsId - return !this.getWebContents(targetWebContentsId) - } - - private runAgentBrowserRaw( - sessionName: string, - args: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - return new Promise((resolve, reject) => { - const session = this.sessions.get(sessionName) - let child: ChildProcess | null = null - child = execFile( - this.agentBrowserBin, - args, - // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). - { - timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, - maxBuffer: 50 * 1024 * 1024, - // Why windowsHide: see the stale-session close above -- every - // agent-browser invocation would otherwise flash a console (#14543). - windowsHide: true, - env: execOptions?.envOverrides - ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } - : this.agentBrowserEnv - }, - (error, stdout, stderr) => { - if (session && session.activeProcess === child) { - session.activeProcess = null - } - if (child && this.cancelledProcesses.has(child)) { - this.cancelledProcesses.delete(child) - reject( - new BrowserError('browser_tab_closed', 'Tab was closed while command was running') - ) - return - } - - const liveSession = this.sessions.get(sessionName) - - if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { - if (execOptions?.timeoutError) { - reject(execOptions.timeoutError) - return - } - if (liveSession) { - liveSession.consecutiveTimeouts++ - if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { - // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate - this.destroySession(sessionName) - } - } - reject(new BrowserError('browser_error', 'Browser command timed out')) - return - } - - if (liveSession) { - liveSession.consecutiveTimeouts = 0 - } - - if (error) { - // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. - if (stdout) { - try { - const parsed = JSON.parse(stdout) - if (parsed.error) { - const code = classifyErrorCode(parsed.error) - reject( - this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) - ) - return - } - } catch { - // stdout not valid JSON — fall through to stderr/error.message - } - } - const message = stderr || error.message - const code = classifyErrorCode(message) - reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) - return - } - - resolve(stdout) - } - ) - if (session) { - session.activeProcess = child - } - if (execOptions?.stdinText !== undefined && child?.stdin) { - // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. - child.stdin.on('error', () => {}) - child.stdin.end(execOptions.stdinText) - } - }) - } - - private resolveTabIdSafe(webContentsId: number): string | null { - return this.browserManager.getTabIdForWebContentsId(webContentsId) - } - - private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) - } - return wc - } - - private getWebContents(webContentsId: number): Electron.WebContents | null { - try { - const { webContents } = require('electron') - const target = webContents.fromId(webContentsId) - return target && !target.isDestroyed() ? target : null - } catch { - return null - } - } -} +import { AgentBrowserBridgeStateCommands } from './agent-browser-bridge-state-commands' + +export { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES +} from './agent-browser-bridge-types' +export type { + BrowserMouseModifier, + AgentBrowserCleanupOptions, + AgentBrowserBridgeOptions +} from './agent-browser-bridge-types' + +/** + * Routes automation commands to the browser guest registered for the selected tab. + * + * The implementation is layered by responsibility (tab targeting, command queue, + * session lifecycle, and command families) so each layer stays independently + * reviewable while preserving this public facade. + */ +export class AgentBrowserBridge extends AgentBrowserBridgeStateCommands {} diff --git a/src/main/browser/browser-cookie-chromium-finalize.ts b/src/main/browser/browser-cookie-chromium-finalize.ts new file mode 100644 index 00000000000..46203d0d151 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-finalize.ts @@ -0,0 +1,132 @@ +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { browserSessionRegistry } from './browser-session-registry' +import { removeTransplantableCookies } from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write' +import { deriveUrl } from './browser-cookie-validation' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function finalizeChromiumCookieImport( + context: ChromiumImportContext +): Promise { + if (context.decryptedCookies.length === 0) { + const zeroPathWarning = context.undecryptableWarning + context.closeStagingDb() + context.discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: context.sourceRows.length, + importedCookies: 0, + skippedCookies: + context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 + ? { partitionSkippedCookies: context.partitionSkipped } + : {}), + domains: [], + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) + } + } + } + + if (context.stagingDb) { + try { + context.stagingDb.exec('COMMIT') + context.closeStagingDb() + diag( + ` SQLite staging complete: ${context.imported} cookies, ${context.domainSet.size} domains` + ) + } catch (err) { + context.disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${context.imported} cookies will load in-memory only`) + } + + const cookieClearStore = openCookieClearStore(context.targetSession) + try { + await removeTransplantableCookies( + { + cookies: cookieClearStore, + snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), + restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) + }, + context.nativePlan.skippedFamilies, + context.importScope + ) + diag( + ` cleared existing cookies for ${context.domainSet.size} imported domains before loading ${context.decryptedCookies.length} imported cookies` + ) + + const writable: SourceCookieToWrite[] = [] + for (const cookie of context.decryptedCookies) { + const url = deriveUrl(cookie.domain, cookie.secure) + if (!url) { + context.memoryFailed++ + continue + } + writable.push({ ...cookie, url }) + } + const phase = await writeImportedCookies(cookieClearStore, writable, { + stopOnFailure: false, + log: diag + }) + context.memoryLoaded = phase.importedCount + context.memoryFailed += phase.writeRejected + } finally { + cookieClearStore.dispose() + } + + diag( + ` memory load: ${context.memoryLoaded} OK, ${context.memoryFailed} failed, ${context.partitionSkipped} partition-unreadable` + ) + + let warning: BrowserCookieImportSummary['warning'] + if (context.memoryFailed > 0 && context.stagingAvailable) { + browserSessionRegistry.setPendingCookieImport( + context.targetPartition, + context.stagingCookiesPath + ) + diag( + ` staged at ${context.stagingCookiesPath} for ${context.memoryFailed} cookies that need restart` + ) + } else if (context.memoryFailed > 0) { + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(` ${context.memoryFailed} cookies need a restart but staging is unavailable — skipped`) + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: context.memoryLoaded, + failedCookies: context.memoryFailed + } + } else { + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(' all cookies loaded in-memory — no restart needed') + } + + if (!warning && context.undecryptableWarning) { + warning = context.undecryptableWarning + } + + const summary: BrowserCookieImportSummary = { + totalCookies: context.sourceRows.length, + importedCookies: context.imported, + skippedCookies: context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 ? { partitionSkippedCookies: context.partitionSkipped } : {}), + domains: [...context.domainSet].sort(), + ...(warning ? { warning } : {}) + } + return { ok: true, profileId: '', summary } +} diff --git a/src/main/browser/browser-cookie-chromium-import.ts b/src/main/browser/browser-cookie-chromium-import.ts new file mode 100644 index 00000000000..e9ee2ebe386 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-import.ts @@ -0,0 +1,74 @@ +import { session } from 'electron' +import { existsSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { withCookieMutationLock } from './browser-cookie-import-clear' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import { prepareChromiumCookieImport } from './browser-cookie-chromium-prepare' +import { scanChromiumCookieRows } from './browser-cookie-chromium-scan' +import { finalizeChromiumCookieImport } from './browser-cookie-chromium-finalize' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function importChromiumCookies( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions = {} +): Promise { + diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) + if (!existsSync(browser.cookiesPath)) { + diag(` cookies DB not found: ${browser.cookiesPath}`) + return { ok: false, reason: `${browser.label} cookies database not found.` } + } + + const targetSession = session.fromPartition(targetPartition) + return withCookieMutationLock(targetSession, async () => { + let context: ChromiumImportContext | null = null + try { + const preparation = await prepareChromiumCookieImport( + browser, + targetPartition, + options, + targetSession + ) + if ('result' in preparation) { + return preparation.result + } + context = preparation.context + const scanResult = scanChromiumCookieRows(context) + if (scanResult) { + return scanResult + } + return await finalizeChromiumCookieImport(context) + } catch (err) { + if (context) { + try { + context.sourceDb?.close() + } catch { + /* may already be closed */ + } + context.closeStagingDb() + context.discardStagingFile() + } + diag(` SQLite import failed: ${String(err)}`) + return { + ok: false, + reason: reasonWithDiagLog( + `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` + ) + } + } finally { + if (context) { + try { + context.sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } + } + } + }) +} diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts new file mode 100644 index 00000000000..d9cf4d30ba3 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -0,0 +1,289 @@ +import { app } from 'electron' +import { randomUUID } from 'node:crypto' +import { mkdirSync, unlinkSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain +} from './browser-cookie-import-policy' +import { createChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { planImportWrites } from './browser-cookie-import-write' +import { readChromiumRowPartition } from './browser-cookie-source-partition' +import { diag } from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { ChromiumCookieColumnInfo } from './browser-cookie-sqlite' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' +import type { Session } from 'electron' +import { getEncryptionKey } from './browser-cookie-key' + +export type ChromiumImportPreparation = + | { context: ChromiumImportContext } + | { result: BrowserCookieImportResult } + +export async function prepareChromiumCookieImport( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions, + targetSession: Session +): Promise { + await targetSession.cookies.flushStore() + const partitionDir = targetSession.getStoragePath() + if (!partitionDir) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const partitionName = targetPartition.replace('persist:', '') + let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + // Why: initialize an unused profile so Chromium creates its Cookies database. + if (!liveCookiesPath) { + try { + await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) + await targetSession.cookies.remove('https://localhost', '__init') + await targetSession.cookies.flushStore() + } catch { + // ignore — flushStore still creates the file on supported Electron versions + } + liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + } + if (!liveCookiesPath) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') + const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') + const stagingCookiesPath = join( + stagingDir, + `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` + ) + let stagingAvailable = false + if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { + diag(` restart fallback unsupported for partition "${targetPartition}" — not staging cookies`) + } else { + try { + mkdirSync(stagingDir, { recursive: true }) + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } + } + + let sourceSnapshot: ReturnType + try { + // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. + sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) + } catch (err) { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` Chromium snapshot failed: ${String(err)}`) + return { + result: { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + } + + let sourceDb: InstanceType | null = null + let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagingCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + stagingDb.exec('PRAGMA journal_mode = DELETE') + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols = targetColumnInfo.map((row) => row.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + discardStagingFile() + } + } + + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name + ) + ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + diag(` source has ${sourceRows.length} cookies`) + if (sourceRows.length === 0) { + closeStagingDb() + discardStagingFile() + return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } + } + + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) + ) + if (nativePlan.hasUnrepresentableSkip) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + } + + const needsSourceKey = sourceRows.some((sourceRow) => { + const encrypted = sourceRow.encrypted_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + return false + } + return ( + !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && + !isNonTransplantableCookieDomain(sourceRow.host_key as string) + ) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + } + + let insertStmt: ChromiumImportContext['insertStmt'] = null + const context: ChromiumImportContext = { + browser, + targetPartition, + options, + targetSession, + stagingCookiesPath, + stagingAvailable, + sourceSnapshot, + sourceDb, + stagingDb, + targetColumnInfo, + colList, + placeholders, + sourceColumns, + sourceRows, + nativePlan, + plannedSourceRows, + partitionBySourceRow, + sourceKey, + imported: 0, + skipped: 0, + decryptFailed: 0, + appBoundFailed: 0, + keyringUnavailableFailed: 0, + integritySkipped: 0, + nonTransplantableSkipped: 0, + partitionSkipped: nativePlan.skips.length, + googleCookiesSkipped: 0, + memoryLoaded: 0, + memoryFailed: 0, + domainSet: new Set(), + decryptedCookies: [], + scanned: [], + sourceDomainValidity: new Map(), + insertStmt, + importScope: { + exact: new Set(), + ancestors: new Set(), + descendantRoots: new Set() + }, + closeStagingDb, + discardStagingFile, + disableStaging: (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + context.stagingAvailable = false + context.insertStmt = null + context.closeStagingDb() + context.discardStagingFile() + } + } satisfies ChromiumImportContext + + if (context.stagingDb && context.colList && context.placeholders) { + try { + context.insertStmt = context.stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + ) + context.stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + context.disableStaging(String(err)) + } + } else if (context.stagingAvailable) { + context.disableStaging('staged database exposed no cookies columns') + } + if (context.nativePlan.skippedFamilies.size > 0) { + context.disableStaging( + `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` + ) + } + return { context } +} diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts new file mode 100644 index 00000000000..dd7e061bebc --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -0,0 +1,167 @@ +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + importedDomainScope +} from './browser-cookie-import-policy' +import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' +import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' +import { chromiumSameSite } from './browser-cookie-validation' +import { + buildUndecryptableWarning, + cookieEncryptionVersion, + decryptCookieValueRaw +} from './browser-cookie-decryption' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +/** + * Decrypts and validates source rows without touching the target cookie jar. + * Keeping this pass separate makes the write scope derive from one complete plan. + */ +export function scanChromiumCookieRows( + context: ChromiumImportContext +): BrowserCookieImportResult | null { + const { sourceRows, sourceKey, plannedSourceRows, partitionBySourceRow, targetColumnInfo } = + context + + for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + context.integritySkipped++ + continue + } + if (isNonTransplantableCookieDomain(domain)) { + context.nonTransplantableSkipped++ + continue + } + + const encRaw = sourceRow.encrypted_value + const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null + const plainRaw = sourceRow.value + let decryptedValue: Buffer + if (encBuf && encBuf.length > 0) { + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null + if (!raw) { + // Why: retain the prefix while it is available so diagnostics identify the failure cause. + context.decryptFailed++ + if (appBoundIneligible) { + context.appBoundFailed++ + } else if (keyringIneligible) { + context.keyringUnavailableFailed++ + } + context.skipped++ + continue + } + decryptedValue = raw + } else if (plainRaw instanceof Uint8Array) { + decryptedValue = Buffer.from(plainRaw) + } else if (typeof plainRaw === 'string') { + decryptedValue = Buffer.from(plainRaw, 'latin1') + } else { + decryptedValue = Buffer.alloc(0) + } + + let validDomain = context.sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + context.sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + context.skipped++ + continue + } + // Decryption failures are counted above; planned family omissions are counted once here. + if (!plannedSourceRows.has(sourceRow)) { + context.skipped++ + continue + } + + const path = sourceRow.path as string + const secure = sourceRow.is_secure === 1n + const httpOnly = sourceRow.is_httponly === 1n + const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) + const partition = partitionBySourceRow.get(sourceRow)! + const value = decryptedValue.toString('latin1') + context.scanned.push({ + entry: { + decryptedValue, + value, + domain, + name, + path, + secure, + httpOnly, + sameSite, + expirationDate: expiresUtc > 0 ? expiresUtc : undefined, + partition + }, + sourceRow + }) + } + + for (const { entry } of context.scanned) { + context.domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) + } + context.importScope = importedDomainScope([...context.domainSet]) + + if (context.stagingDb && context.insertStmt) { + try { + prepareStagedCookiesForImport(context.stagingDb, context.importScope) + } catch (err) { + context.disableStaging(String(err)) + } + } + + // EMIT: all downstream writes derive from the one scan, so no row can leak into the jar. + for (const { entry, sourceRow } of context.scanned) { + context.decryptedCookies.push(entry) + if (context.insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + entry.decryptedValue + ) + context.insertStmt.run(...params) + } catch (err) { + context.disableStaging(String(err)) + } + } + context.imported++ + } + + diag( + ` skipped ${context.integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${context.nonTransplantableSkipped} non-transplantable-domain cookies` + ) + context.googleCookiesSkipped = context.integritySkipped + context.nonTransplantableSkipped + context.undecryptableWarning = buildUndecryptableWarning({ + decryptFailed: context.decryptFailed, + appBoundFailed: context.appBoundFailed, + keyringUnavailableFailed: context.keyringUnavailableFailed + }) + + if (context.partitionSkipped > 0 && context.options.canReportPartitionSkippedCookies === false) { + context.closeStagingDb() + context.discardStagingFile() + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + return null +} diff --git a/src/main/browser/browser-cookie-chromium-types.ts b/src/main/browser/browser-cookie-chromium-types.ts new file mode 100644 index 00000000000..9bf792de01c --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-types.ts @@ -0,0 +1,83 @@ +import type { Session } from 'electron' +import type { DatabaseSync } from 'node:sqlite' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { + ImportedCookieFields, + ImportWritePhase, + SourceCookieToWrite +} from './browser-cookie-import-write' +import type { SourcePartitionRead } from './browser-cookie-source-partition' +import type { ImportedDomainScope } from './browser-cookie-import-policy' +import type { ChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import type { ChromiumCookieColumnInfo, EncryptionKeyResult } from './browser-cookie-sqlite' + +export type ChromiumSourceRow = Record + +export type DecryptedCookie = Omit & { + decryptedValue: Buffer + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +export type ScannedChromiumCookie = { + entry: DecryptedCookie + sourceRow: ChromiumSourceRow +} + +export type ChromiumImportPlan = { + writes: { sourceRow: ChromiumSourceRow; domain: string; partition: SourcePartitionRead }[] + skips: unknown[] + skippedFamilies: Set + hasUnrepresentableSkip: boolean +} + +export type ChromiumImportContext = { + browser: DetectedBrowser + targetPartition: string + options: CookieImportOptions + targetSession: Session + stagingCookiesPath: string + stagingAvailable: boolean + sourceSnapshot: ChromiumCookieSnapshot + sourceDb: InstanceType | null + stagingDb: InstanceType | null + targetColumnInfo: ChromiumCookieColumnInfo[] | null + colList: string | null + placeholders: string | null + sourceColumns: Set + sourceRows: ChromiumSourceRow[] + nativePlan: ChromiumImportPlan + plannedSourceRows: Set + partitionBySourceRow: Map + sourceKey: EncryptionKeyResult | null + imported: number + skipped: number + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number + integritySkipped: number + nonTransplantableSkipped: number + partitionSkipped: number + googleCookiesSkipped: number + memoryLoaded: number + memoryFailed: number + domainSet: Set + decryptedCookies: DecryptedCookie[] + scanned: ScannedChromiumCookie[] + sourceDomainValidity: Map + insertStmt: ReturnType['prepare']> | null + importScope: ImportedDomainScope + closeStagingDb: () => void + discardStagingFile: () => void + disableStaging: (reason: string) => void + undecryptableWarning?: BrowserCookieImportSummary['warning'] + warning?: BrowserCookieImportSummary['warning'] + writePhase?: ImportWritePhase + writable?: SourceCookieToWrite[] + result?: BrowserCookieImportResult +} diff --git a/src/main/browser/browser-cookie-decryption.ts b/src/main/browser/browser-cookie-decryption.ts new file mode 100644 index 00000000000..04fb002f7f4 --- /dev/null +++ b/src/main/browser/browser-cookie-decryption.ts @@ -0,0 +1,126 @@ +import { createDecipheriv } from 'node:crypto' +import type { BrowserCookieImportSummary } from '../../shared/browser-workspace-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. +const CHROMIUM_COOKIE_HMAC_LEN = 32 + +function hasHmacPrefix(buf: Buffer): boolean { + if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { + return false + } + let nonPrintable = 0 + for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { + if (buf[i] < 0x20 || buf[i] > 0x7e) { + nonPrintable++ + } + } + return nonPrintable >= 8 +} + +function stripHmac(buf: Buffer): Buffer { + return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf +} + +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +export function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +export function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + +export function decryptCookieValueRaw( + encryptedBuffer: Buffer, + keyResult: EncryptionKeyResult +): Buffer | null { + if (!encryptedBuffer || encryptedBuffer.length === 0) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + if (!/^v\d\d$/.test(version)) { + return null + } + + if (keyResult.mode === 'aes-256-gcm') { + return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) + } + + // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + + const ciphertext = encryptedBuffer.subarray(3) + if (!ciphertext.length) { + return null + } + + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} + +function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { + // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] + if (payload.length < 12 + 16) { + return null + } + const nonce = payload.subarray(0, 12) + const authTag = payload.subarray(-16) + const ciphertext = payload.subarray(12, -16) + try { + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} diff --git a/src/main/browser/browser-cookie-detection-types.ts b/src/main/browser/browser-cookie-detection-types.ts new file mode 100644 index 00000000000..bac46f6d750 --- /dev/null +++ b/src/main/browser/browser-cookie-detection-types.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type { BrowserSessionProfileSource } from '../../shared/browser-workspace-types' + +export type BrowserProfile = { + name: string + directory: string +} + +export type DetectedBrowser = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + cookiesPath: string + keychainService?: string + keychainAccount?: string + profiles: BrowserProfile[] + selectedProfile: string +} + +export type ChromiumBrowserDef = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + keychainService: string + keychainAccount: string + // Per-platform data-dir roots, resolved at detection time via browserRootPath(). + macRoot?: string + winRoot?: string + linuxRoot?: string +} + +export const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ + { + family: 'chrome', + label: 'Google Chrome', + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + macRoot: 'Google/Chrome', + winRoot: 'Google/Chrome/User Data', + linuxRoot: 'google-chrome' + }, + { + family: 'edge', + label: 'Microsoft Edge', + keychainService: 'Microsoft Edge Safe Storage', + keychainAccount: 'Microsoft Edge', + macRoot: 'Microsoft Edge', + winRoot: 'Microsoft/Edge/User Data', + linuxRoot: 'microsoft-edge' + }, + { + family: 'arc', + label: 'Arc', + keychainService: 'Arc Safe Storage', + keychainAccount: 'Arc', + macRoot: 'Arc/User Data' + }, + { + family: 'chromium', + label: 'Brave', + keychainService: 'Brave Safe Storage', + keychainAccount: 'Brave', + macRoot: 'BraveSoftware/Brave-Browser', + winRoot: 'BraveSoftware/Brave-Browser/User Data', + linuxRoot: 'BraveSoftware/Brave-Browser' + }, + { + family: 'comet', + label: 'Comet', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + macRoot: 'Comet', + winRoot: 'Comet/User Data' + // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 + }, + { + family: 'helium', + // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. + label: 'Helium', + keychainService: 'Helium Storage Key', + keychainAccount: 'Helium', + macRoot: 'net.imput.helium' + // winRoot/linuxRoot intentionally omitted — only the macOS install is verified + } +] + +export function browserRootPath(def: ChromiumBrowserDef): string | null { + if (process.platform === 'darwin') { + if (!def.macRoot) { + return null + } + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', def.macRoot) + } + if (process.platform === 'win32') { + if (!def.winRoot) { + return null + } + const localAppData = process.env.LOCALAPPDATA ?? '' + if (!localAppData) { + return null + } + return join(localAppData, def.winRoot) + } + // Linux + if (!def.linuxRoot) { + return null + } + const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') + return join(configHome, def.linuxRoot) +} + +export function isSafeBrowserProfileDirectory(directory: string): boolean { + return ( + directory.length > 0 && + directory !== '.' && + !directory.includes('\0') && + !directory.includes('/') && + !directory.includes('\\') && + !directory.includes('..') + ) +} + +// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. +export function discoverProfiles(browserRoot: string): BrowserProfile[] { + try { + const localStatePath = join(browserRoot, 'Local State') + if (!existsSync(localStatePath)) { + return [{ name: 'Default', directory: 'Default' }] + } + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const infoCache = localState?.profile?.info_cache + if (!infoCache || typeof infoCache !== 'object') { + return [{ name: 'Default', directory: 'Default' }] + } + const profiles: BrowserProfile[] = [] + for (const [dir, info] of Object.entries(infoCache)) { + // Why: Local State is external metadata, but profile dirs become path segments. + if (!isSafeBrowserProfileDirectory(dir)) { + continue + } + const profileName = (info as { name?: string })?.name ?? dir + profiles.push({ name: profileName, directory: dir }) + } + return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] + } catch { + return [{ name: 'Default', directory: 'Default' }] + } +} + +// --------------------------------------------------------------------------- +// Firefox detection +// --------------------------------------------------------------------------- + +export function firefoxProfilesRoot(): string | null { + if (process.platform === 'darwin') { + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? '' + return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null + } + const home = process.env.HOME ?? '' + return join(home, '.mozilla', 'firefox') +} + +export function discoverFirefoxProfiles(): BrowserProfile[] { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return [] + } + try { + if (!existsSync(profilesRoot)) { + return [] + } + const entries = readdirSync(profilesRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. + const sorted = entries.sort((a, b) => { + if (a.includes('default-release')) { + return -1 + } + if (b.includes('default-release')) { + return 1 + } + if (a.includes('default')) { + return -1 + } + if (b.includes('default')) { + return 1 + } + return 0 + }) + return sorted.map((dir) => { + const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir + return { name: label, directory: dir } + }) + } catch { + return [] + } +} + +export function detectFirefox(): DetectedBrowser | null { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const profiles = discoverFirefoxProfiles() + for (const profile of profiles) { + const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') + if (existsSync(cookiesPath)) { + return { + family: 'firefox', + label: 'Firefox', + cookiesPath, + profiles, + selectedProfile: profile.directory + } + } + } + return null +} diff --git a/src/main/browser/browser-cookie-detection.ts b/src/main/browser/browser-cookie-detection.ts new file mode 100644 index 00000000000..78c5d7c0ed8 --- /dev/null +++ b/src/main/browser/browser-cookie-detection.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { + CHROMIUM_BROWSERS, + browserRootPath, + discoverProfiles, + detectFirefox, + firefoxProfilesRoot, + isSafeBrowserProfileDirectory, + type DetectedBrowser +} from './browser-cookie-detection-types' + +// --------------------------------------------------------------------------- +// Safari detection +// --------------------------------------------------------------------------- + +export function detectSafari(): DetectedBrowser | null { + if (process.platform !== 'darwin') { + return null + } + const home = process.env.HOME ?? '' + const candidates = [ + join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), + join( + home, + 'Library', + 'Containers', + 'com.apple.Safari', + 'Data', + 'Library', + 'Cookies', + 'Cookies.binarycookies' + ) + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return { + family: 'safari', + label: 'Safari', + cookiesPath: candidate, + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } + } + } + return null +} + +export function detectInstalledBrowsers(): DetectedBrowser[] { + const detected: DetectedBrowser[] = [] + for (const browser of CHROMIUM_BROWSERS) { + const root = browserRootPath(browser) + if (!root) { + continue + } + const profiles = discoverProfiles(root) + // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. + for (const profile of profiles) { + const profileDir = join(root, profile.directory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (cookiesPath) { + detected.push({ + family: browser.family, + label: browser.label, + keychainService: browser.keychainService, + keychainAccount: browser.keychainAccount, + cookiesPath, + profiles, + selectedProfile: profile.directory + }) + break + } + } + } + + const firefox = detectFirefox() + if (firefox) { + detected.push(firefox) + } + + const safari = detectSafari() + if (safari) { + detected.push(safari) + } + + return detected +} + +export function selectBrowserProfile( + browser: DetectedBrowser, + profileDirectory: string +): DetectedBrowser | null { + if (!isSafeBrowserProfileDirectory(profileDirectory)) { + return null + } + if (browser.family === 'firefox') { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') + if (!existsSync(cookiesPath)) { + return null + } + return { ...browser, cookiesPath, selectedProfile: profileDirectory } + } + + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + const profileDir = join(root, profileDirectory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (!cookiesPath) { + return null + } + return { + ...browser, + cookiesPath, + selectedProfile: profileDirectory + } +} diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts new file mode 100644 index 00000000000..60482b30e31 --- /dev/null +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -0,0 +1,138 @@ +import { DatabaseSync } from 'node:sqlite' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { readFirefoxRowPartition } from './browser-cookie-source-partition' +import { + importValidatedCookies, + cookieImportTarget, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Firefox import +// --------------------------------------------------------------------------- + +export async function importCookiesFromFirefox( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions +): Promise { + diag(`importCookiesFromFirefox: partition="${targetPartition}"`) + + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) + const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') + + try { + copyFileSync(browser.cookiesPath, tmpCookiesPath) + for (const suffix of ['-wal', '-shm'] as const) { + const sidecar = browser.cookiesPath + suffix + if (existsSync(sidecar)) { + try { + copyFileSync(sidecar, tmpCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + } catch { + rmSync(tmpDir, { recursive: true, force: true }) + return { + ok: false, + reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' + } + } + + try { + const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) + type FirefoxRow = Record & { + name: string + value: string + host: string + path: string + expiry: number + isSecure: number + isHttpOnly: number + sameSite: number + isPartitionedAttributeSet?: number + } + // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema + // without the server-declared partition flag predates that cookie identity. + const firefoxColumns = new Set( + (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( + (column) => column.name + ) + ) + const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') + ? ', isPartitionedAttributeSet' + : '' + const rows = db + .prepare( + `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` + ) + .all() as FirefoxRow[] + db.close() + + diag(` Firefox source has ${rows.length} cookies`) + if (rows.length === 0) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'No cookies found in Firefox.' } + } + + const now = Math.floor(Date.now() / 1000) + const validated: ValidatedCookie[] = [] + for (const row of rows) { + if (!row.name || !row.host) { + continue + } + if (row.expiry > 0 && row.expiry < now) { + continue + } + + const domain = row.host + const secure = row.isSecure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + continue + } + + validated.push({ + url, + name: row.name, + value: row.value ?? '', + domain, + path: row.path || '/', + secure, + httpOnly: row.isHttpOnly === 1, + sameSite: firefoxSameSite(row.sameSite), + expirationDate: row.expiry > 0 ? row.expiry : undefined, + partition: readFirefoxRowPartition(row, firefoxColumns) + }) + } + + rmSync(tmpDir, { recursive: true, force: true }) + + if (validated.length === 0) { + return { ok: false, reason: 'No valid cookies found in Firefox.' } + } + + return importValidatedCookies( + validated, + rows.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains', + options + ) + } catch (err) { + rmSync(tmpDir, { recursive: true, force: true }) + diag(` Firefox import failed: ${String(err)}`) + return { + ok: false, + reason: 'Could not import cookies from Firefox. Try closing Firefox first.' + } + } +} diff --git a/src/main/browser/browser-cookie-import-diagnostics.ts b/src/main/browser/browser-cookie-import-diagnostics.ts new file mode 100644 index 00000000000..f80850fc58e --- /dev/null +++ b/src/main/browser/browser-cookie-import-diagnostics.ts @@ -0,0 +1,55 @@ +import { app } from 'electron' +import { appendFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. +let _diagLog: string | null = null +export function getDiagLogPath(): string { + if (!_diagLog) { + try { + _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') + } catch { + _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') + } + } + return _diagLog +} +export function reasonWithDiagLog(reason: string): string { + return `${reason} Details were written to ${getDiagLogPath()}.` +} +const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 +const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 + +// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. +export function summarizeCookieImportError(err: unknown): string { + const raw = err instanceof Error && err.message ? err.message : String(err) + let summary = '' + let previousWasWhitespace = false + const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) + for (let index = 0; index < scanLimit; index += 1) { + const code = raw.charCodeAt(index) + if (code === 32 || (code >= 9 && code <= 13)) { + if (summary.length > 0 && !previousWasWhitespace) { + summary += ' ' + } + previousWasWhitespace = true + continue + } + summary += raw.charAt(index) + if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { + return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) + } + previousWasWhitespace = false + } + return summary +} +export function diag(msg: string): void { + const line = `[${new Date().toISOString()}] ${msg}\n` + try { + appendFileSync(getDiagLogPath(), line) + } catch { + /* best-effort */ + } + console.log('[cookie-import]', msg) +} diff --git a/src/main/browser/browser-cookie-import-pipeline.ts b/src/main/browser/browser-cookie-import-pipeline.ts new file mode 100644 index 00000000000..c1d5deb769b --- /dev/null +++ b/src/main/browser/browser-cookie-import-pipeline.ts @@ -0,0 +1,300 @@ +import { dialog, session, type BrowserWindow } from 'electron' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + type CookieImportMode, + type ReplacedImportedDomainCookies +} from './browser-cookie-import-policy' +import { + acquireCookieMutationLock, + type CookieClearStore, + type CookieImportWriteStore +} from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { + emptyImportWritePhase, + planImportWrites, + writeImportedCookies, + type ImportWritePhase +} from './browser-cookie-import-write' +import { readFile } from 'node:fs/promises' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import { + validateCookieEntry, + type RawCookieEntry, + type ValidatedCookie +} from './browser-cookie-validation' + +// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so +// the partition-dropping write is not merely unused here — it cannot be reached. +export type CookieImportSessionStore = CookieClearStore & + CookieImportWriteStore & { dispose: () => void } + +export type CookieImportTarget = { + partition: string + // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the + // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME + // instance for one partition string, so carrying that instance here is what keeps this path's + // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. + mutationLockOwner: object + openWriteStore: () => CookieImportSessionStore +} + +export type CookieImportOptions = { + canReportPartitionSkippedCookies?: boolean +} + +export function cookieImportTarget(targetPartition: string): CookieImportTarget { + const targetSession = session.fromPartition(targetPartition) + return { + partition: targetPartition, + mutationLockOwner: targetSession, + openWriteStore: () => openCookieClearStore(targetSession) + } +} + +export async function importValidatedCookies( + cookies: ValidatedCookie[], + totalInput: number, + target: CookieImportTarget, + mode: CookieImportMode, + options: CookieImportOptions = {} +): Promise { + const targetPartition = target.partition + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const sourceBoundFiltered = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + // Why: dropping these before the replace scope is computed is what keeps the existing + // Google session intact — replaceCookiesForImportedDomains only clears domains we import. + const importableCookies = sourceBoundFiltered.filter( + (cookie) => !isNonTransplantableCookieDomain(cookie.domain) + ) + const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length + const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const invalidDomainSkipped = cookies.length - validDomainCookies.length + diag( + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` + ) + // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is + // the single value the write set AND the removal scope both derive from, so they cannot drift + // apart the way they did in bf6dc6fcba. + const plan = planImportWrites(importableCookies) + + // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and + // clearing a family we cannot protect is the P0. Refuse before touching anything. + if (plan.hasUnrepresentableSkip) { + return { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + + // Why: an older remote client cannot surface this skip, so fail before opening the target jar. + if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a + // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or + // totalCookies === importedCookies + skippedCookies silently stops holding. + const partitionSkipped = plan.skips.length + let skipped = totalInput - importableCookies.length + partitionSkipped + let phase: ImportWritePhase = emptyImportWritePhase() + // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only + // they carry partitionKey. cookies.set drops it silently, on the success path as well. + const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null + + if (cookieClearStore) { + // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. + // Releasing after the replace lets a second import interleave, so this run's rollback could + // remove cookies the newer import already wrote and reported as imported. Taken AFTER the + // store is opened on purpose — openWriteStore only builds the adapter, it attaches no + // debugger, so holding it while queued cannot deadlock against the holder. + const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) + let replaced: ReplacedImportedDomainCookies | null = null + try { + if (mode === 'replace-imported-domains') { + try { + // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact + // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain + // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's + // live session into the removal scope with nothing written back. plan.writes is already + // family-closed, and using the same array for both makes them impossible to diverge. + const replacementDomains = plan.writes.map((cookie) => cookie.domain) + replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) + diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } + + // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + phase = await writeImportedCookies( + cookieClearStore, + plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), + { stopOnFailure: replaced !== null, log: diag } + ) + // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable + // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned + // writes reach the writer, so the count comes from the plan and is added exactly once. + skipped += phase.writeRejected + + if (phase.failure && replaced) { + const rollbackFailures: unknown[] = [] + for (const cookie of phase.attemptedKeys.toReversed()) { + try { + await cookieClearStore.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty + // restore set would spin up a hidden BrowserWindow to put nothing back. + if (replaced.identities.length > 0) { + try { + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + } catch (err) { + rollbackFailures.push(err) + } + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') + } + } + } finally { + try { + cookieClearStore.dispose() + } finally { + releaseMutationLock() + } + } + } + + diag( + `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` + ) + + const summary: BrowserCookieImportSummary = { + totalCookies: totalInput, + importedCookies: phase.importedCount, + skippedCookies: skipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), + domains: [...phase.domains].sort() + } + + return { ok: true, profileId: '', summary } +} + +// --------------------------------------------------------------------------- +// Import from JSON file +// --------------------------------------------------------------------------- + +// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. +export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { + const opts = { + title: 'Import Cookies', + filters: [ + { name: 'Cookie Files', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile' as const] + } + const result = parentWindow + ? await dialog.showOpenDialog(parentWindow, opts) + : await dialog.showOpenDialog(opts) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] +} + +export async function importCookiesFromFile( + filePath: string, + targetPartition: string +): Promise { + let rawContent: string + try { + rawContent = await readFile(filePath, 'utf-8') + } catch { + return { ok: false, reason: 'Could not read the selected file.' } + } + + let parsed: unknown + try { + parsed = JSON.parse(rawContent) + } catch { + return { ok: false, reason: 'File is not valid JSON.' } + } + + if (!Array.isArray(parsed)) { + return { ok: false, reason: 'Expected a JSON array of cookie objects.' } + } + + if (parsed.length === 0) { + return { ok: false, reason: 'Cookie file is empty.' } + } + + const validated: ValidatedCookie[] = [] + let skipped = 0 + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + skipped++ + continue + } + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (cookie) { + validated.push(cookie) + } else { + skipped++ + } + } + + if (validated.length === 0) { + return { + ok: false, + reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` + } + } + + return importValidatedCookies( + validated, + parsed.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) +} diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 752940c9c83..10929761731 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,968 +1,48 @@ -/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' -import { execFileSync } from 'node:child_process' -import { runProcessSync } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' -import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' +import type { BrowserWindow } from 'electron' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' import { - appendFileSync, - copyFileSync, - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - unlinkSync -} from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DatabaseSync } from 'node:sqlite' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. -let _diagLog: string | null = null -function getDiagLogPath(): string { - if (!_diagLog) { - try { - _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') - } catch { - _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') - } - } - return _diagLog -} -function reasonWithDiagLog(reason: string): string { - return `${reason} Details were written to ${getDiagLogPath()}.` -} -const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 -const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 - -// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. -export function summarizeCookieImportError(err: unknown): string { - const raw = err instanceof Error && err.message ? err.message : String(err) - let summary = '' - let previousWasWhitespace = false - const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) - for (let index = 0; index < scanLimit; index += 1) { - const code = raw.charCodeAt(index) - if (code === 32 || (code >= 9 && code <= 13)) { - if (summary.length > 0 && !previousWasWhitespace) { - summary += ' ' - } - previousWasWhitespace = true - continue - } - summary += raw.charAt(index) - if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { - return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) - } - previousWasWhitespace = false - } - return summary -} -function diag(msg: string): void { - const line = `[${new Date().toISOString()}] ${msg}\n` - try { - appendFileSync(getDiagLogPath(), line) - } catch { - /* best-effort */ - } - console.log('[cookie-import]', msg) -} -import type { - BrowserCookieImportResult, - BrowserCookieImportSummary, - BrowserSessionProfileSource -} from '../../shared/browser-workspace-types' -import { browserSessionRegistry } from './browser-session-registry' -import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' + detectInstalledBrowsers as detectBrowsers, + selectBrowserProfile as selectProfile +} from './browser-cookie-detection' +import type { BrowserProfile, DetectedBrowser } from './browser-cookie-detection-types' import { - isGoogleSourceBoundCookie, - isNonTransplantableCookieDomain, - normalizeCookieDomain, - normalizeCookieImportDomain, - importedDomainScope, - replaceCookiesForImportedDomains, - type CookieImportMode, - type ReplacedImportedDomainCookies -} from './browser-cookie-import-policy' + pickCookieFile as pickFile, + importCookiesFromFile as importFile, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { importChromiumCookies } from './browser-cookie-chromium-import' +import { importCookiesFromFirefox } from './browser-cookie-firefox-import' +import { importCookiesFromSafari } from './browser-cookie-safari-import' import { - acquireCookieMutationLock, - removeTransplantableCookies, - withCookieMutationLock, - type CookieClearStore, - type CookieImportWriteStore -} from './browser-cookie-import-clear' -import { openCookieClearStore } from './browser-cookie-clear-store' -import { - readChromiumRowPartition, - readFirefoxRowPartition, - readJsonCookiePartition, - type SourcePartitionRead -} from './browser-cookie-source-partition' -import { - emptyImportWritePhase, - writeImportedCookies, - type ImportedCookieFields, - type ImportWritePhase, - type SourceCookieToWrite, - planImportWrites -} from './browser-cookie-import-write' -import { - createChromiumCookieSnapshot, - type ChromiumCookieSnapshot -} from './chromium-cookie-snapshot' -import { resolveChromiumCookiesPath } from './chromium-cookie-path' -import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' -import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' + buildChromiumCookieInsertParams as buildInsertParams, + type ChromiumCookieColumnInfo +} from './browser-cookie-sqlite' +import { isAppBoundEncryptedCookie as isAppBoundCookie } from './browser-cookie-decryption' +import { summarizeCookieImportError as summarizeError } from './browser-cookie-import-diagnostics' -// --------------------------------------------------------------------------- -// Browser detection -// --------------------------------------------------------------------------- - -export type BrowserProfile = { - name: string - directory: string -} - -export type DetectedBrowser = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - cookiesPath: string - keychainService?: string - keychainAccount?: string - profiles: BrowserProfile[] - selectedProfile: string -} - -type ChromiumBrowserDef = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - keychainService: string - keychainAccount: string - // Per-platform data-dir roots, resolved at detection time via browserRootPath(). - macRoot?: string - winRoot?: string - linuxRoot?: string -} - -const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ - { - family: 'chrome', - label: 'Google Chrome', - keychainService: 'Chrome Safe Storage', - keychainAccount: 'Chrome', - macRoot: 'Google/Chrome', - winRoot: 'Google/Chrome/User Data', - linuxRoot: 'google-chrome' - }, - { - family: 'edge', - label: 'Microsoft Edge', - keychainService: 'Microsoft Edge Safe Storage', - keychainAccount: 'Microsoft Edge', - macRoot: 'Microsoft Edge', - winRoot: 'Microsoft/Edge/User Data', - linuxRoot: 'microsoft-edge' - }, - { - family: 'arc', - label: 'Arc', - keychainService: 'Arc Safe Storage', - keychainAccount: 'Arc', - macRoot: 'Arc/User Data' - }, - { - family: 'chromium', - label: 'Brave', - keychainService: 'Brave Safe Storage', - keychainAccount: 'Brave', - macRoot: 'BraveSoftware/Brave-Browser', - winRoot: 'BraveSoftware/Brave-Browser/User Data', - linuxRoot: 'BraveSoftware/Brave-Browser' - }, - { - family: 'comet', - label: 'Comet', - keychainService: 'Comet Safe Storage', - keychainAccount: 'Comet', - macRoot: 'Comet', - winRoot: 'Comet/User Data' - // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 - }, - { - family: 'helium', - // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. - label: 'Helium', - keychainService: 'Helium Storage Key', - keychainAccount: 'Helium', - macRoot: 'net.imput.helium' - // winRoot/linuxRoot intentionally omitted — only the macOS install is verified - } -] - -function browserRootPath(def: ChromiumBrowserDef): string | null { - if (process.platform === 'darwin') { - if (!def.macRoot) { - return null - } - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', def.macRoot) - } - if (process.platform === 'win32') { - if (!def.winRoot) { - return null - } - const localAppData = process.env.LOCALAPPDATA ?? '' - if (!localAppData) { - return null - } - return join(localAppData, def.winRoot) - } - // Linux - if (!def.linuxRoot) { - return null - } - const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') - return join(configHome, def.linuxRoot) -} - -function isSafeBrowserProfileDirectory(directory: string): boolean { - return ( - directory.length > 0 && - directory !== '.' && - !directory.includes('\0') && - !directory.includes('/') && - !directory.includes('\\') && - !directory.includes('..') - ) -} - -// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. -function discoverProfiles(browserRoot: string): BrowserProfile[] { - try { - const localStatePath = join(browserRoot, 'Local State') - if (!existsSync(localStatePath)) { - return [{ name: 'Default', directory: 'Default' }] - } - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const infoCache = localState?.profile?.info_cache - if (!infoCache || typeof infoCache !== 'object') { - return [{ name: 'Default', directory: 'Default' }] - } - const profiles: BrowserProfile[] = [] - for (const [dir, info] of Object.entries(infoCache)) { - // Why: Local State is external metadata, but profile dirs become path segments. - if (!isSafeBrowserProfileDirectory(dir)) { - continue - } - const profileName = (info as { name?: string })?.name ?? dir - profiles.push({ name: profileName, directory: dir }) - } - return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] - } catch { - return [{ name: 'Default', directory: 'Default' }] - } -} - -// --------------------------------------------------------------------------- -// Firefox detection -// --------------------------------------------------------------------------- - -function firefoxProfilesRoot(): string | null { - if (process.platform === 'darwin') { - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') - } - if (process.platform === 'win32') { - const appData = process.env.APPDATA ?? '' - return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null - } - const home = process.env.HOME ?? '' - return join(home, '.mozilla', 'firefox') -} - -function discoverFirefoxProfiles(): BrowserProfile[] { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return [] - } - try { - if (!existsSync(profilesRoot)) { - return [] - } - const entries = readdirSync(profilesRoot, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) - // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. - const sorted = entries.sort((a, b) => { - if (a.includes('default-release')) { - return -1 - } - if (b.includes('default-release')) { - return 1 - } - if (a.includes('default')) { - return -1 - } - if (b.includes('default')) { - return 1 - } - return 0 - }) - return sorted.map((dir) => { - const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir - return { name: label, directory: dir } - }) - } catch { - return [] - } -} - -function detectFirefox(): DetectedBrowser | null { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const profiles = discoverFirefoxProfiles() - for (const profile of profiles) { - const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') - if (existsSync(cookiesPath)) { - return { - family: 'firefox', - label: 'Firefox', - cookiesPath, - profiles, - selectedProfile: profile.directory - } - } - } - return null -} - -// --------------------------------------------------------------------------- -// Safari detection -// --------------------------------------------------------------------------- - -const MAC_EPOCH_DELTA = 978_307_200 - -function detectSafari(): DetectedBrowser | null { - if (process.platform !== 'darwin') { - return null - } - const home = process.env.HOME ?? '' - const candidates = [ - join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), - join( - home, - 'Library', - 'Containers', - 'com.apple.Safari', - 'Data', - 'Library', - 'Cookies', - 'Cookies.binarycookies' - ) - ] - for (const candidate of candidates) { - if (existsSync(candidate)) { - return { - family: 'safari', - label: 'Safari', - cookiesPath: candidate, - profiles: [{ name: 'Default', directory: 'Default' }], - selectedProfile: 'Default' - } - } - } - return null -} +export type { BrowserProfile, DetectedBrowser, CookieImportOptions, ChromiumCookieColumnInfo } +export { summarizeError as summarizeCookieImportError } export function detectInstalledBrowsers(): DetectedBrowser[] { - const detected: DetectedBrowser[] = [] - for (const browser of CHROMIUM_BROWSERS) { - const root = browserRootPath(browser) - if (!root) { - continue - } - const profiles = discoverProfiles(root) - // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. - for (const profile of profiles) { - const profileDir = join(root, profile.directory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (cookiesPath) { - detected.push({ - family: browser.family, - label: browser.label, - keychainService: browser.keychainService, - keychainAccount: browser.keychainAccount, - cookiesPath, - profiles, - selectedProfile: profile.directory - }) - break - } - } - } - - const firefox = detectFirefox() - if (firefox) { - detected.push(firefox) - } - - const safari = detectSafari() - if (safari) { - detected.push(safari) - } - - return detected + return detectBrowsers() } export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string ): DetectedBrowser | null { - if (!isSafeBrowserProfileDirectory(profileDirectory)) { - return null - } - if (browser.family === 'firefox') { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') - if (!existsSync(cookiesPath)) { - return null - } - return { ...browser, cookiesPath, selectedProfile: profileDirectory } - } - - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - const profileDir = join(root, profileDirectory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (!cookiesPath) { - return null - } - return { - ...browser, - cookiesPath, - selectedProfile: profileDirectory - } + return selectProfile(browser, profileDirectory) } -// --------------------------------------------------------------------------- -// Cookie validation (shared between file import and direct import) -// --------------------------------------------------------------------------- - -type RawCookieEntry = { - domain?: unknown - name?: unknown - value?: unknown - path?: unknown - secure?: unknown - httpOnly?: unknown - sameSite?: unknown - expirationDate?: unknown - partitionKey?: unknown - partitionKeyOpaque?: unknown -} - -// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to -// state what it read. An optional field would let a new source silently default to unpartitioned. -type ValidatedCookie = ImportedCookieFields & { - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead -} - -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 0: - return 'no_restriction' - case 1: - return 'lax' - case 2: - return 'strict' - default: - return 'unspecified' - } -} - -function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - if (typeof raw === 'number') { - return chromiumSameSite(raw) - } - if (typeof raw !== 'string') { - return 'unspecified' - } - const lower = raw.toLowerCase() - if (lower === 'lax') { - return 'lax' - } - if (lower === 'strict') { - return 'strict' - } - if (lower === 'none' || lower === 'no_restriction') { - return 'no_restriction' - } - return 'unspecified' -} - -// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. -function deriveUrl(domain: string, secure: boolean): string | null { - const normalizedDomain = normalizeCookieDomain(domain) - if (!normalizedDomain) { - return null - } - const protocol = secure ? 'https' : 'http' - try { - const url = new URL(`${protocol}://${normalizedDomain}/`) - return url.toString() - } catch { - return null - } -} - -function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { - if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { - return null - } - if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { - return null - } - if (typeof raw.value !== 'string') { - return null - } - - const domain = raw.domain.trim() - const secure = raw.secure === true || raw.secure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = - typeof raw.expirationDate === 'number' && raw.expirationDate > 0 - ? raw.expirationDate - : undefined - - return { - url, - name: raw.name.trim(), - value: raw.value, - domain, - path: typeof raw.path === 'string' ? raw.path : '/', - secure, - httpOnly: raw.httpOnly === true || raw.httpOnly === 1, - sameSite: normalizeSameSite(raw.sameSite), - expirationDate, - partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) - } -} - -// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so -// the partition-dropping write is not merely unused here — it cannot be reached. -type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { dispose: () => void } - -type CookieImportTarget = { - partition: string - // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the - // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME - // instance for one partition string, so carrying that instance here is what keeps this path's - // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. - mutationLockOwner: object - openWriteStore: () => CookieImportSessionStore -} - -type CookieImportOptions = { - canReportPartitionSkippedCookies?: boolean -} - -function cookieImportTarget(targetPartition: string): CookieImportTarget { - const targetSession = session.fromPartition(targetPartition) - return { - partition: targetPartition, - mutationLockOwner: targetSession, - openWriteStore: () => openCookieClearStore(targetSession) - } -} - -async function importValidatedCookies( - cookies: ValidatedCookie[], - totalInput: number, - target: CookieImportTarget, - mode: CookieImportMode, - options: CookieImportOptions = {} -): Promise { - const targetPartition = target.partition - const importDomainCache = new Map() - const validDomainCookies = cookies.filter((cookie) => { - let valid = importDomainCache.get(cookie.domain) - if (valid === undefined) { - valid = normalizeCookieImportDomain(cookie.domain) !== null - importDomainCache.set(cookie.domain, valid) - } - return valid - }) - const sourceBoundFiltered = validDomainCookies.filter( - (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) - ) - // Why: dropping these before the replace scope is computed is what keeps the existing - // Google session intact — replaceCookiesForImportedDomains only clears domains we import. - const importableCookies = sourceBoundFiltered.filter( - (cookie) => !isNonTransplantableCookieDomain(cookie.domain) - ) - const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length - const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - const invalidDomainSkipped = cookies.length - validDomainCookies.length - diag( - `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` - ) - // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is - // the single value the write set AND the removal scope both derive from, so they cannot drift - // apart the way they did in bf6dc6fcba. - const plan = planImportWrites(importableCookies) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and - // clearing a family we cannot protect is the P0. Refuse before touching anything. - if (plan.hasUnrepresentableSkip) { - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - // Why: an older remote client cannot surface this skip, so fail before opening the target jar. - if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a - // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or - // totalCookies === importedCookies + skippedCookies silently stops holding. - const partitionSkipped = plan.skips.length - let skipped = totalInput - importableCookies.length + partitionSkipped - let phase: ImportWritePhase = emptyImportWritePhase() - // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only - // they carry partitionKey. cookies.set drops it silently, on the success path as well. - const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null - - if (cookieClearStore) { - // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. - // Releasing after the replace lets a second import interleave, so this run's rollback could - // remove cookies the newer import already wrote and reported as imported. Taken AFTER the - // store is opened on purpose — openWriteStore only builds the adapter, it attaches no - // debugger, so holding it while queued cannot deadlock against the holder. - const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) - let replaced: ReplacedImportedDomainCookies | null = null - try { - if (mode === 'replace-imported-domains') { - try { - // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact - // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain - // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's - // live session into the removal scope with nothing written back. plan.writes is already - // family-closed, and using the same array for both makes them impossible to diverge. - const replacementDomains = plan.writes.map((cookie) => cookie.domain) - replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) - diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) - } catch (err) { - diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) - return { - ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') - } - } - } - - // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. - const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') - phase = await writeImportedCookies( - cookieClearStore, - plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), - { stopOnFailure: replaced !== null, log: diag } - ) - // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable - // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned - // writes reach the writer, so the count comes from the plan and is added exactly once. - skipped += phase.writeRejected - - if (phase.failure && replaced) { - const rollbackFailures: unknown[] = [] - for (const cookie of phase.attemptedKeys.toReversed()) { - try { - await cookieClearStore.remove(cookie.url, cookie.name) - } catch (err) { - rollbackFailures.push(err) - } - } - // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty - // restore set would spin up a hidden BrowserWindow to put nothing back. - if (replaced.identities.length > 0) { - try { - await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) - } catch (err) { - rollbackFailures.push(err) - } - } - if (rollbackFailures.length > 0) { - diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) - } - return { - ok: false, - reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') - } - } - } finally { - try { - cookieClearStore.dispose() - } finally { - releaseMutationLock() - } - } - } - - diag( - `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` - ) - - const summary: BrowserCookieImportSummary = { - totalCookies: totalInput, - importedCookies: phase.importedCount, - skippedCookies: skipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...phase.domains].sort() - } - - return { ok: true, profileId: '', summary } -} - -// --------------------------------------------------------------------------- -// Import from JSON file -// --------------------------------------------------------------------------- - -// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { - const opts = { - title: 'Import Cookies', - filters: [ - { name: 'Cookie Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile' as const] - } - const result = parentWindow - ? await dialog.showOpenDialog(parentWindow, opts) - : await dialog.showOpenDialog(opts) - - if (result.canceled || result.filePaths.length === 0) { - return null - } - return result.filePaths[0] + return pickFile(parentWindow) } export async function importCookiesFromFile( filePath: string, targetPartition: string ): Promise { - let rawContent: string - try { - rawContent = await readFile(filePath, 'utf-8') - } catch { - return { ok: false, reason: 'Could not read the selected file.' } - } - - let parsed: unknown - try { - parsed = JSON.parse(rawContent) - } catch { - return { ok: false, reason: 'File is not valid JSON.' } - } - - if (!Array.isArray(parsed)) { - return { ok: false, reason: 'Expected a JSON array of cookie objects.' } - } - - if (parsed.length === 0) { - return { ok: false, reason: 'Cookie file is empty.' } - } - - const validated: ValidatedCookie[] = [] - let skipped = 0 - for (const entry of parsed) { - if (typeof entry !== 'object' || entry === null) { - skipped++ - continue - } - const cookie = validateCookieEntry(entry as RawCookieEntry) - if (cookie) { - validated.push(cookie) - } else { - skipped++ - } - } - - if (validated.length === 0) { - return { - ok: false, - reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` - } - } - - return importValidatedCookies( - validated, - parsed.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) -} - -const PBKDF2_ITERATIONS = 1003 -const PBKDF2_KEY_LENGTH = 16 -const PBKDF2_SALT = 'saltysalt' - -const CHROMIUM_EPOCH_OFFSET = 11644473600n - -function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { - if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { - return 0 - } - try { - const ts = - typeof chromiumTs === 'bigint' - ? chromiumTs - : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) - if (ts === 0n) { - return 0 - } - return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) - } catch { - return 0 - } -} - -// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. - -type EncryptionKeyResult = - | { - mode: 'aes-128-cbc' - keysByVersion: Partial> - keyringUnavailable?: boolean - } - | { mode: 'aes-256-gcm'; key: Buffer } - -export type ChromiumCookieColumnInfo = { - name: string - type?: string - notnull?: number | bigint - dflt_value?: unknown -} - -function parseSqliteDefaultValue(raw: unknown, type: string): string | number | Buffer | null { - if (raw === null || raw === undefined) { - return null - } - if (typeof raw !== 'string') { - return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) - } - - const trimmed = raw.trim() - if (!trimmed || trimmed.toUpperCase() === 'NULL') { - return null - } - if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { - return Buffer.alloc(0) - } - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - return trimmed.slice(1, -1).replaceAll("''", "'") - } - if (type.includes('INT')) { - const numeric = Number(trimmed) - return Number.isFinite(numeric) ? numeric : 0 - } - return trimmed -} - -function normalizeSqliteCookieValue(value: unknown): string | number | bigint | Buffer | null { - if (value instanceof Uint8Array) { - return Buffer.from(value) - } - if (value === undefined || value === null) { - return null - } - if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { - return value - } - return String(value) -} - -function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { - return Number(column.notnull ?? 0) !== 0 -} - -function fallbackChromiumCookieColumnValue( - column: ChromiumCookieColumnInfo, - sourceRow: Record -): string | number | bigint | Buffer | null { - const type = (column.type ?? '').toUpperCase() - const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) - if (defaultValue !== null) { - return defaultValue - } - if (!isSqliteNotNull(column)) { - return null - } - - switch (column.name) { - case 'value': - case 'encrypted_value': - return Buffer.alloc(0) - case 'top_frame_site_key': - return '' - case 'source_port': - return -1 - case 'last_update_utc': - return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 - default: - if (type.includes('BLOB')) { - return Buffer.alloc(0) - } - if (type.includes('INT')) { - return 0 - } - return '' - } + return importFile(filePath, targetPartition) } export function buildChromiumCookieInsertParams( @@ -970,1247 +50,23 @@ export function buildChromiumCookieInsertParams( sourceRow: Record, decryptedValue: Buffer ): (string | number | bigint | Buffer | null)[] { - return targetColumns.map((column) => { - if (column.name === 'encrypted_value') { - return Buffer.alloc(0) - } - if (column.name === 'value') { - return decryptedValue - } - - const sourceHasColumn = Object.hasOwn(sourceRow, column.name) - const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null - if (sourceValue !== null) { - return sourceValue - } - if (sourceHasColumn && !isSqliteNotNull(column)) { - return null - } - - // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. - return fallbackChromiumCookieColumnValue(column, sourceRow) - }) + return buildInsertParams(targetColumns, sourceRow, decryptedValue) } -function getEncryptionKey( - keychainService: string, - keychainAccount: string, - browser?: DetectedBrowser -): EncryptionKeyResult | null { - if (process.platform === 'darwin') { - return getMacEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'linux') { - return getLinuxEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'win32' && browser) { - return getWindowsEncryptionKey(browser) - } - return null -} - -function getMacEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - try { - const raw = execFileSync( - 'security', - ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], - { encoding: 'utf-8', timeout: 30_000 } - ).trim() - return { - mode: 'aes-128-cbc', - keysByVersion: { - v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') - } - } - } catch { - return null - } -} - -function getLinuxEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded - // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. - const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - - let keyringPassword = '' - try { - // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. - keyringPassword = execFileSync( - 'secret-tool', - ['lookup', 'service', keychainService, 'account', keychainAccount], - { encoding: 'utf-8', timeout: 5_000 } - ).trim() - } catch { - // Why: fall back to application-based lookup used by newer Chromium versions. - try { - const app = keychainAccount.toLowerCase().replaceAll(' ', '') - keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { - encoding: 'utf-8', - timeout: 5_000 - }).trim() - } catch { - diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') - } - } - - if (!keyringPassword) { - return { - mode: 'aes-128-cbc', - keysByVersion: { v10: v10Key }, - keyringUnavailable: true - } - } - - const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } -} - -function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - - const localStatePath = join(root, 'Local State') - if (!existsSync(localStatePath)) { - return null - } - - try { - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const encryptedKeyB64 = localState?.os_crypt?.encrypted_key - if (typeof encryptedKeyB64 !== 'string') { - return null - } - - const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') - const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') - if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { - return null - } - - // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. - const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') - const script = [ - 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', - 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', - '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', - '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', - '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', - '[Convert]::ToBase64String($out)' - ].join('') - - // Why runProcessSync and an absolute path: a bare `powershell` spawn from a - // GUI-subsystem process opens a visible conhost that takes foreground, so - // keystrokes typed into an Orca terminal during a cookie import land in the - // black box (#14543), and PATH under Electron is not the user's (#11771). - const result = runProcessSync({ - program: windowsPowerShellPath(), - args: ['-NoProfile', '-NonInteractive', '-Command', script], - timeoutMs: 10_000, - input: dpapiData - }) - if (result.code !== 0 || result.timedOut) { - diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') - return null - } - - return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } - } catch (err) { - diag(` Windows DPAPI key extraction failed: ${String(err)}`) - return null - } -} - -// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. -const CHROMIUM_COOKIE_HMAC_LEN = 32 - -function hasHmacPrefix(buf: Buffer): boolean { - if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { - return false - } - let nonPrintable = 0 - for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { - if (buf[i] < 0x20 || buf[i] > 0x7e) { - nonPrintable++ - } - } - return nonPrintable >= 8 -} - -function stripHmac(buf: Buffer): Buffer { - return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf -} - -// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and -// share it between the decrypt path and the failure attribution. -function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { - if (encryptedBuffer.length < 3) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - return /^v\d\d$/.test(version) ? version : null -} - -// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which -// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { - return cookieEncryptionVersion(encryptedBuffer) === 'v20' + return isAppBoundCookie(encryptedBuffer) } -// Why: a named cause must carry only its exact count; tied causes fall back to unknown. -function buildUndecryptableWarning(counts: { - decryptFailed: number - appBoundFailed: number - keyringUnavailableFailed: number -}): BrowserCookieImportSummary['warning'] { - if (counts.decryptFailed === 0) { - return undefined - } - const unknownFailed = - counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed - const rankedCauses = [ - { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, - { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, - { reason: 'unknown' as const, count: unknownFailed } - ].sort((left, right) => right.count - left.count) - const [dominant, runnerUp] = rankedCauses - - if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { - return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } - } - - const otherFailedCookies = counts.decryptFailed - dominant.count - return { - code: 'cookies-undecryptable', - failedCookies: dominant.count, - reason: dominant.reason, - ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) - } -} - -function decryptCookieValueRaw( - encryptedBuffer: Buffer, - keyResult: EncryptionKeyResult -): Buffer | null { - if (!encryptedBuffer || encryptedBuffer.length === 0) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - if (!/^v\d\d$/.test(version)) { - return null - } - - if (keyResult.mode === 'aes-256-gcm') { - return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) - } - - // AES-128-CBC (macOS and Linux) - const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined - if (!key) { - return null - } - - const ciphertext = encryptedBuffer.subarray(3) - if (!ciphertext.length) { - return null - } - - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { - // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] - if (payload.length < 12 + 16) { - return null - } - const nonce = payload.subarray(0, 12) - const authTag = payload.subarray(-16) - const ciphertext = payload.subarray(12, -16) - try { - const decipher = createDecipheriv('aes-256-gcm', key, nonce) - decipher.setAuthTag(authTag) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -// --------------------------------------------------------------------------- -// Safari binary cookie parser -// --------------------------------------------------------------------------- - -function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { - if (buffer.length < 8) { - return [] - } - if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { - return [] - } - - const pageCount = buffer.readUInt32BE(4) - let cursor = 8 - if (cursor + pageCount * 4 > buffer.length) { - return [] - } - const pageSizes: number[] = [] - for (let i = 0; i < pageCount; i++) { - pageSizes.push(buffer.readUInt32BE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const pageSize of pageSizes) { - const page = buffer.subarray(cursor, cursor + pageSize) - cursor += pageSize - appendSafariCookies(cookies, decodeSafariPage(page)) - } - return cookies -} - -function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { - // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. - for (const cookie of cookies) { - target.push(cookie) - } -} - -function decodeSafariPage(page: Buffer): ValidatedCookie[] { - if (page.length < 16) { - return [] - } - if (page.readUInt32BE(0) !== 0x00000100) { - return [] - } - - const cookieCount = page.readUInt32LE(4) - if (8 + cookieCount * 4 > page.length) { - return [] - } - const offsets: number[] = [] - let cursor = 8 - for (let i = 0; i < cookieCount; i++) { - offsets.push(page.readUInt32LE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const offset of offsets) { - const cookie = decodeSafariCookie(page.subarray(offset)) - if (cookie) { - cookies.push(cookie) - } - } - return cookies -} - -function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { - if (buf.length < 48) { - return null - } - // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. - const size = Math.min(buf.readUInt32LE(0), buf.length) - if (size < 48) { - return null - } - - const flags = buf.readUInt32LE(8) - const secure = (flags & 1) !== 0 - const httpOnly = (flags & 4) !== 0 - - const urlOffset = buf.readUInt32LE(16) - const nameOffset = buf.readUInt32LE(20) - const pathOffset = buf.readUInt32LE(24) - const valueOffset = buf.readUInt32LE(28) - - // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). - const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 - - const name = readCString(buf, nameOffset, size) - if (!name) { - return null - } - const value = readCString(buf, valueOffset, size) ?? '' - const path = readCString(buf, pathOffset, size) ?? '/' - const rawUrl = readCString(buf, urlOffset, size) ?? '' - - // Why: Safari stores the domain in the URL field, not as a separate domain column. - const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null - if (!domain) { - return null - } - - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined - - return { - url, - name, - value, - domain, - path, - secure, - httpOnly, - sameSite: 'unspecified', - expirationDate, - // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every - // decoded cookie is genuinely unpartitioned rather than missing an identity. - partition: { status: 'unpartitioned' } - } -} - -function readCString(buf: Buffer, offset: number, end: number): string | null { - if (offset < 0 || offset >= end) { - return null - } - let cursor = offset - while (cursor < end && buf[cursor] !== 0) { - cursor++ - } - if (cursor >= end) { - return null - } - return buf.toString('utf8', offset, cursor) -} - -// --------------------------------------------------------------------------- -// Firefox import -// --------------------------------------------------------------------------- - -async function importCookiesFromFirefox( - browser: DetectedBrowser, - targetPartition: string, - options: CookieImportOptions -): Promise { - diag(`importCookiesFromFirefox: partition="${targetPartition}"`) - - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) - const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') - - try { - copyFileSync(browser.cookiesPath, tmpCookiesPath) - for (const suffix of ['-wal', '-shm'] as const) { - const sidecar = browser.cookiesPath + suffix - if (existsSync(sidecar)) { - try { - copyFileSync(sidecar, tmpCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - } catch { - rmSync(tmpDir, { recursive: true, force: true }) - return { - ok: false, - reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' - } - } - - try { - const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) - type FirefoxRow = Record & { - name: string - value: string - host: string - path: string - expiry: number - isSecure: number - isHttpOnly: number - sameSite: number - isPartitionedAttributeSet?: number - } - // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema - // without the server-declared partition flag predates that cookie identity. - const firefoxColumns = new Set( - (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( - (column) => column.name - ) - ) - const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') - ? ', isPartitionedAttributeSet' - : '' - const rows = db - .prepare( - `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` - ) - .all() as FirefoxRow[] - db.close() - - diag(` Firefox source has ${rows.length} cookies`) - if (rows.length === 0) { - rmSync(tmpDir, { recursive: true, force: true }) - return { ok: false, reason: 'No cookies found in Firefox.' } - } - - const now = Math.floor(Date.now() / 1000) - const validated: ValidatedCookie[] = [] - for (const row of rows) { - if (!row.name || !row.host) { - continue - } - if (row.expiry > 0 && row.expiry < now) { - continue - } - - const domain = row.host - const secure = row.isSecure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - continue - } - - validated.push({ - url, - name: row.name, - value: row.value ?? '', - domain, - path: row.path || '/', - secure, - httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), - expirationDate: row.expiry > 0 ? row.expiry : undefined, - partition: readFirefoxRowPartition(row, firefoxColumns) - }) - } - - rmSync(tmpDir, { recursive: true, force: true }) - - if (validated.length === 0) { - return { ok: false, reason: 'No valid cookies found in Firefox.' } - } - - return importValidatedCookies( - validated, - rows.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains', - options - ) - } catch (err) { - rmSync(tmpDir, { recursive: true, force: true }) - diag(` Firefox import failed: ${String(err)}`) - return { - ok: false, - reason: 'Could not import cookies from Firefox. Try closing Firefox first.' - } - } -} - -// --------------------------------------------------------------------------- -// Safari import -// --------------------------------------------------------------------------- - -async function importCookiesFromSafari( - browser: DetectedBrowser, - targetPartition: string -): Promise { - diag(`importCookiesFromSafari: partition="${targetPartition}"`) - - let data: Buffer - try { - data = readFileSync(browser.cookiesPath) - } catch (err) { - diag(` Safari read failed: ${String(err)}`) - // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. - const isPermError = - err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' - if (isPermError) { - return { - ok: false, - reason: - 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' - } - } - return { ok: false, reason: 'Could not read Safari cookies.' } - } - - try { - const cookies = decodeSafariBinaryCookies(data) - diag(` Safari source has ${cookies.length} cookies`) - - if (cookies.length === 0) { - return { ok: false, reason: 'No cookies found in Safari.' } - } - - const now = Math.floor(Date.now() / 1000) - const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) - - if (valid.length === 0) { - return { ok: false, reason: 'All Safari cookies are expired.' } - } - - return importValidatedCookies( - valid, - cookies.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) - } catch (err) { - diag(` Safari import failed: ${String(err)}`) - return { ok: false, reason: 'Could not import cookies from Safari.' } - } -} - -// --------------------------------------------------------------------------- -// Import dispatcher -// --------------------------------------------------------------------------- - export async function importCookiesFromBrowser( browser: DetectedBrowser, targetPartition: string, options: CookieImportOptions = {} ): Promise { - diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) - if (!existsSync(browser.cookiesPath)) { - diag(` cookies DB not found: ${browser.cookiesPath}`) - return { ok: false, reason: `${browser.label} cookies database not found.` } - } - if (browser.family === 'firefox') { return importCookiesFromFirefox(browser, targetPartition, options) } if (browser.family === 'safari') { return importCookiesFromSafari(browser, targetPartition) } - - // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. - - // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose - // imported-domain rows can be merged into the live DB on the next cold start. - const targetSession = session.fromPartition(targetPartition) - // Why (STA-4601): native imports mutate the live jar and their staged image before the old - // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, - // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer - // import on the same partition. - return withCookieMutationLock(targetSession, async () => { - await targetSession.cookies.flushStore() - - // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from - // the caller's partition string. String surgery on a caller-supplied name is what let a value like - // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement - // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. - const partitionDir = targetSession.getStoragePath() - if (!partitionDir) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - const partitionName = targetPartition.replace('persist:', '') - let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - - // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. - // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as - // the import itself. An earlier revision left it outside on the argument that no import writes - // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, - // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write - // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had - // just written and reported as imported. The cost is negligible: the probe only runs for a - // partition that has never stored a cookie, so it is at most a one-time wait per profile. - if (!liveCookiesPath) { - try { - await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) - await targetSession.cookies.remove('https://localhost', '__init') - await targetSession.cookies.flushStore() - } catch { - // ignore — the set/remove may fail but flushStore should still create the file - } - liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - } - - if (!liveCookiesPath) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - - const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') - const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') - const stagingCookiesPath = join( - stagingDir, - `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` - ) - // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory - // import rejects, so losing it must degrade that fallback rather than abort the import. - let stagingAvailable = false - // Why: a client-hosted route partition is derived at runtime and never reaches the startup - // replay, so staging it would only leave a plaintext cookie DB nothing ever consumes. - if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { - diag( - ` restart fallback unsupported for partition "${targetPartition}" — not staging cookies` - ) - } else { - try { - mkdirSync(stagingDir, { recursive: true }) - copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) - stagingAvailable = true - } catch (err) { - const fsErr = err as NodeJS.ErrnoException - diag( - ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` - ) - // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - } - } - - let sourceSnapshot: ChromiumCookieSnapshot - try { - // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. - sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) - } catch (err) { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - diag(` Chromium snapshot failed: ${String(err)}`) - return { - ok: false, - reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` - } - } - - let sourceDb: InstanceType | null = null - let stagingDb: InstanceType | null = null - const closeStagingDb = (): void => { - try { - stagingDb?.close() - } catch { - /* best-effort */ - } - stagingDb = null - } - const discardStagingFile = (): void => { - // Why: the staged copy holds plaintext cookie values, and SQLite may have left sidecars beside it. - for (const suffix of ['', '-wal', '-shm']) { - try { - unlinkSync(stagingCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - - try { - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { - readOnly: true, - readBigInts: true - }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. - try { - stagingDb = new DatabaseSync(stagingCookiesPath) - // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL - // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image - // import, restoring the unrelated-cookie data loss this format is meant to prevent. - stagingDb.exec('PRAGMA journal_mode = DELETE') - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) - stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null - closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. - discardStagingFile() - } - } - - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name - ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - - diag(` source has ${sourceRows.length} cookies`) - - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { ok: false, reason: `No cookies found in ${browser.label}.` } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - const needsSourceKey = sourceRows.some((sourceRow) => { - const encRaw = sourceRow.encrypted_value - if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { - return false - } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - - let imported = 0 - let skipped = 0 - let decryptFailed = 0 - let appBoundFailed = 0 - let keyringUnavailableFailed = 0 - let integritySkipped = 0 - let nonTransplantableSkipped = 0 - const partitionSkipped = nativePlan.skips.length - let memoryLoaded = 0 - let memoryFailed = 0 - const domainSet = new Set() - - type DecryptedCookie = Omit & { - decryptedValue: Buffer - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead - } - - const decryptedCookies: DecryptedCookie[] = [] - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - const scanned: { entry: DecryptedCookie; sourceRow: Record }[] = [] - const sourceDomainValidity = new Map() - - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ReturnType['prepare']> | null = null - const disableStaging = (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - stagingAvailable = false - insertStmt = null - closeStagingDb() - discardStagingFile() - } - - if (stagingDb && colList && placeholders) { - try { - insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) - stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - disableStaging(String(err)) - } - } else if (stagingAvailable) { - disableStaging('staged database exposed no cookies columns') - } - - // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding - // partial-import restart behavior is separate from narrowing what a staged replay may replace. - if (nativePlan.skippedFamilies.size > 0) { - disableStaging( - `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` - ) - } - - for (const sourceRow of sourceRows) { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - - // Why: transplanting these replaces a working sign-in with a session the site rejects. - if (isNonTransplantableCookieDomain(domain)) { - nonTransplantableSkipped++ - continue - } - - const encRaw = sourceRow.encrypted_value - // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. - const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null - const plainRaw = sourceRow.value - - let decryptedValue: Buffer - if (encBuf && encBuf.length > 0) { - const version = cookieEncryptionVersion(encBuf) - const appBoundIneligible = version === 'v20' - const keyringIneligible = - version === 'v11' && - sourceKey?.mode === 'aes-128-cbc' && - sourceKey.keyringUnavailable === true - const raw = - sourceKey && !appBoundIneligible && !keyringIneligible - ? decryptCookieValueRaw(encBuf, sourceKey) - : null - if (!raw) { - // Why: once decrypt returns null every failure looks identical, so attribute the cause - // here while the version prefix is still in hand. Without this an undecryptable profile - // is indistinguishable from an empty one and reports success. - decryptFailed++ - if (appBoundIneligible) { - appBoundFailed++ - } else if (keyringIneligible) { - keyringUnavailableFailed++ - } - skipped++ - continue - } - decryptedValue = raw - } else if (plainRaw instanceof Uint8Array) { - decryptedValue = Buffer.from(plainRaw) - } else if (typeof plainRaw === 'string') { - decryptedValue = Buffer.from(plainRaw, 'latin1') - } else { - decryptedValue = Buffer.alloc(0) - } - - let validDomain = sourceDomainValidity.get(domain) - if (validDomain === undefined) { - validDomain = normalizeCookieImportDomain(domain) !== null - sourceDomainValidity.set(domain, validDomain) - } - if (!validDomain) { - skipped++ - continue - } - - // Decryption failures are already counted above. Every other row suppressed by the - // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. - if (!plannedSourceRows.has(sourceRow)) { - skipped++ - continue - } - - const path = sourceRow.path as string - const secure = sourceRow.is_secure === 1n - const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) - const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) - const partition = partitionBySourceRow.get(sourceRow)! - // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. - const value = decryptedValue.toString('latin1') - - // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not - // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and - // THEN applied the unreadable guard, so an unreadable row discovered late could not retract - // a sibling already emitted, and the jar-wide clear then removed more than was written back. - scanned.push({ - entry: { - decryptedValue, - value, - domain, - name, - path, - secure, - httpOnly, - sameSite, - expirationDate: expiresUtc > 0 ? expiresUtc : undefined, - partition - }, - sourceRow - }) - } - - for (const { entry } of scanned) { - domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) - } - // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the - // plan — the same rows the writes come from — is what keeps the removal set from drifting past - // the write set, and it is derived here rather than at the clear because the staged image below - // has to be cleared to the identical scope. - const importScope = importedDomainScope([...domainSet]) - - // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. - // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. - if (stagingDb && insertStmt) { - try { - prepareStagedCookiesForImport(stagingDb, importScope) - } catch (err) { - disableStaging(String(err)) - } - } - - // EMIT: everything downstream derives from the plan, so there is no second place a row can - // leak in. - for (const { entry, sourceRow } of scanned) { - decryptedCookies.push(entry) - if (insertStmt && targetColumnInfo) { - try { - const params = buildChromiumCookieInsertParams( - targetColumnInfo, - sourceRow, - entry.decryptedValue - ) - insertStmt.run(...params) - } catch (err) { - disableStaging(String(err)) - } - } - // Why: counts importable cookies, not staged rows — the summary must stay truthful when - // the optional staging DB is unavailable. - imported++ - } - diag( - ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` - ) - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - - const undecryptableWarning = buildUndecryptableWarning({ - decryptFailed, - appBoundFailed, - keyringUnavailableFailed - }) - - // Why: an older remote client ignores the new counter and would present this loss as success. - // Placed before the early return and before any jar mutation, so a client that cannot render - // the skip fails the import outright rather than reporting a partial import as complete. - if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - - if (decryptedCookies.length === 0) { - const zeroPathWarning = undecryptableWarning - closeStagingDb() - discardStagingFile() - return { - ok: true, - profileId: '', - summary: { - totalCookies: sourceRows.length, - importedCookies: 0, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so - // totalCookies === importedCookies + skippedCookies keeps holding on this path too. - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [], - // Why: a profile whose rows cannot be decrypted returns here, and without this it is - // reported as a successful empty import. - ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) - } - } - } - - if (stagingDb) { - try { - stagingDb.exec('COMMIT') - closeStagingDb() - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) - } catch (err) { - disableStaging(String(err)) - } - } else { - diag(` staging skipped: ${imported} cookies will load in-memory only`) - } - - // Why: clear stale cookies for the domains being imported first; mixing them with the imported - // set makes sites reject the session. Non-transplantable families are exempt — nothing was - // imported for them, and their live session is the only one that works. - // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches - // only as far as the domains this import writes; beyond them a clear has nothing to reconcile - // and only signs the user out of sessions the import was never about. - // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak - // the same CDP identities — cookies.set() cannot express the partition either one reads. - const cookieClearStore = openCookieClearStore(targetSession) - try { - // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a - // second import cannot clear between them and write on top of a newer import's jar. - await removeTransplantableCookies( - { - cookies: cookieClearStore, - snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), - restoreClearIdentities: (identities) => - cookieClearStore.restoreClearIdentities(identities) - }, - // Why (STA-4300): the families this import declined to write must not be removed either. - // Passing them here keeps their coordinates out of the removal plan AND out of the CDP - // snapshot taken from it, so they are never submitted to any mutation. - nativePlan.skippedFamilies, - importScope - ) - diag( - ` cleared existing cookies for ${domainSet.size} imported domains before loading ${decryptedCookies.length} imported cookies` - ) - - const writable: SourceCookieToWrite[] = [] - for (const cookie of decryptedCookies) { - const url = deriveUrl(cookie.domain, cookie.secure) - if (!url) { - memoryFailed++ - continue - } - writable.push({ ...cookie, url }) - } - // Why: a rejected cookie here falls back to the staged cold-start replay rather than - // unwinding the import, so one failure must not stop the rest from loading. - const phase = await writeImportedCookies(cookieClearStore, writable, { - stopOnFailure: false, - log: diag - }) - memoryLoaded = phase.importedCount - memoryFailed += phase.writeRejected - } finally { - cookieClearStore.dispose() - } - - diag( - ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` - ) - - let warning: BrowserCookieImportSummary['warning'] - if (memoryFailed > 0 && stagingAvailable) { - // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. - browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) - diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else if (memoryFailed > 0) { - // Why: never register a path that was never written or can never be replayed — cold start - // would replay a missing or partial DB over the live partition. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) - // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. - warning = { - code: 'restart-fallback-unavailable', - loadedCookies: memoryLoaded, - failedCookies: memoryFailed - } - } else { - // Why: this import already rewrote the live session, so an older staged DB must not replay over it. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` all cookies loaded in-memory — no restart needed`) - } - - // Why: the session keeps the UA the registry set at startup (clean or native). - // Imports must not impersonate the source browser — the synthesized UA read a - // fork's marketing version as a Chromium version (STA-3514), and Google binds - // sessions to the re-import, not the UA (#12884), so it bought nothing. - // Google-bound integrity cookies are already excluded by - // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. - - // Why: a partial import still drops every undecryptable row, so silence here would report it - // as an unqualified success. The restart-fallback warning describes a lossier outcome and - // keeps precedence. - if (!warning && undecryptableWarning) { - warning = undecryptableWarning - } - - const summary: BrowserCookieImportSummary = { - totalCookies: sourceRows.length, - importedCookies: imported, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...domainSet].sort(), - ...(warning ? { warning } : {}) - } - - return { ok: true, profileId: '', summary } - } catch (err) { - try { - sourceDb?.close() - } catch { - /* may already be closed */ - } - try { - stagingDb?.close() - } catch { - /* may already be closed */ - } - // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* may not exist yet */ - } - diag(` SQLite import failed: ${String(err)}`) - return { - ok: false, - reason: reasonWithDiagLog( - `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` - ) - } - } finally { - try { - sourceSnapshot.cleanup() - } catch (err) { - diag(` Chromium snapshot cleanup failed: ${String(err)}`) - } - } - }) + return importChromiumCookies(browser, targetPartition, options) } diff --git a/src/main/browser/browser-cookie-key.ts b/src/main/browser/browser-cookie-key.ts new file mode 100644 index 00000000000..620b279c140 --- /dev/null +++ b/src/main/browser/browser-cookie-key.ts @@ -0,0 +1,158 @@ +import { execFileSync } from 'node:child_process' +import { pbkdf2Sync } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runProcessSync } from '../../shared/child-process/run-process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { diag } from './browser-cookie-import-diagnostics' +import { + CHROMIUM_BROWSERS, + browserRootPath, + type DetectedBrowser +} from './browser-cookie-detection-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +const PBKDF2_ITERATIONS = 1003 +const PBKDF2_KEY_LENGTH = 16 +const PBKDF2_SALT = 'saltysalt' + +export function getEncryptionKey( + keychainService: string, + keychainAccount: string, + browser?: DetectedBrowser +): EncryptionKeyResult | null { + if (process.platform === 'darwin') { + return getMacEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'linux') { + return getLinuxEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'win32' && browser) { + return getWindowsEncryptionKey(browser) + } + return null +} + +export function getMacEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + try { + const raw = execFileSync( + 'security', + ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], + { encoding: 'utf-8', timeout: 30_000 } + ).trim() + return { + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } + } + } catch { + return null + } +} + +export function getLinuxEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. + const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + + let keyringPassword = '' + try { + // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. + keyringPassword = execFileSync( + 'secret-tool', + ['lookup', 'service', keychainService, 'account', keychainAccount], + { encoding: 'utf-8', timeout: 5_000 } + ).trim() + } catch { + // Why: fall back to application-based lookup used by newer Chromium versions. + try { + const app = keychainAccount.toLowerCase().replaceAll(' ', '') + keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { + encoding: 'utf-8', + timeout: 5_000 + }).trim() + } catch { + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true + } + } + + const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } +} + +export function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + + const localStatePath = join(root, 'Local State') + if (!existsSync(localStatePath)) { + return null + } + + try { + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const encryptedKeyB64 = localState?.os_crypt?.encrypted_key + if (typeof encryptedKeyB64 !== 'string') { + return null + } + + const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') + const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') + if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { + return null + } + + // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. + const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') + const script = [ + 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', + 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', + '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', + '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', + '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', + '[Convert]::ToBase64String($out)' + ].join('') + + // Why runProcessSync and an absolute path: a bare `powershell` spawn from a + // GUI-subsystem process opens a visible conhost that takes foreground, so + // keystrokes typed into an Orca terminal during a cookie import land in the + // black box (#14543), and PATH under Electron is not the user's (#11771). + const result = runProcessSync({ + program: windowsPowerShellPath(), + args: ['-NoProfile', '-NonInteractive', '-Command', script], + timeoutMs: 10_000, + input: dpapiData + }) + if (result.code !== 0 || result.timedOut) { + diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') + return null + } + + return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } + } catch (err) { + diag(` Windows DPAPI key extraction failed: ${String(err)}`) + return null + } +} diff --git a/src/main/browser/browser-cookie-safari-import.ts b/src/main/browser/browser-cookie-safari-import.ts new file mode 100644 index 00000000000..e8fa79c5fa0 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-import.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { decodeSafariBinaryCookies } from './browser-cookie-safari-parser' +import { importValidatedCookies, cookieImportTarget } from './browser-cookie-import-pipeline' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Safari import +// --------------------------------------------------------------------------- + +export async function importCookiesFromSafari( + browser: DetectedBrowser, + targetPartition: string +): Promise { + diag(`importCookiesFromSafari: partition="${targetPartition}"`) + + let data: Buffer + try { + data = readFileSync(browser.cookiesPath) + } catch (err) { + diag(` Safari read failed: ${String(err)}`) + // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. + const isPermError = + err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' + if (isPermError) { + return { + ok: false, + reason: + 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' + } + } + return { ok: false, reason: 'Could not read Safari cookies.' } + } + + try { + const cookies = decodeSafariBinaryCookies(data) + diag(` Safari source has ${cookies.length} cookies`) + + if (cookies.length === 0) { + return { ok: false, reason: 'No cookies found in Safari.' } + } + + const now = Math.floor(Date.now() / 1000) + const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) + + if (valid.length === 0) { + return { ok: false, reason: 'All Safari cookies are expired.' } + } + + return importValidatedCookies( + valid, + cookies.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) + } catch (err) { + diag(` Safari import failed: ${String(err)}`) + return { ok: false, reason: 'Could not import cookies from Safari.' } + } +} diff --git a/src/main/browser/browser-cookie-safari-parser.ts b/src/main/browser/browser-cookie-safari-parser.ts new file mode 100644 index 00000000000..9c4f7552488 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-parser.ts @@ -0,0 +1,148 @@ +import { deriveUrl } from './browser-cookie-validation' +import type { ValidatedCookie } from './browser-cookie-validation' + +const MAC_EPOCH_DELTA = 978_307_200 + +// --------------------------------------------------------------------------- +// Safari binary cookie parser +// --------------------------------------------------------------------------- + +export function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { + if (buffer.length < 8) { + return [] + } + if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { + return [] + } + + const pageCount = buffer.readUInt32BE(4) + let cursor = 8 + if (cursor + pageCount * 4 > buffer.length) { + return [] + } + const pageSizes: number[] = [] + for (let i = 0; i < pageCount; i++) { + pageSizes.push(buffer.readUInt32BE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const pageSize of pageSizes) { + const page = buffer.subarray(cursor, cursor + pageSize) + cursor += pageSize + appendSafariCookies(cookies, decodeSafariPage(page)) + } + return cookies +} + +export function appendSafariCookies( + target: ValidatedCookie[], + cookies: readonly ValidatedCookie[] +): void { + // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. + for (const cookie of cookies) { + target.push(cookie) + } +} + +export function decodeSafariPage(page: Buffer): ValidatedCookie[] { + if (page.length < 16) { + return [] + } + if (page.readUInt32BE(0) !== 0x00000100) { + return [] + } + + const cookieCount = page.readUInt32LE(4) + if (8 + cookieCount * 4 > page.length) { + return [] + } + const offsets: number[] = [] + let cursor = 8 + for (let i = 0; i < cookieCount; i++) { + offsets.push(page.readUInt32LE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const offset of offsets) { + const cookie = decodeSafariCookie(page.subarray(offset)) + if (cookie) { + cookies.push(cookie) + } + } + return cookies +} + +export function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { + if (buf.length < 48) { + return null + } + // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. + const size = Math.min(buf.readUInt32LE(0), buf.length) + if (size < 48) { + return null + } + + const flags = buf.readUInt32LE(8) + const secure = (flags & 1) !== 0 + const httpOnly = (flags & 4) !== 0 + + const urlOffset = buf.readUInt32LE(16) + const nameOffset = buf.readUInt32LE(20) + const pathOffset = buf.readUInt32LE(24) + const valueOffset = buf.readUInt32LE(28) + + // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). + const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 + + const name = readCString(buf, nameOffset, size) + if (!name) { + return null + } + const value = readCString(buf, valueOffset, size) ?? '' + const path = readCString(buf, pathOffset, size) ?? '/' + const rawUrl = readCString(buf, urlOffset, size) ?? '' + + // Why: Safari stores the domain in the URL field, not as a separate domain column. + const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null + if (!domain) { + return null + } + + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined + + return { + url, + name, + value, + domain, + path, + secure, + httpOnly, + sameSite: 'unspecified', + expirationDate, + // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every + // decoded cookie is genuinely unpartitioned rather than missing an identity. + partition: { status: 'unpartitioned' } + } +} + +export function readCString(buf: Buffer, offset: number, end: number): string | null { + if (offset < 0 || offset >= end) { + return null + } + let cursor = offset + while (cursor < end && buf[cursor] !== 0) { + cursor++ + } + if (cursor >= end) { + return null + } + return buf.toString('utf8', offset, cursor) +} diff --git a/src/main/browser/browser-cookie-sqlite.ts b/src/main/browser/browser-cookie-sqlite.ts new file mode 100644 index 00000000000..05593c79024 --- /dev/null +++ b/src/main/browser/browser-cookie-sqlite.ts @@ -0,0 +1,147 @@ +const CHROMIUM_EPOCH_OFFSET = 11644473600n + +export function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { + if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { + return 0 + } + try { + const ts = + typeof chromiumTs === 'bigint' + ? chromiumTs + : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) + if (ts === 0n) { + return 0 + } + return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) + } catch { + return 0 + } +} + +// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. + +export type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } + +export type ChromiumCookieColumnInfo = { + name: string + type?: string + notnull?: number | bigint + dflt_value?: unknown +} + +export function parseSqliteDefaultValue( + raw: unknown, + type: string +): string | number | Buffer | null { + if (raw === null || raw === undefined) { + return null + } + if (typeof raw !== 'string') { + return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) + } + + const trimmed = raw.trim() + if (!trimmed || trimmed.toUpperCase() === 'NULL') { + return null + } + if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { + return Buffer.alloc(0) + } + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (type.includes('INT')) { + const numeric = Number(trimmed) + return Number.isFinite(numeric) ? numeric : 0 + } + return trimmed +} + +export function normalizeSqliteCookieValue( + value: unknown +): string | number | bigint | Buffer | null { + if (value instanceof Uint8Array) { + return Buffer.from(value) + } + if (value === undefined || value === null) { + return null + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value + } + return String(value) +} + +export function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { + return Number(column.notnull ?? 0) !== 0 +} + +export function fallbackChromiumCookieColumnValue( + column: ChromiumCookieColumnInfo, + sourceRow: Record +): string | number | bigint | Buffer | null { + const type = (column.type ?? '').toUpperCase() + const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) + if (defaultValue !== null) { + return defaultValue + } + if (!isSqliteNotNull(column)) { + return null + } + + switch (column.name) { + case 'value': + case 'encrypted_value': + return Buffer.alloc(0) + case 'top_frame_site_key': + return '' + case 'source_port': + return -1 + case 'last_update_utc': + return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 + default: + if (type.includes('BLOB')) { + return Buffer.alloc(0) + } + if (type.includes('INT')) { + return 0 + } + return '' + } +} + +export function buildChromiumCookieInsertParams( + targetColumns: ChromiumCookieColumnInfo[], + sourceRow: Record, + decryptedValue: Buffer +): (string | number | bigint | Buffer | null)[] { + return targetColumns.map((column) => { + if (column.name === 'encrypted_value') { + return Buffer.alloc(0) + } + if (column.name === 'value') { + return decryptedValue + } + + const sourceHasColumn = Object.hasOwn(sourceRow, column.name) + const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null + if (sourceValue !== null) { + return sourceValue + } + if (sourceHasColumn && !isSqliteNotNull(column)) { + return null + } + + // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. + return fallbackChromiumCookieColumnValue(column, sourceRow) + }) +} diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts new file mode 100644 index 00000000000..6ae543245ef --- /dev/null +++ b/src/main/browser/browser-cookie-validation.ts @@ -0,0 +1,127 @@ +import { normalizeCookieDomain } from './browser-cookie-import-policy' +import { + readJsonCookiePartition, + type SourcePartitionRead +} from './browser-cookie-source-partition' +import type { ImportedCookieFields } from './browser-cookie-import-write' + +export type RawCookieEntry = { + domain?: unknown + name?: unknown + value?: unknown + path?: unknown + secure?: unknown + httpOnly?: unknown + sameSite?: unknown + expirationDate?: unknown + partitionKey?: unknown + partitionKeyOpaque?: unknown +} + +// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to +// state what it read. An optional field would let a new source silently default to unpartitioned. +export type ValidatedCookie = ImportedCookieFields & { + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. +export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 1: + return 'no_restriction' + case 2: + return 'lax' + case 3: + return 'strict' + default: + return 'unspecified' + } +} + +export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } +} + +export function normalizeSameSite( + raw: unknown +): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + if (typeof raw === 'number') { + return chromiumSameSite(raw) + } + if (typeof raw !== 'string') { + return 'unspecified' + } + const lower = raw.toLowerCase() + if (lower === 'lax') { + return 'lax' + } + if (lower === 'strict') { + return 'strict' + } + if (lower === 'none' || lower === 'no_restriction') { + return 'no_restriction' + } + return 'unspecified' +} + +// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. +export function deriveUrl(domain: string, secure: boolean): string | null { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { + return null + } + const protocol = secure ? 'https' : 'http' + try { + const url = new URL(`${protocol}://${normalizedDomain}/`) + return url.toString() + } catch { + return null + } +} + +export function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { + if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { + return null + } + if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return null + } + if (typeof raw.value !== 'string') { + return null + } + + const domain = raw.domain.trim() + const secure = raw.secure === true || raw.secure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = + typeof raw.expirationDate === 'number' && raw.expirationDate > 0 + ? raw.expirationDate + : undefined + + return { + url, + name: raw.name.trim(), + value: raw.value, + domain, + path: typeof raw.path === 'string' ? raw.path : '/', + secure, + httpOnly: raw.httpOnly === true || raw.httpOnly === 1, + sameSite: normalizeSameSite(raw.sameSite), + expirationDate, + partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) + } +} diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts new file mode 100644 index 00000000000..931c5c6bc3b --- /dev/null +++ b/src/main/browser/browser-manager-bindings.ts @@ -0,0 +1,89 @@ +import { resolveRendererWebContents } from './browser-guest-renderer-target' +import { setupGuestContextMenu } from './browser-guest-context-menu' +import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' +import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' +import { BrowserManagerGrab } from './browser-manager-grab' + +export abstract class BrowserManagerBindings extends BrowserManagerGrab { + protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { + this.contextMenuCleanupByTabId.set( + browserTabId, + setupGuestContextMenu({ + browserTabId, + guest, + resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) + }) + ) + } + + // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. + protected setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + + this.grabShortcutCleanupByTabId.set( + browserTabId, + setupGrabShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), + getKeybindings: () => this.settingsResolver?.().keybindings + }) + ) + } + + // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. + protected setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + + this.shortcutForwardingCleanupByTabId.set( + browserTabId, + setupGuestShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, + isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, + getKeybindings: () => this.settingsResolver?.().keybindings, + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, + resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null + }) + ) + } + + protected setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + + this.mouseWheelZoomCleanupByTabId.set( + browserTabId, + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + isViewportPresetActive: () => { + const state = this.viewportPresetActiveByTabId.get(browserTabId) + return state?.guestWebContentsId === guest.id && state.active + }, + canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), + onViewportWheelConsumed: (deltaX, deltaY) => + this.recordViewportScrollDelta(browserTabId, deltaX, deltaY) + }) + ) + } +} diff --git a/src/main/browser/browser-manager-download-creation.ts b/src/main/browser/browser-manager-download-creation.ts new file mode 100644 index 00000000000..fcef84b9809 --- /dev/null +++ b/src/main/browser/browser-manager-download-creation.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { routeBrowserClientDownload } from './browser-client-download-routing' +import { + safeOrigin, + type ActiveDownload, + type BrowserDownloadDoneState +} from './browser-manager-types' +import type { BrowserDownloadFinishedEvent } from '../../shared/browser-guest-events' +import { BrowserManagerQueries } from './browser-manager-queries' + +export abstract class BrowserManagerDownloadCreation extends BrowserManagerQueries { + handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { + const { guestWebContentsId, item } = args + const downloadId = randomUUID() + const requestedFilename = (() => { + try { + return item.getFilename() || 'download' + } catch { + return 'download' + } + })() + const totalBytes = (() => { + try { + const total = item.getTotalBytes() + return total > 0 ? total : null + } catch { + return null + } + })() + const mimeType = (() => { + try { + const mime = item.getMimeType() + return mime || null + } catch { + return null + } + })() + const origin = (() => { + try { + return safeOrigin(item.getURL()) + } catch { + return 'unknown' + } + })() + + // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself + // instead of reserving a name in the desktop Downloads folder. A popup downloads to its + // opener's page: the popup itself is a client-local transient with no logical page of its own. + const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) + const decision = routeBrowserClientDownload({ + guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId + }) + const clientRoute = decision.kind === 'remote' ? decision.route : null + const destination = (() => { + if (clientRoute) { + return { + filename: requestedFilename, + savePath: clientRoute.stagingPath, + reservationKey: null + } + } + // Why: a client-hosted download with no resolvable remote destination is canceled rather than + // written to this desktop's Downloads folder. + if (decision.kind === 'blocked') { + return null + } + try { + return browserDownloadDestinationReservations.reserve(requestedFilename) + } catch (error) { + console.error('[browser-download] Failed to choose download destination:', error) + return null + } + })() + + const fallbackSavePath = destination?.savePath ?? '' + + const download: ActiveDownload = { + downloadId, + guestWebContentsId, + browserTabId: null, + rendererWebContentsId: null, + origin, + filename: destination?.filename ?? requestedFilename, + totalBytes, + mimeType, + item, + savePath: fallbackSavePath, + reservationKey: destination?.reservationKey ?? null, + clientRoute, + remoteDestination: undefined, + receivedBytes: 0, + transientState: null, + terminalEvent: null, + startedSent: false, + cleanup: null + } + this.downloadsById.set(downloadId, download) + + const browserTabId = ownerContext?.browserTabId ?? null + if (browserTabId) { + this.bindDownloadToTab(downloadId, browserTabId) + } else { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] + pending.push(downloadId) + this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) + } + + if (!destination) { + this.finishDownloadInternal( + downloadId, + 'failed', + decision.kind === 'blocked' + ? 'Could not save the download to the remote workspace.' + : 'Could not choose a Downloads file name.' + ) + try { + item.cancel() + } catch { + // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. + } + return + } + + try { + item.setSavePath(destination.savePath) + } catch (error) { + console.error('[browser-download] Failed to set download destination:', error) + this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') + try { + item.cancel() + } catch { + // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. + } + return + } + + const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { + download.receivedBytes = this.getDownloadReceivedBytes(download.item) + download.transientState = state + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state + }) + } + const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { + const status: BrowserDownloadFinishedEvent['status'] = + state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' + const failure = + status === 'failed' + ? state === 'interrupted' + ? 'Download was interrupted.' + : 'Download failed.' + : null + if (download.clientRoute) { + void this.settleClientHostedDownload(download, status, failure) + return + } + this.finishDownloadInternal(download.downloadId, status, failure) + } + download.cleanup = (): void => { + try { + download.item.off('updated', updatedHandler) + download.item.off('done', doneHandler) + } catch { + // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. + } + } + item.on('updated', updatedHandler) + item.once('done', doneHandler) + + if (browserTabId) { + this.sendDownloadStarted(downloadId) + } + } + + cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { + const download = this.downloadsById.get(args.downloadId) + if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { + return false + } + this.cancelDownloadInternal(args.downloadId, 'Canceled.') + return true + } +} diff --git a/src/main/browser/browser-manager-download-lifecycle.ts b/src/main/browser/browser-manager-download-lifecycle.ts new file mode 100644 index 00000000000..614b7651d16 --- /dev/null +++ b/src/main/browser/browser-manager-download-lifecycle.ts @@ -0,0 +1,241 @@ +import { browserDownloadDestinationReservations } from './browser-download-destination' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent +} from '../../shared/browser-guest-events' +import type { ActiveDownload } from './browser-manager-types' +import { BrowserManagerDownloadCreation } from './browser-manager-download-creation' + +export abstract class BrowserManagerDownloadLifecycle extends BrowserManagerDownloadCreation { + protected bindDownloadToTab(downloadId: string, browserTabId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + download.browserTabId = browserTabId + download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null + } + + protected flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + for (const downloadId of pending) { + this.bindDownloadToTab(downloadId, browserTabId) + this.flushDownloadSnapshot(downloadId) + } + } + + protected flushDownloadSnapshot(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + this.sendDownloadStarted(downloadId) + if (download.receivedBytes > 0 || download.transientState) { + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state: download.transientState + }) + } + if (download.terminalEvent) { + this.sendDownloadFinished(download.browserTabId, { + ...download.terminalEvent, + browserPageId: download.browserTabId ?? undefined + }) + this.downloadsById.delete(downloadId) + } + } + + protected sendDownloadStarted(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download?.browserTabId) { + return + } + if (download.startedSent) { + return + } + const renderer = this.resolveRendererForBrowserTab(download.browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-requested', { + browserPageId: download.browserTabId, + downloadId: download.downloadId, + origin: download.origin, + filename: download.filename, + totalBytes: download.totalBytes, + mimeType: download.mimeType, + savePath: download.savePath, + status: 'downloading' + } satisfies BrowserDownloadRequestedEvent) + download.startedSent = true + } + + protected sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-progress', payload) + } + + protected sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-finished', payload) + } + + protected async settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise { + const route = download.clientRoute + if (!route) { + return + } + if (status !== 'completed') { + download.clientRoute = null + await route.abort().catch(() => undefined) + this.finishDownloadInternal(download.downloadId, status, failure) + return + } + try { + // Why: the route stays on the record for the whole commit, which spans many round trips -- a + // cancel arriving mid-stream has to find something to abort or the bytes land anyway. + const remoteDestination = await route.complete(download.filename) + download.clientRoute = null + download.remoteDestination = remoteDestination + // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. + download.savePath = '' + this.finishDownloadInternal(download.downloadId, 'completed', null) + } catch (error) { + download.clientRoute = null + if (download.terminalEvent) { + // A cancel already reported the outcome; this rejection is that cancel taking effect. + return + } + console.error('[browser-download] Failed to save download to the remote workspace:', error) + this.finishDownloadInternal( + download.downloadId, + 'failed', + 'Could not save the download to the remote workspace.' + ) + } + } + + protected cancelDownloadInternal(downloadId: string, reason: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + const shouldSendCancel = !download.terminalEvent + + try { + download.item.cancel() + } catch { + // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. + } + + if (shouldSendCancel) { + this.finishDownloadInternal(downloadId, 'canceled', reason || null) + return + } + + this.downloadsById.delete(downloadId) + } + + protected finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void { + const download = this.downloadsById.get(downloadId) + if (!download || download.terminalEvent) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + browserDownloadDestinationReservations.release(download.reservationKey) + download.reservationKey = null + if (download.clientRoute) { + // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. + void download.clientRoute.abort().catch(() => undefined) + download.clientRoute = null + } + const event: BrowserDownloadFinishedEvent = { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + status, + savePath: download.savePath || null, + ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), + error + } + download.terminalEvent = event + if (download.browserTabId) { + this.sendDownloadStarted(downloadId) + this.sendDownloadFinished(download.browserTabId, event) + this.downloadsById.delete(downloadId) + } + } + + protected cancelPendingDownloadsForGuest(guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + if (!pending?.length) { + return + } + for (const downloadId of pending) { + const download = this.downloadsById.get(downloadId) + if (!download) { + continue + } + if (download.terminalEvent) { + this.downloadsById.delete(downloadId) + continue + } + this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') + const afterCancel = this.downloadsById.get(downloadId) + if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { + this.downloadsById.delete(downloadId) + } + } + } + + protected getDownloadReceivedBytes(item: Electron.DownloadItem): number { + try { + return Math.max(0, item.getReceivedBytes()) + } catch { + return 0 + } + } +} diff --git a/src/main/browser/browser-manager-event-forwarding.ts b/src/main/browser/browser-manager-event-forwarding.ts new file mode 100644 index 00000000000..62c6e1d00a1 --- /dev/null +++ b/src/main/browser/browser-manager-event-forwarding.ts @@ -0,0 +1,123 @@ +import type { + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { BrowserManagerBindings } from './browser-manager-bindings' +import type { PendingPermissionEvent, PendingPopupEvent } from './browser-manager-types' + +export abstract class BrowserManagerEventForwarding extends BrowserManagerBindings { + protected forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (!browserTabId) { + // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. + this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) + return + } + this.sendGuestLoadFailure(browserTabId, loadError) + } + + protected forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPermissionDenied(browserTabId, event) + } + + protected flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPermissionDenied(browserTabId, event) + } + } + + protected sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:permission-denied', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPermissionDeniedEvent) + } + + protected forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPopupEvent(browserTabId, event) + } + + protected flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPopupEvent(browserTabId, event) + } + } + + protected sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:popup', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPopupEvent) + } + + protected flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) + if (!pending) { + return + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.sendGuestLoadFailure(browserTabId, pending) + } + + protected sendGuestLoadFailure( + browserTabId: string, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:guest-load-failed', { + browserPageId: browserTabId, + loadError: { + ...loadError, + validatedUrl: redactKagiSessionToken(loadError.validatedUrl) + } + }) + } +} diff --git a/src/main/browser/browser-manager-final.ts b/src/main/browser/browser-manager-final.ts new file mode 100644 index 00000000000..b19369fc91d --- /dev/null +++ b/src/main/browser/browser-manager-final.ts @@ -0,0 +1,22 @@ +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { BrowserManagerEventForwarding } from './browser-manager-event-forwarding' + +export abstract class BrowserManagerFinal extends BrowserManagerEventForwarding { + protected openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } +} diff --git a/src/main/browser/browser-manager-grab.ts b/src/main/browser/browser-manager-grab.ts new file mode 100644 index 00000000000..77e890138c5 --- /dev/null +++ b/src/main/browser/browser-manager-grab.ts @@ -0,0 +1,126 @@ +import { webContents } from 'electron' +import { buildGuestOverlayScript } from './grab-guest-script' +import { clampGrabPayload } from './browser-grab-payload' +import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import type { + BrowserGrabCancelReason, + BrowserGrabResult, + BrowserGrabRect, + BrowserGrabPayload, + BrowserGrabScreenshot +} from './browser-manager-types' +import { BrowserManagerViewport } from './browser-manager-viewport' + +export abstract class BrowserManagerGrab extends BrowserManagerViewport { + // --- Browser Context Grab — main-owned operations --- + + /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ + getAuthorizedGuest( + browserTabId: string, + senderWebContentsId: number + ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } + const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) + if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { + return null + } + const guestId = this.webContentsIdByTabId.get(browserTabId) + if (guestId == null) { + return null + } + const guest = webContents.fromId(guestId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return null + } + return guest + } + + /** Returns true if a grab operation is currently active for this tab. */ + hasActiveGrabOp(browserTabId: string): boolean { + return this.grabSessionController.hasActiveGrabOp(browserTabId) + } + + /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ + async setGrabMode( + browserTabId: string, + enabled: boolean, + guest: Electron.WebContents + ): Promise { + if (!enabled) { + const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) + this.cancelGrabOp(browserTabId, 'user') + if (hadActiveGrabOp) { + return true + } + try { + await guest.executeJavaScript(buildGuestOverlayScript('teardown')) + return true + } catch { + return false + } + } + // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. + try { + await guest.executeJavaScript(buildGuestOverlayScript('arm')) + return true + } catch { + return false + } + } + + /** + * Await a single grab selection on the given tab; resolves once on click, cancel, or error. + * + * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. + */ + awaitGrabSelection( + browserTabId: string, + opId: string, + guest: Electron.WebContents + ): Promise { + return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) + } + + /** Cancel an active grab operation for the given tab. */ + cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { + this.grabSessionController.cancelGrabOp(browserTabId, reason) + } + + /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ + async captureSelectionScreenshot( + _browserTabId: string, + rect: BrowserGrabRect, + guest: Electron.WebContents + ): Promise { + return captureGrabSelectionScreenshot(rect, guest) + } + + /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ + async extractHoverPayload( + _browserTabId: string, + guest: Electron.WebContents + ): Promise { + try { + const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) + if (!rawPayload || typeof rawPayload !== 'object') { + return null + } + return clampGrabPayload(rawPayload) + } catch { + return null + } + } +} diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts new file mode 100644 index 00000000000..9dac4f3f665 --- /dev/null +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -0,0 +1,44 @@ +import { BrowserManagerGuestNavigationPolicy } from './browser-manager-guest-navigation-policy' + +export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavigationPolicy { + protected retireStaleGuestWebContents(previousWebContentsId: number): void { + // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. + this.cleanupGuestPolicyAttachment(previousWebContentsId) + } + + protected cleanupGuestPolicyAttachment(guestWebContentsId: number): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) + const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) + if (policyCleanup) { + policyCleanup() + this.policyCleanupByGuestId.delete(guestWebContentsId) + } + this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) + this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) + // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. + if (isPrimaryGuest) { + for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { + if (owner.rootGuestWebContentsId === guestWebContentsId) { + this.popupOwnerContextByGuestId.delete(popupGuestId) + } + } + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + this.cancelPendingDownloadsForGuest(guestWebContentsId) + } +} diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts new file mode 100644 index 00000000000..abacd268640 --- /dev/null +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -0,0 +1,152 @@ +import { + normalizeBrowserNavigationUrl, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { isChromiumInternalErrorUrl } from './browser-manager-types' +import { BrowserManagerGuestPopupPolicy } from './browser-manager-guest-popup-policy' + +export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManagerGuestPopupPolicy { + protected installGuestNavigationPolicy(guest: Electron.WebContents): () => void { + const navigationGuard = (event: Electron.Event, url: string): boolean => { + // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. + if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { + return true + } + // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. + if (url.startsWith('file:')) { + event.preventDefault() + return false + } + if (!normalizeBrowserNavigationUrl(url)) { + // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. + event.preventDefault() + return false + } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) + } + + const didFailLoadHandler = ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ): void => { + if (!isMainFrame) { + return + } + // Why: a nav that never committed must not leave its target standing as the tab's host. + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. + return + } + if (errorCode === -3) { + // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = this.buildLoadError( + errorCode, + errorDescription || 'This site could not be reached.', + validatedURL || guest.getURL() || 'about:blank' + ) + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: once committed, getURL() reports this url, so the pending target is redundant. + this.pendingNavigationByGuestId.delete(guest.id) + // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) + } + + guest.on('will-navigate', navigationGuard) + guest.on('will-redirect', willRedirectHandler) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) + guest.on('did-fail-load', didFailLoadHandler) + const handleDestroyed = (): void => { + // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + + return () => { + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + if (!guest.isDestroyed()) { + guest.off('will-navigate', navigationGuard) + guest.off('will-redirect', willRedirectHandler) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) + guest.off('did-fail-load', didFailLoadHandler) + } + } + } +} diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts new file mode 100644 index 00000000000..c0d522235c8 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto' +import { + BROWSING_GUEST_POLICY, + type BrowserGuestPolicy, + type PopupOwnerContext +} from './browser-manager-types' +import { BrowserManagerGuestCleanup } from './browser-manager-guest-cleanup' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' + +export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestCleanup { + attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY + ): void { + if (this.policyAttachedGuestIds.has(guest.id)) { + return + } + this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } + if (inheritedOwnerContext) { + this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) + } + // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + + // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. + const disposeAntiDetection = this.injectAntiDetection(guest) + // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. + guest.setBackgroundThrottling(false) + const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName) + const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest) + + // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. + this.policyCleanupByGuestId.set(guest.id, () => { + disposeAntiDetection() + disposePopupPolicy() + disposeNavigationPolicy() + }) + } + + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + protected attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } +} diff --git a/src/main/browser/browser-manager-guest-popup-policy.ts b/src/main/browser/browser-manager-guest-popup-policy.ts new file mode 100644 index 00000000000..43133fd8e29 --- /dev/null +++ b/src/main/browser/browser-manager-guest-popup-policy.ts @@ -0,0 +1,210 @@ +import { shell } from 'electron' +import { randomUUID } from 'node:crypto' +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl, + redactKagiSessionToken +} from '../../shared/browser-url' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' +import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' +import { SAFE_POPUP_WINDOW_OPTIONS, safeOrigin } from './browser-manager-types' +import type { PopupChildWindowOptions } from './popup-origin-bar-window' +import { BrowserManagerNavigation } from './browser-manager-navigation' + +export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavigation { + protected installGuestPopupPolicy( + guest: Electron.WebContents, + clickedLinkFrameName: string | null + ): () => void { + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } + const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { + // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. + this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) + } + guest.on('did-create-window', handleDidCreateWindow) + guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const browserTabId = ownerContext?.browserTabId ?? null + const browserUrl = normalizeBrowserNavigationUrl(url) + const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. + return { action: 'deny' } + } + + // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is + // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are + // excluded by isNewBrowserTabPopupIntent and still get a real child window below. + if ( + ownerContext && + externalUrl && + isNewBrowserTabPopupIntent({ frameName, disposition, features }) + ) { + // Why: one activation lets a page loop window.open, and each routed tab persists into + // workspace session state, so it survives the quit that used to clear popup windows. + if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'blocked' + }) + return { action: 'deny' } + } + if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. + return { action: 'deny' } + } + + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. + const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) + if (browserTabId && canOpenAsChild) { + // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. + return { + action: 'allow', + overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, + // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. + createWindow: (options: PopupChildWindowOptions) => + this.createPopupChildWindowWithOriginBar(guest, url, options) + } + } else if (externalUrl) { + // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. + void shell.openExternal(redactKagiSessionToken(externalUrl)) + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-external' + }) + } else { + // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(url), + action: 'blocked' + }) + } + return { action: 'deny' } + }) + + return () => { + clickedLinkRoutingActive = false + try { + guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } + } catch { + // guest may already be destroyed + } + } + } +} diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts new file mode 100644 index 00000000000..4e061d288aa --- /dev/null +++ b/src/main/browser/browser-manager-navigation.ts @@ -0,0 +1,263 @@ +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { cleanElectronUserAgent } from './browser-session-ua' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' +import { + safeOrigin, + type AuthUserAgentOverrideOperation, + type AuthUserAgentOverrideState +} from './browser-manager-types' +import { BrowserManagerVisibility } from './browser-manager-visibility' + +export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, + // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // must be matched here per navigation or the two layers disagree — itself a bot tell. + // Restores the session's base identity off the auth hosts. Native-UA profiles opt out + // of the whole clean-UA path, so they keep their untouched identity everywhere. + protected applyGoogleAuthUserAgent( + guest: Electron.WebContents, + url: string, + options: { duringRedirect?: boolean } = {} + ): void { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct + // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. + // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. + const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) + if (mode === 'native') { + return + } + const firefoxUa = googleAuthUserAgent() + const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) + const latestPendingOverride = overrideState?.pending.at(-1) + const confirmedOverride = overrideState?.confirmed + const currentOverride = + latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) + ? latestPendingOverride + : confirmedOverride + const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() + const nextUa = isGoogleAuthUrl(url) + ? firefoxUa + : // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + currentUa === firefoxUa + ? guest.session.getUserAgent() + : null + let authOverrideIssuedOverCdp = false + if (nextUa !== null && nextUa !== currentUa) { + // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight + // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain + // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without + // touching the navigation, and it outranks the WebContents UA from then on, so a guest that + // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride + // rewrites User-Agent per request for auth-host URLs on its own. + if (options.duringRedirect === true || overrideState !== undefined) { + if (this.canOverrideUserAgentOverCdp(guest)) { + authOverrideIssuedOverCdp = true + // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers + // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off + // them, any mobile preset preserved. Writing the session UA directly would put the + // unlaundered Electron token back on the wire. + void this.applyAuthUserAgentOverrideOverCdp( + guest, + (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? + false, + url, + nextUa + ) + } + // Why: with no debugger there is no way to retarget the identity without cancelling the + // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. + } else { + guest.setUserAgent(nextUa) + } + } + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of + // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. + if (browserPageId && !authOverrideIssuedOverCdp) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } + } + + protected canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { + try { + return !guest.isDestroyed() && guest.debugger.isAttached() + } catch { + return false + } + } + + protected applyAuthUserAgentOverrideOverCdp( + guest: Electron.WebContents, + mobile: boolean, + url: string, + userAgent: string + ): Promise { + if (!this.canOverrideUserAgentOverCdp(guest)) { + return Promise.resolve(false) + } + const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { + confirmed: null, + nextSequence: 0, + pending: [] + } + const operation = { sequence: ++state.nextSequence, userAgent } + state.pending.push(operation) + this.authUserAgentOverrideStateByGuestId.set(guest.id, state) + return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( + () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), + () => { + this.settleAuthUserAgentOverride(guest.id, state, operation, false) + return false + } + ) + } + + protected settleAuthUserAgentOverride( + guestId: number, + state: AuthUserAgentOverrideState, + operation: AuthUserAgentOverrideOperation, + succeeded: boolean + ): boolean { + if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { + return false + } + if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { + state.confirmed = operation + } + const pendingIndex = state.pending.indexOf(operation) + if (pendingIndex !== -1) { + state.pending.splice(pendingIndex, 1) + } + if (state.confirmed === null && state.pending.length === 0) { + this.authUserAgentOverrideStateByGuestId.delete(guestId) + } + return true + } + + protected startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + protected updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return + } + pending.currentUrl = url + } + + protected failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true + } + + // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host + // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through + // here or two writers racing the same navigation will pick opposite identities. + protected resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() + } + + // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, + // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the + // request header says Firefox — the two-layer disagreement this scope exists to remove. + protected reapplyViewportUserAgentOverride( + guest: Electron.WebContents, + browserTabId: string, + url: string + ): void { + const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + if (mobile === undefined) { + return + } + // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the + // later-issued write wins. What matters is that both writers resolve the SAME host, which they + // now do via the navigation target rather than the stale committed URL. + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) + } + + protected async sendViewportUserAgentOverride( + guest: Electron.WebContents, + mobile: boolean, + url?: string, + baseUserAgent?: string + ): Promise { + if (guest.isDestroyed() || !guest.debugger.isAttached()) { + return + } + await guest.debugger.sendCommand( + 'Emulation.setUserAgentOverride', + buildViewportUserAgentOverride({ + url: url ?? this.resolveTabNavigationUrl(guest), + mobile, + // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: + // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to + // the CDP override, so reading it back here would republish that identity on ordinary hosts. + baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) + }) + ) + } + + /** Route guests own their own popup handler, so their denials arrive here instead. */ + reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { + this.forwardOrQueuePopupEvent(input.openerWebContentsId, { + origin: safeOrigin(input.url), + action: 'blocked' + }) + } + + protected createPopupChildWindowWithOriginBar( + openerGuest: Electron.WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): Electron.WebContents { + const popup = openPopupWithOriginBar(options, targetUrl) + // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. + this.attachGuestPolicies( + popup.contentWebContents, + this.resolvePopupOwnerContext(openerGuest.id) + ) + this.forwardOrQueuePopupEvent(openerGuest.id, { + origin: safeOrigin(targetUrl), + action: 'opened-in-orca' + }) + // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. + const closePopupWithOpener = (): void => popup.close() + openerGuest.once('destroyed', closePopupWithOpener) + popup.onClosed(() => { + if (!openerGuest.isDestroyed()) { + openerGuest.off('destroyed', closePopupWithOpener) + } + }) + return popup.contentWebContents + } +} diff --git a/src/main/browser/browser-manager-queries.ts b/src/main/browser/browser-manager-queries.ts new file mode 100644 index 00000000000..dd8a5dc87a7 --- /dev/null +++ b/src/main/browser/browser-manager-queries.ts @@ -0,0 +1,136 @@ +import { webContents } from 'electron' +import type { + BrowserCertificateFailure, + BrowserLoadError +} from '../../shared/browser-workspace-types' +import type { ManagedBrowserGuestContext } from './browser-certificate-trust-controller' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { safeOrigin } from './browser-manager-types' +import { BrowserManagerRegistration } from './browser-manager-registration' + +export abstract class BrowserManagerQueries extends BrowserManagerRegistration { + getGuestWebContentsId(browserTabId: string): number | null { + return this.webContentsIdByTabId.get(browserTabId) ?? null + } + + getWebContentsIdByTabId(): Map { + return this.webContentsIdByTabId + } + + getTabIdForWebContentsId(webContentsId: number): string | null { + return this.tabIdByWebContentsId.get(webContentsId) ?? null + } + + getWorktreeIdForTab(browserTabId: string): string | undefined { + return this.worktreeIdByTabId.get(browserTabId) + } + + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + + getSessionProfileIdForTab(browserTabId: string): string | null { + return this.sessionProfileIdByPageId.get(browserTabId) ?? null + } + + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. + protected buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { + return { + code, + description, + validatedUrl: redactKagiSessionToken(rawUrl) + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl) + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + protected notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + + notifyPermissionDenied(args: { + guestWebContentsId: number + permission: string + rawUrl: string + }): void { + this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { + permission: args.permission, + origin: safeOrigin(args.rawUrl) + }) + } +} diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts new file mode 100644 index 00000000000..6850c240950 --- /dev/null +++ b/src/main/browser/browser-manager-registration.ts @@ -0,0 +1,230 @@ +import { webContents } from 'electron' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { isWorkspaceDocPageId } from './doc-preview-guest-policy' +import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' +import type { BrowserGuestRegistration } from './browser-manager-types' +import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy' + +export abstract class BrowserManagerRegistration extends BrowserManagerGuestPolicy { + registerGuest({ + browserPageId, + browserTabId: legacyBrowserTabId, + workspaceId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId, + rendererWebContentsId + }: BrowserGuestRegistration): boolean { + const browserTabId = browserPageId ?? legacyBrowserTabId + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { + return false + } + // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. + this.cancelGrabOp(browserTabId, 'evicted') + + const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + + // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. + if (guest.getType() !== 'webview') { + return false + } + if (!this.policyAttachedGuestIds.has(webContentsId)) { + // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. + return false + } + + const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportScrollStateByTabId.delete(browserTabId) + } + this.webContentsIdByTabId.set(browserTabId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserTabId) + if (workspaceId) { + this.workspaceIdByPageId.set(browserTabId, workspaceId) + } + this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserTabId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserTabId) + } + this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) + if (worktreeId) { + this.worktreeIdByTabId.set(browserTabId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) + + this.setupContextMenu(browserTabId, guest) + this.setupGrabShortcut(browserTabId, guest) + this.setupShortcutForwarding(browserTabId, guest) + this.setupMouseWheelZoomForwarding(browserTabId, guest) + this.flushPendingLoadFailure(browserTabId, webContentsId) + this.flushPendingPermissionEvents(browserTabId, webContentsId) + this.flushPendingPopupEvents(browserTabId, webContentsId) + this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true + } + + unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } + // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. + this.cancelGrabOp(browserTabId, 'evicted') + + // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. + const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (guestWebContentsId !== undefined) { + this.cleanupGuestPolicyAttachment(guestWebContentsId) + } + + const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (cleanup) { + cleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (shortcutCleanup) { + shortcutCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (fwdCleanup) { + fwdCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (mouseWheelZoomCleanup) { + mouseWheelZoomCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. + for (const [downloadId, download] of this.downloadsById.entries()) { + if (download.browserTabId === browserTabId && !download.terminalEvent) { + this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') + } + } + const wcId = this.webContentsIdByTabId.get(browserTabId) + if (wcId !== undefined) { + this.tabIdByWebContentsId.delete(wcId) + } + this.webContentsIdByTabId.delete(browserTabId) + this.rendererWebContentsIdByTabId.delete(browserTabId) + this.workspaceIdByPageId.delete(browserTabId) + this.sessionProfileIdByPageId.delete(browserTabId) + this.userAgentModeByPageId.delete(browserTabId) + this.worktreeIdByTabId.delete(browserTabId) + // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. + this.viewportOpsByTabId.delete(browserTabId) + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportScrollStateByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + + // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. + registerOffscreenGuest({ + browserPageId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId + }: { + browserPageId: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) + const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + this.viewportPresetActiveByTabId.delete(browserPageId) + this.viewportScrollStateByTabId.delete(browserPageId) + } + this.webContentsIdByTabId.set(browserPageId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserPageId) + this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserPageId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserPageId) + } + if (worktreeId) { + this.worktreeIdByTabId.set(browserPageId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true + } + + unregisterAll(): void { + // Cancel all active grab ops before tearing down registrations + this.grabSessionController.cancelAll('evicted') + for (const downloadId of this.downloadsById.keys()) { + this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') + } + browserDownloadDestinationReservations.clear() + for (const browserTabId of this.webContentsIdByTabId.keys()) { + this.unregisterGuest(browserTabId) + } + this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() + // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. + for (const cleanup of this.policyCleanupByGuestId.values()) { + cleanup() + } + this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() + this.tabIdByWebContentsId.clear() + this.popupOwnerContextByGuestId.clear() + this.pageInitiatedTabBudgetByRootGuestId.clear() + this.worktreeIdByTabId.clear() + this.sessionProfileIdByPageId.clear() + this.userAgentModeByPageId.clear() + this.viewportUaOverrideMobileByTabId.clear() + this.viewportPresetActiveByTabId.clear() + this.viewportScrollStateByTabId.clear() + this.authUserAgentOverrideStateByGuestId.clear() + this.pendingNavigationByGuestId.clear() + this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() + this.pendingPermissionEventsByGuestId.clear() + this.pendingPopupEventsByGuestId.clear() + this.pendingDownloadIdsByGuestId.clear() + this.mouseWheelZoomCleanupByTabId.clear() + this.annotationViewportBridgeOpsByTabId.clear() + } +} diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts new file mode 100644 index 00000000000..bc65cc3d2dc --- /dev/null +++ b/src/main/browser/browser-manager-state.ts @@ -0,0 +1,289 @@ +import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { BrowserGrabSessionController } from './browser-grab-session-controller' +import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { + createPageInitiatedTabBudget, + type PageInitiatedTabBudget +} from './browser-page-initiated-tab-budget' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { + BrowserLoadError, + BrowserSessionUserAgentMode +} from '../../shared/browser-workspace-types' +import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' +import type { + ActiveDownload, + AuthUserAgentOverrideState, + PendingMainFrameNavigation, + PendingPermissionEvent, + PendingPopupEvent, + BrowserGuestPolicy, + BrowserManagerLoadError, + PopupOwnerContext +} from './browser-manager-types' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent +} from '../../shared/browser-guest-events' +import type { BrowserGrabCancelReason } from '../../shared/browser-grab-types' +import { BrowserManagerViewportScrollState } from './browser-manager-viewport-scroll-state' + +export abstract class BrowserManagerState extends BrowserManagerViewportScrollState { + protected abstract attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext?: PopupOwnerContext | null, + policy?: BrowserGuestPolicy + ): void + + protected abstract forwardOrQueuePopupEvent( + guestWebContentsId: number, + event: PendingPopupEvent + ): void + + protected abstract cancelPendingDownloadsForGuest(guestWebContentsId: number): void + + protected abstract cleanupGuestPolicyAttachment(guestWebContentsId: number): void + protected abstract notifyBrowserGuestStateChanged(webContentsId: number): void + protected abstract buildLoadError( + code: number, + description: string, + rawUrl: string + ): BrowserLoadError + protected abstract forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: BrowserManagerLoadError + ): void + protected abstract forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void + protected abstract flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingPermissionEvents( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingDownloadRequests( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract setupContextMenu(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupShortcutForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract setupMouseWheelZoomForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void + protected abstract hasActiveGrabOp(browserTabId: string): boolean + protected abstract unregisterGuest(browserTabId: string): void + protected abstract cancelDownloadInternal(downloadId: string, reason: string): void + protected abstract bindDownloadToTab(downloadId: string, browserTabId: string): void + protected abstract flushDownloadSnapshot(downloadId: string): void + protected abstract sendDownloadStarted(downloadId: string): void + protected abstract sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void + protected abstract sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void + protected abstract settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise + protected abstract finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void + protected abstract getDownloadReceivedBytes(item: Electron.DownloadItem): number + protected abstract openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean + + protected settingsResolver: + | (() => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + }) + | null = null + protected readonly webContentsIdByTabId = new Map() + // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. + protected readonly tabIdByWebContentsId = new Map() + protected readonly popupOwnerContextByGuestId = new Map() + // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. + protected readonly pageInitiatedTabBudgetByRootGuestId = new Map() + // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. + protected readonly workspaceIdByPageId = new Map() + protected readonly sessionProfileIdByPageId = new Map() + protected readonly userAgentModeByPageId = new Map() + // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. + protected readonly viewportOpsByTabId = new Map>() + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. + protected readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations + // ordered without claiming a failed write was installed. + protected readonly authUserAgentOverrideStateByGuestId = new Map< + number, + AuthUserAgentOverrideState + >() + // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() + // still reports the outgoing page until then. See resolveTabNavigationUrl. + protected readonly pendingNavigationByGuestId = new Map() + protected readonly contextMenuCleanupByTabId = new Map void>() + protected readonly grabShortcutCleanupByTabId = new Map void>() + protected readonly shortcutForwardingCleanupByTabId = new Map void>() + protected readonly mouseWheelZoomCleanupByTabId = new Map void>() + protected readonly annotationViewportBridgeOpsByTabId = new Map>() + protected readonly worktreeIdByTabId = new Map() + protected readonly policyAttachedGuestIds = new Set() + protected readonly offscreenGuestIds = new Set() + protected readonly policyCleanupByGuestId = new Map void>() + protected readonly clickedLinkFrameNameByGuestId = new Map() + protected readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. + protected readonly clearedLoadErrorsByGuestId = new Map() + protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + protected certificateTrustController: BrowserCertificateTrustController | null = null + protected shouldForwardDictationShortcut: (() => boolean) | null = null + protected readonly pendingLoadFailuresByGuestId = new Map< + number, + { code: number; description: string; validatedUrl: string } + >() + protected readonly pendingPermissionEventsByGuestId = new Map() + protected readonly pendingPopupEventsByGuestId = new Map() + protected readonly pendingDownloadIdsByGuestId = new Map() + protected readonly downloadsById = new Map() + protected readonly grabSessionController = new BrowserGrabSessionController() + + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + + setSettingsResolver( + resolver: () => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + } + ): void { + this.settingsResolver = resolver + } + + // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. + protected injectAntiDetection(guest: Electron.WebContents): () => void { + let disposed = false + let reattachTimer: ReturnType | null = null + + const attach = (): void => { + if (disposed || guest.isDestroyed()) { + return + } + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + void guest.debugger + .sendCommand('Page.enable', {}) + .then(() => + guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { + source: ANTI_DETECTION_SCRIPT + }) + ) + .catch(() => {}) + } catch { + /* best-effort — debugger may be unavailable */ + } + } + + // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. + const onDetach = (): void => { + this.authUserAgentOverrideStateByGuestId.delete(guest.id) + if (!disposed && !guest.isDestroyed() && reattachTimer === null) { + reattachTimer = setTimeout(() => { + reattachTimer = null + attach() + }, 500) + } + } + + try { + attach() + guest.debugger.on('detach', onDetach) + } catch { + /* best-effort */ + } + + return () => { + disposed = true + if (reattachTimer !== null) { + clearTimeout(reattachTimer) + reattachTimer = null + } + try { + guest.debugger.off('detach', onDetach) + } catch { + /* guest may already be destroyed */ + } + } + } + + protected resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { + return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null + } + + protected resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (browserTabId) { + return { browserTabId, rootGuestWebContentsId: guestWebContentsId } + } + // Route popups live in an Orca-built window, so they never pass through did-create-window and + // have no inherited context; their owning page comes from the route popup registry instead. + const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) + if (routeOpenerWebContentsId !== null) { + const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) + return openerTabId + ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } + : null + } + const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) + if ( + inherited && + this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId + ) { + return inherited + } + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + return null + } + + /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ + protected tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { + let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) + if (!budget) { + budget = createPageInitiatedTabBudget() + this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) + } + return budget.tryConsume(Date.now()) + } +} diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts new file mode 100644 index 00000000000..a1b832a65bc --- /dev/null +++ b/src/main/browser/browser-manager-types.ts @@ -0,0 +1,238 @@ +import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import type { + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot +} from '../../shared/browser-grab-types' +import type { BrowserClientDownloadRoute } from './browser-client-download-relay' +import type { PageInitiatedTabBudget } from './browser-page-initiated-tab-budget' +import type { + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride +} from '../../shared/browser-workspace-types' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import type { KeybindingOverrides } from '../../shared/keybindings' + +export const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 + +export function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + +export function resolveWithTimeout( + promise: Promise, + timeoutMs: number, + fallbackValue: T +): Promise<{ value: T; timedOut: boolean }> { + let timeoutId: ReturnType | null = null + const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { + timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) + }) + return Promise.race([ + promise.then((value) => ({ value, timedOut: false })), + timeoutPromise + ]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) +} + +export function releaseAutomationVisibilityToken( + renderer: Electron.WebContents, + token: string +): void { + if (renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.release !== 'function') return false; + return bridge.release(${JSON.stringify(token)}); + })()` + ) + .catch(() => {}) +} + +export function cleanupLateAutomationVisibilityToken( + renderer: Electron.WebContents, + acquirePromise: Promise +): void { + acquirePromise + .then((lateToken) => { + if (typeof lateToken !== 'string' || lateToken.length === 0) { + return + } + // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. + releaseAutomationVisibilityToken(renderer, lateToken) + }) + .catch(() => {}) +} + +export function createNoopRestoreForTimedOutAutomationAcquire( + renderer: Electron.WebContents, + acquirePromise: Promise, + timedOut: boolean +): () => void { + if (timedOut) { + cleanupLateAutomationVisibilityToken(renderer, acquirePromise) + } + return () => {} +} + +export function isAutomationVisibilityToken(token: unknown): token is string { + return typeof token === 'string' && token.length > 0 +} + +export type BrowserGuestRegistration = { + browserPageId?: string + browserTabId?: string + workspaceId?: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + rendererWebContentsId: number +} + +export type PendingPermissionEvent = Omit +export type PendingPopupEvent = Omit +export type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' +export type PopupOwnerContext = { + browserTabId: string + rootGuestWebContentsId: number +} + +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } + +export const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } + +export type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} + +export type AuthUserAgentOverrideOperation = { + sequence: number + userAgent: string +} + +export type AuthUserAgentOverrideState = { + confirmed: AuthUserAgentOverrideOperation | null + nextSequence: number + pending: AuthUserAgentOverrideOperation[] +} + +export const SAFE_POPUP_WINDOW_OPTIONS = { + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false, + // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false + } +} satisfies Electron.BrowserWindowConstructorOptions + +export type ActiveDownload = { + downloadId: string + guestWebContentsId: number + browserTabId: string | null + rendererWebContentsId: number | null + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + item: Electron.DownloadItem + savePath: string + reservationKey: string | null + clientRoute: BrowserClientDownloadRoute | null + remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] + receivedBytes: number + transientState: BrowserDownloadProgressEvent['state'] + terminalEvent: BrowserDownloadFinishedEvent | null + startedSent: boolean + cleanup: (() => void) | null +} + +export function safeOrigin(rawUrl: string): string { + const external = normalizeExternalBrowserUrl(rawUrl) + const urlToParse = external ?? rawUrl + try { + return new URL(urlToParse).origin + } catch { + return external ?? 'unknown' + } +} + +export type BrowserManagerSettings = { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean +} + +export type BrowserManagerLoadError = Pick< + BrowserLoadError, + 'code' | 'description' | 'validatedUrl' +> + +export type BrowserManagerGrabTypes = { + cancelReason: BrowserGrabCancelReason + payload: BrowserGrabPayload + rect: BrowserGrabRect + result: BrowserGrabResult + screenshot: BrowserGrabScreenshot +} + +export type { + BrowserAnnotationViewportBridgeOptions, + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride, + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent, + BrowserClientDownloadRoute, + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot, + KeybindingOverrides, + PageInitiatedTabBudget +} diff --git a/src/main/browser/browser-manager-viewport-scroll-state.ts b/src/main/browser/browser-manager-viewport-scroll-state.ts new file mode 100644 index 00000000000..1f83534c856 --- /dev/null +++ b/src/main/browser/browser-manager-viewport-scroll-state.ts @@ -0,0 +1,81 @@ +import { webContents } from 'electron' +import type { BrowserViewportScrollState } from '../../shared/browser-workspace-types' + +/** + * Renderer routing plus the host-side viewport-preset geometry the wheel path needs to decide + * whether a scroll belongs to the emulated viewport or to the guest page. + */ +export abstract class BrowserManagerViewportScrollState { + protected readonly rendererWebContentsIdByTabId = new Map() + // Why: host-side wheel panning follows the requested local viewport on the owning guest; + // replacement guests must not inherit a retired guest's state. + protected readonly viewportPresetActiveByTabId = new Map< + string, + { guestWebContentsId: number; active: boolean } + >() + protected readonly viewportScrollStateByTabId = new Map() + + setViewportScrollState( + browserTabId: string, + rendererWebContentsId: number, + state: BrowserViewportScrollState + ): void { + if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) { + return + } + if ( + ![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every( + (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + ) + ) { + return + } + this.viewportScrollStateByTabId.set(browserTabId, state) + } + + recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void { + const state = this.viewportScrollStateByTabId.get(browserTabId) + if (!state) { + return + } + this.viewportScrollStateByTabId.set(browserTabId, { + ...state, + scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)), + scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY)) + }) + } + + protected canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean { + const state = this.viewportScrollStateByTabId.get(browserTabId) + if (!state) { + return false + } + const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0 + const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0 + const canScrollAxis = (delta: number, position: number, maximum: number): boolean => { + if (delta < 0) { + return position > 0 + } + if (delta > 0) { + return position < maximum + } + return false + } + return ( + canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) || + canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop) + ) + } + + protected resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { + const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWebContentsId) { + return null + } + const renderer = webContents.fromId(rendererWebContentsId) + if (!renderer || renderer.isDestroyed()) { + return null + } + return renderer + } +} diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts new file mode 100644 index 00000000000..1e79e760942 --- /dev/null +++ b/src/main/browser/browser-manager-viewport.ts @@ -0,0 +1,219 @@ +import { webContents } from 'electron' +import { + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + buildBrowserAnnotationViewportBridgeScript, + type BrowserAnnotationViewportBridgeOptions +} from '../../shared/browser-annotation-viewport-bridge' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' + +export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { + // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. + async openDevTools(browserTabId: string): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + // Offscreen guests have no visible window on this desktop; detaching DevTools would open it + // on the host display with no route back to the remote client. + if (this.offscreenGuestIds.has(webContentsId)) { + return false + } + guest.openDevTools({ mode: 'detach' }) + return true + } + + // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. + async setViewportOverride( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. + const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (expectedWebContentsId !== undefined) { + // Keep host panning available while CDP applies the requested dimensions. The guest id fence + // prevents this intent from leaking to a replacement guest; clearing the preset removes it. + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: expectedWebContentsId, + active: override !== null + }) + } + // The renderer resizes the host before CDP completes; discard the old geometry until it + // reports the new pane bounds so a pending preset cannot route wheel input using stale limits. + this.viewportScrollStateByTabId.delete(browserTabId) + const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId)) + this.viewportOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. + if (this.viewportOpsByTabId.get(browserTabId) === next) { + this.viewportOpsByTabId.delete(browserTabId) + } + } + } + + async setAnnotationViewportBridge( + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) + this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + } + } + + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. + protected async doSetAnnotationViewportBridgeImpl( + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() + if (!guest || guest.isDestroyed()) { + return false + } + + try { + // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. + await guest.executeJavaScriptInIsolatedWorld( + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], + false + ) + return true + } catch { + return false + } + } + + protected async doSetViewportOverrideImpl( + browserTabId: string, + override: BrowserViewportOverride | null, + expectedWebContentsId: number | undefined + ): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId || webContentsId !== expectedWebContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + } catch (err) { + // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. + console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { + browserTabId, + webContentsId, + error: err instanceof Error ? err.message : String(err) + }) + return false + } + + const dbg = guest.debugger + try { + if (override) { + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: override.width, + height: override.height, + deviceScaleFactor: override.deviceScaleFactor, + mobile: override.mobile + }) + if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: webContentsId, + active: true + }) + } + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: override.mobile, + maxTouchPoints: override.mobile ? 5 : 0 + }) + // Why: viewport sizing must not override a profile's explicit native-UA identity. + if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { + // Navigation must see the preset intent while the final CDP command is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) + } + } else { + await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) + if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: webContentsId, + active: false + }) + } + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: false, + maxTouchPoints: 0 + }) + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + try { + if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { + const url = this.resolveTabNavigationUrl(guest) + const restored = await this.applyAuthUserAgentOverrideOverCdp( + guest, + false, + url, + isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() + ) + if (!restored) { + throw new Error('Failed to preserve auth user agent') + } + } else { + // Why: passing an empty string restores the session default UA. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } + } + if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { + return false + } + return true + } catch { + return false + } + } +} diff --git a/src/main/browser/browser-manager-visibility.ts b/src/main/browser/browser-manager-visibility.ts new file mode 100644 index 00000000000..1da2a75638d --- /dev/null +++ b/src/main/browser/browser-manager-visibility.ts @@ -0,0 +1,256 @@ +import { + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + createNoopRestoreForTimedOutAutomationAcquire, + isAutomationVisibilityToken, + releaseAutomationVisibilityToken, + resolveWithTimeout +} from './browser-manager-types' +import { BrowserManagerState } from './browser-manager-state' + +export abstract class BrowserManagerVisibility extends BrowserManagerState { + // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. + async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId + const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + const prev = await renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return null; + var state = store.getState(); + var prevTabType = state.activeTabType; + var prevActiveWorktreeId = state.activeWorktreeId || null; + var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; + var prevActiveBrowserPageId = null; + var prevFocusedGroupTabId = null; + var targetWorktreeId = ${JSON.stringify(worktreeId)}; + var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; + var browserPageId = ${JSON.stringify(browserPageId)}; + var browserTabsByWorktree = state.browserTabsByWorktree || {}; + + if (prevActiveWorktreeId) { + var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; + var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; + for (var pg = 0; pg < prevGroups.length; pg++) { + if (prevGroups[pg].id === prevFocusedGroupId) { + prevFocusedGroupTabId = prevGroups[pg].activeTabId; + break; + } + } + } + + if (prevActiveBrowserWorkspaceId) { + for (var prevWtId in browserTabsByWorktree) { + var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; + for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { + if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { + prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; + break; + } + } + if (prevActiveBrowserPageId) break; + } + } + + if ( + targetWorktreeId && + prevActiveWorktreeId !== targetWorktreeId && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(targetWorktreeId); + state = store.getState(); + } + + var foundWorkspace = null; + for (var wtId in browserTabsByWorktree) { + var tabs = browserTabsByWorktree[wtId] || []; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id === browserWorkspaceId) { + foundWorkspace = tabs[i]; + if (!targetWorktreeId) { + targetWorktreeId = wtId; + } + break; + } + } + if (foundWorkspace) break; + } + + var hasTargetPage = false; + var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; + for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { + if (targetPages[pageIndex].id === browserPageId) { + hasTargetPage = true; + break; + } + } + + if (foundWorkspace) { + if (typeof state.setActiveBrowserTab === 'function') { + state.setActiveBrowserTab(browserWorkspaceId); + state = store.getState(); + } else { + var allTabs = state.unifiedTabsByWorktree || {}; + var found = null; + for (var unifiedWtId in allTabs) { + var unifiedTabs = allTabs[unifiedWtId] || []; + for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { + if ( + unifiedTabs[unifiedIndex].contentType === 'browser' && + unifiedTabs[unifiedIndex].entityId === browserWorkspaceId + ) { + found = unifiedTabs[unifiedIndex]; + break; + } + } + if (found) break; + } + if (found) { + state.activateTab(found.id); + } + state.setActiveTabType('browser'); + state = store.getState(); + } + // Why: activating the workspace alone is not enough for screenshot + // capture when a browser workspace contains multiple pages. The + // compositor only paints the currently mounted page guest. + if ( + hasTargetPage && + foundWorkspace.activePageId !== browserPageId && + typeof state.setActiveBrowserPage === 'function' + ) { + state.setActiveBrowserPage(browserWorkspaceId, browserPageId); + state = store.getState(); + } + } + + return { + prevTabType: prevTabType, + prevActiveWorktreeId: prevActiveWorktreeId, + prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, + prevActiveBrowserPageId: prevActiveBrowserPageId, + prevFocusedGroupTabId: prevFocusedGroupTabId, + targetWorktreeId: targetWorktreeId, + targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, + targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null + }; + })()` + ) + .catch(() => null) + + const needsRestore = + prev && + (prev.prevTabType !== 'browser' || + prev.prevActiveWorktreeId !== prev.targetWorktreeId || + prev.prevFocusedGroupTabId !== null || + prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || + prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) + + if (!needsRestore) { + return () => {} + } + + return () => { + if (!prev || !renderer || renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return; + var state = store.getState(); + if ( + ${JSON.stringify(prev?.prevActiveWorktreeId)} && + ${JSON.stringify(prev?.prevActiveWorktreeId)} !== + ${JSON.stringify(prev?.targetWorktreeId)} && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== + ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && + typeof state.setActiveBrowserTab === 'function' + ) { + state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== + ${JSON.stringify(prev?.targetBrowserPageId)} && + typeof state.setActiveBrowserPage === 'function' + ) { + // Why: Orca remembers the last browser workspace/page even when + // the user is currently in terminal/editor view. Screenshot prep + // temporarily switches that hidden browser selection state, so + // restore it independently of the visible tab type. + state.setActiveBrowserPage( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, + ${JSON.stringify(prev?.prevActiveBrowserPageId)} + ); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevTabType)} !== 'browser' && + ${JSON.stringify(prev?.prevFocusedGroupTabId)} + ) { + state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); + } + if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { + state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); + } + })()` + ) + .catch(() => {}) + } + } + + async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. + const acquirePromise = renderer + .executeJavaScript( + `(async function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.acquire !== 'function') return null; + return await bridge.acquire(${JSON.stringify(browserPageId)}); + })()` + ) + .catch(() => null) + const { value: token, timedOut } = await resolveWithTimeout( + acquirePromise, + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + null + ) + + if (!isAutomationVisibilityToken(token)) { + return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) + } + + return () => { + releaseAutomationVisibilityToken(renderer, token) + } + } +} diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 5764b45e5e8..bdac69dd906 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1,2705 +1,14 @@ -/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */ -import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { BrowserManagerFinal } from './browser-manager-final' -import { shell, webContents } from 'electron' -import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl, - redactKagiSessionToken, - toSecureCertificateEndpoint -} from '../../shared/browser-url' -import type { - BrowserDownloadFinishedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadRequestedEvent, - BrowserPermissionDeniedEvent, - BrowserPopupEvent -} from '../../shared/browser-guest-events' -import type { - BrowserGrabCancelReason, - BrowserGrabPayload, - BrowserGrabRect, - BrowserGrabResult, - BrowserGrabScreenshot -} from '../../shared/browser-grab-types' -import { buildGuestOverlayScript } from './grab-guest-script' -import { clampGrabPayload } from './browser-grab-payload' -import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' -import { BrowserGrabSessionController } from './browser-grab-session-controller' -import { browserDownloadDestinationReservations } from './browser-download-destination' -import type { BrowserClientDownloadRoute } from './browser-client-download-relay' -import { routeBrowserClientDownload } from './browser-client-download-routing' -import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' -import { resolveRendererWebContents } from './browser-guest-renderer-target' -import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' -import { setupGuestContextMenu } from './browser-guest-context-menu' -import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' -import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' -import { ANTI_DETECTION_SCRIPT } from './anti-detection' -import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' -import { - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - buildBrowserClickedLinkRoutingScript, - buildBrowserIframeClickedLinkRoutingScript -} from './browser-clicked-link-routing' -import { - createPageInitiatedTabBudget, - type PageInitiatedTabBudget -} from './browser-page-initiated-tab-budget' -import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' -import { cleanElectronUserAgent } from './browser-session-ua' -import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' -import type { - BrowserCertificateFailure, - BrowserLoadError, - BrowserSessionUserAgentMode, - BrowserViewportOverride, - BrowserViewportScrollState -} from '../../shared/browser-workspace-types' -import { - type BrowserAnnotationViewportBridgeOptions, - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - buildBrowserAnnotationViewportBridgeScript -} from '../../shared/browser-annotation-viewport-bridge' -import { - getWorkspaceDocPageGuest, - installDocPreviewGuestPolicy, - isWorkspaceDocPageId -} from './doc-preview-guest-policy' -import type { KeybindingOverrides } from '../../shared/keybindings' -import { - BrowserCertificateTrustController, - type ManagedBrowserGuestContext -} from './browser-certificate-trust-controller' +export type { BrowserGuestPolicy, BrowserGuestRegistration } from './browser-manager-types' -const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 - -function isChromiumInternalErrorUrl(url: string): boolean { - return url.startsWith('chrome-error://') -} - -function resolveWithTimeout( - promise: Promise, - timeoutMs: number, - fallbackValue: T -): Promise<{ value: T; timedOut: boolean }> { - let timeoutId: ReturnType | null = null - const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { - timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) - }) - return Promise.race([ - promise.then((value) => ({ value, timedOut: false })), - timeoutPromise - ]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId) - } - }) -} - -function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void { - if (renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.release !== 'function') return false; - return bridge.release(${JSON.stringify(token)}); - })()` - ) - .catch(() => {}) -} - -function cleanupLateAutomationVisibilityToken( - renderer: Electron.WebContents, - acquirePromise: Promise -): void { - acquirePromise - .then((lateToken) => { - if (typeof lateToken !== 'string' || lateToken.length === 0) { - return - } - // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. - releaseAutomationVisibilityToken(renderer, lateToken) - }) - .catch(() => {}) -} - -function createNoopRestoreForTimedOutAutomationAcquire( - renderer: Electron.WebContents, - acquirePromise: Promise, - timedOut: boolean -): () => void { - if (timedOut) { - cleanupLateAutomationVisibilityToken(renderer, acquirePromise) - } - return () => {} -} - -function isAutomationVisibilityToken(token: unknown): token is string { - return typeof token === 'string' && token.length > 0 -} - -export type BrowserGuestRegistration = { - browserPageId?: string - browserTabId?: string - workspaceId?: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - rendererWebContentsId: number -} - -type PendingPermissionEvent = Omit -type PendingPopupEvent = Omit -type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' -type PopupOwnerContext = { - browserTabId: string - rootGuestWebContentsId: number -} /** - * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and - * anti-detection all apply. A workspace-document guest renders one granted document and gets none - * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + * Privileged browser guest facade. Behavior is organized by lifecycle concern in the focused + * manager modules while this module keeps the stable import surface used by main and renderer code. */ -export type BrowserGuestPolicy = - | { profile: 'browsing' } - | { profile: 'workspace-doc'; host: Electron.WebContents } -const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } -type PendingMainFrameNavigation = { - currentUrl: string - supersededUrls: string[] -} -type AuthUserAgentOverrideOperation = { - sequence: number - userAgent: string -} -type AuthUserAgentOverrideState = { - confirmed: AuthUserAgentOverrideOperation | null - nextSequence: number - pending: AuthUserAgentOverrideOperation[] -} -const SAFE_POPUP_WINDOW_OPTIONS = { - alwaysOnTop: false, - closable: true, - focusable: true, - frame: true, - fullscreen: false, - kiosk: false, - modal: false, - movable: true, - opacity: 1, - show: true, - simpleFullscreen: false, - skipTaskbar: false, - titleBarStyle: 'default', - transparent: false, - // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. - webPreferences: { - allowRunningInsecureContent: false, - contextIsolation: true, - nodeIntegration: false, - nodeIntegrationInSubFrames: false, - sandbox: true, - webviewTag: false - } -} satisfies Electron.BrowserWindowConstructorOptions - -type ActiveDownload = { - downloadId: string - guestWebContentsId: number - browserTabId: string | null - rendererWebContentsId: number | null - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - item: Electron.DownloadItem - savePath: string - reservationKey: string | null - clientRoute: BrowserClientDownloadRoute | null - remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] - receivedBytes: number - transientState: BrowserDownloadProgressEvent['state'] - terminalEvent: BrowserDownloadFinishedEvent | null - startedSent: boolean - cleanup: (() => void) | null -} - -function safeOrigin(rawUrl: string): string { - const external = normalizeExternalBrowserUrl(rawUrl) - const urlToParse = external ?? rawUrl - try { - return new URL(urlToParse).origin - } catch { - return external ?? 'unknown' - } -} - -export class BrowserManager { - private settingsResolver: - | (() => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - }) - | null = null - private readonly webContentsIdByTabId = new Map() - // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. - private readonly tabIdByWebContentsId = new Map() - private readonly popupOwnerContextByGuestId = new Map() - // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. - private readonly pageInitiatedTabBudgetByRootGuestId = new Map() - // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. - private readonly workspaceIdByPageId = new Map() - private readonly sessionProfileIdByPageId = new Map() - private readonly userAgentModeByPageId = new Map() - private readonly rendererWebContentsIdByTabId = new Map() - // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. - private readonly viewportOpsByTabId = new Map>() - // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation - // can re-issue it against the target URL's identity. - private readonly viewportUaOverrideMobileByTabId = new Map() - // Why: host-side wheel panning follows the requested local viewport on the owning guest; - // replacement guests must not inherit a retired guest's state. - private readonly viewportPresetActiveByTabId = new Map< - string, - { guestWebContentsId: number; active: boolean } - >() - private readonly viewportScrollStateByTabId = new Map() - // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations - // ordered without claiming a failed write was installed. - private readonly authUserAgentOverrideStateByGuestId = new Map< - number, - AuthUserAgentOverrideState - >() - // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() - // still reports the outgoing page until then. See resolveTabNavigationUrl. - private readonly pendingNavigationByGuestId = new Map() - private readonly contextMenuCleanupByTabId = new Map void>() - private readonly grabShortcutCleanupByTabId = new Map void>() - private readonly shortcutForwardingCleanupByTabId = new Map void>() - private readonly mouseWheelZoomCleanupByTabId = new Map void>() - private readonly annotationViewportBridgeOpsByTabId = new Map>() - private readonly worktreeIdByTabId = new Map() - private readonly policyAttachedGuestIds = new Set() - private readonly offscreenGuestIds = new Set() - private readonly policyCleanupByGuestId = new Map void>() - private readonly clickedLinkFrameNameByGuestId = new Map() - private readonly loadErrorsByGuestId = new Map() - // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. - private readonly clearedLoadErrorsByGuestId = new Map() - private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null - private certificateTrustController: BrowserCertificateTrustController | null = null - private shouldForwardDictationShortcut: (() => boolean) | null = null - private readonly pendingLoadFailuresByGuestId = new Map< - number, - { code: number; description: string; validatedUrl: string } - >() - private readonly pendingPermissionEventsByGuestId = new Map() - private readonly pendingPopupEventsByGuestId = new Map() - private readonly pendingDownloadIdsByGuestId = new Map() - private readonly downloadsById = new Map() - private readonly grabSessionController = new BrowserGrabSessionController() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } - - setViewportScrollState( - browserTabId: string, - rendererWebContentsId: number, - state: BrowserViewportScrollState - ): void { - if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) { - return - } - if ( - ![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every( - (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 - ) - ) { - return - } - this.viewportScrollStateByTabId.set(browserTabId, state) - } - - recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void { - const state = this.viewportScrollStateByTabId.get(browserTabId) - if (!state) { - return - } - this.viewportScrollStateByTabId.set(browserTabId, { - ...state, - scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)), - scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY)) - }) - } - - setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { - this.browserGuestStateChangedListener = listener - } - - setCertificateTrustController(controller: BrowserCertificateTrustController): void { - this.certificateTrustController = controller - } - - installCertificateRequestGuard(session: Electron.Session): void { - this.certificateTrustController?.installSessionRequestGuard(session) - } - - removeCertificateRequestGuard(session: Electron.Session): void { - this.certificateTrustController?.removeSessionRequestGuard(session) - } - - setSettingsResolver( - resolver: () => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - } - ): void { - this.settingsResolver = resolver - } - - // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. - private injectAntiDetection(guest: Electron.WebContents): () => void { - let disposed = false - let reattachTimer: ReturnType | null = null - - const attach = (): void => { - if (disposed || guest.isDestroyed()) { - return - } - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - void guest.debugger - .sendCommand('Page.enable', {}) - .then(() => - guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { - source: ANTI_DETECTION_SCRIPT - }) - ) - .catch(() => {}) - } catch { - /* best-effort — debugger may be unavailable */ - } - } - - // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. - const onDetach = (): void => { - this.authUserAgentOverrideStateByGuestId.delete(guest.id) - if (!disposed && !guest.isDestroyed() && reattachTimer === null) { - reattachTimer = setTimeout(() => { - reattachTimer = null - attach() - }, 500) - } - } - - try { - attach() - guest.debugger.on('detach', onDetach) - } catch { - /* best-effort */ - } - - return () => { - disposed = true - if (reattachTimer !== null) { - clearTimeout(reattachTimer) - reattachTimer = null - } - try { - guest.debugger.off('detach', onDetach) - } catch { - /* guest may already be destroyed */ - } - } - } - - private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { - return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null - } - - private resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (browserTabId) { - return { browserTabId, rootGuestWebContentsId: guestWebContentsId } - } - // Route popups live in an Orca-built window, so they never pass through did-create-window and - // have no inherited context; their owning page comes from the route popup registry instead. - const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) - if (routeOpenerWebContentsId !== null) { - const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) - return openerTabId - ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } - : null - } - const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) - if ( - inherited && - this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId - ) { - return inherited - } - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - return null - } - - /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ - private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { - let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) - if (!budget) { - budget = createPageInitiatedTabBudget() - this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) - } - return budget.tryConsume(Date.now()) - } - - private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { - const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) - if (!rendererWebContentsId) { - return null - } - const renderer = webContents.fromId(rendererWebContentsId) - if (!renderer || renderer.isDestroyed()) { - return null - } - return renderer - } - - // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. - async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId - const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - const prev = await renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return null; - var state = store.getState(); - var prevTabType = state.activeTabType; - var prevActiveWorktreeId = state.activeWorktreeId || null; - var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; - var prevActiveBrowserPageId = null; - var prevFocusedGroupTabId = null; - var targetWorktreeId = ${JSON.stringify(worktreeId)}; - var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; - var browserPageId = ${JSON.stringify(browserPageId)}; - var browserTabsByWorktree = state.browserTabsByWorktree || {}; - - if (prevActiveWorktreeId) { - var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; - var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; - for (var pg = 0; pg < prevGroups.length; pg++) { - if (prevGroups[pg].id === prevFocusedGroupId) { - prevFocusedGroupTabId = prevGroups[pg].activeTabId; - break; - } - } - } - - if (prevActiveBrowserWorkspaceId) { - for (var prevWtId in browserTabsByWorktree) { - var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; - for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { - if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { - prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; - break; - } - } - if (prevActiveBrowserPageId) break; - } - } - - if ( - targetWorktreeId && - prevActiveWorktreeId !== targetWorktreeId && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(targetWorktreeId); - state = store.getState(); - } - - var foundWorkspace = null; - for (var wtId in browserTabsByWorktree) { - var tabs = browserTabsByWorktree[wtId] || []; - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].id === browserWorkspaceId) { - foundWorkspace = tabs[i]; - if (!targetWorktreeId) { - targetWorktreeId = wtId; - } - break; - } - } - if (foundWorkspace) break; - } - - var hasTargetPage = false; - var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; - for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { - if (targetPages[pageIndex].id === browserPageId) { - hasTargetPage = true; - break; - } - } - - if (foundWorkspace) { - if (typeof state.setActiveBrowserTab === 'function') { - state.setActiveBrowserTab(browserWorkspaceId); - state = store.getState(); - } else { - var allTabs = state.unifiedTabsByWorktree || {}; - var found = null; - for (var unifiedWtId in allTabs) { - var unifiedTabs = allTabs[unifiedWtId] || []; - for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { - if ( - unifiedTabs[unifiedIndex].contentType === 'browser' && - unifiedTabs[unifiedIndex].entityId === browserWorkspaceId - ) { - found = unifiedTabs[unifiedIndex]; - break; - } - } - if (found) break; - } - if (found) { - state.activateTab(found.id); - } - state.setActiveTabType('browser'); - state = store.getState(); - } - // Why: activating the workspace alone is not enough for screenshot - // capture when a browser workspace contains multiple pages. The - // compositor only paints the currently mounted page guest. - if ( - hasTargetPage && - foundWorkspace.activePageId !== browserPageId && - typeof state.setActiveBrowserPage === 'function' - ) { - state.setActiveBrowserPage(browserWorkspaceId, browserPageId); - state = store.getState(); - } - } - - return { - prevTabType: prevTabType, - prevActiveWorktreeId: prevActiveWorktreeId, - prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, - prevActiveBrowserPageId: prevActiveBrowserPageId, - prevFocusedGroupTabId: prevFocusedGroupTabId, - targetWorktreeId: targetWorktreeId, - targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, - targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null - }; - })()` - ) - .catch(() => null) - - const needsRestore = - prev && - (prev.prevTabType !== 'browser' || - prev.prevActiveWorktreeId !== prev.targetWorktreeId || - prev.prevFocusedGroupTabId !== null || - prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || - prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) - - if (!needsRestore) { - return () => {} - } - - return () => { - if (!prev || !renderer || renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return; - var state = store.getState(); - if ( - ${JSON.stringify(prev?.prevActiveWorktreeId)} && - ${JSON.stringify(prev?.prevActiveWorktreeId)} !== - ${JSON.stringify(prev?.targetWorktreeId)} && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== - ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && - typeof state.setActiveBrowserTab === 'function' - ) { - state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== - ${JSON.stringify(prev?.targetBrowserPageId)} && - typeof state.setActiveBrowserPage === 'function' - ) { - // Why: Orca remembers the last browser workspace/page even when - // the user is currently in terminal/editor view. Screenshot prep - // temporarily switches that hidden browser selection state, so - // restore it independently of the visible tab type. - state.setActiveBrowserPage( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, - ${JSON.stringify(prev?.prevActiveBrowserPageId)} - ); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevTabType)} !== 'browser' && - ${JSON.stringify(prev?.prevFocusedGroupTabId)} - ) { - state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); - } - if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { - state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); - } - })()` - ) - .catch(() => {}) - } - } - - async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. - const acquirePromise = renderer - .executeJavaScript( - `(async function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.acquire !== 'function') return null; - return await bridge.acquire(${JSON.stringify(browserPageId)}); - })()` - ) - .catch(() => null) - const { value: token, timedOut } = await resolveWithTimeout( - acquirePromise, - AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, - null - ) - - if (!isAutomationVisibilityToken(token)) { - return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) - } - - return () => { - releaseAutomationVisibilityToken(renderer, token) - } - } - - attachGuestPolicies( - guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null, - policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY - ): void { - if (this.policyAttachedGuestIds.has(guest.id)) { - return - } - this.policyAttachedGuestIds.add(guest.id) - // Why one door with a profile rather than a second installer beside it: whether a guest was - // policy-attached at all is what registration and teardown both key on, so a guest that took - // another path into the app is invisible to both. - if (policy.profile === 'workspace-doc') { - this.attachWorkspaceDocGuestPolicies(guest, policy.host) - return - } - if (inheritedOwnerContext) { - this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) - } - // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. - const clickedLinkFrameName = inheritedOwnerContext - ? null - : `__orca_clicked_link_foreground_${randomUUID()}` - if (clickedLinkFrameName) { - this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) - } - let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) - - // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. - const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. - guest.setBackgroundThrottling(false) - const installClickedLinkRouting = (): void => { - if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { - return - } - // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. - void guest - .executeJavaScriptInIsolatedWorld( - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - [ - { - // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. - code: buildBrowserClickedLinkRoutingScript( - clickedLinkFrameName, - process.platform === 'darwin' - ) - } - ], - false - ) - .catch(() => {}) - } - if (clickedLinkFrameName) { - guest.on('dom-ready', installClickedLinkRouting) - } - const pendingIframeRoutingInstalls = new Map void>() - const iframeFrameNameByFrame = new Map() - const iframeFrameByFrameName = new Map() - const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { - const name = iframeFrameNameByFrame.get(frame) - if (!name) { - return - } - iframeFrameNameByFrame.delete(frame) - iframeFrameByFrameName.delete(name) - } - const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { - clearIframeFrameName(frame) - if (!clickedLinkRoutingActive || frame.isDestroyed()) { - return - } - const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` - iframeFrameNameByFrame.set(frame, name) - iframeFrameByFrameName.set(name, frame) - // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. - void frame - .executeJavaScript( - buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), - false - ) - .catch(() => { - if (iframeFrameNameByFrame.get(frame) === name) { - clearIframeFrameName(frame) - } - }) - } - const handleFrameCreated = ( - _event: Electron.Event, - { frame }: Electron.FrameCreatedDetails - ): void => { - if (!clickedLinkFrameName || !frame || frame.parent === null) { - return - } - for (const knownFrame of iframeFrameNameByFrame.keys()) { - if (knownFrame.isDestroyed()) { - clearIframeFrameName(knownFrame) - } - } - const installAfterDomReady = (): void => { - pendingIframeRoutingInstalls.delete(frame) - installIframeClickedLinkRouting(frame) - } - pendingIframeRoutingInstalls.set(frame, installAfterDomReady) - frame.once('dom-ready', installAfterDomReady) - } - if (clickedLinkFrameName) { - guest.on('frame-created', handleFrameCreated) - } - const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { - // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. - this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) - } - guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { - const ownerContext = this.resolvePopupOwnerContext(guest.id) - const browserTabId = ownerContext?.browserTabId ?? null - const browserUrl = normalizeBrowserNavigationUrl(url) - const externalUrl = normalizeExternalBrowserUrl(url) - const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) - const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined - let isClickedLink = Boolean( - expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName - ) - if (!isClickedLink && iframeFrame) { - isClickedLink = true - clearIframeFrameName(iframeFrame) - queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) - } - - if (isClickedLink) { - if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(browserUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. - return { action: 'deny' } - } - - // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is - // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are - // excluded by isNewBrowserTabPopupIntent and still get a real child window below. - if ( - ownerContext && - externalUrl && - isNewBrowserTabPopupIntent({ frameName, disposition, features }) - ) { - // Why: one activation lets a page loop window.open, and each routed tab persists into - // workspace session state, so it survives the quit that used to clear popup windows. - if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'blocked' - }) - return { action: 'deny' } - } - if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. - return { action: 'deny' } - } - - // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. - const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) - if (browserTabId && canOpenAsChild) { - // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. - return { - action: 'allow', - overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, - // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. - createWindow: (options: PopupChildWindowOptions) => - this.createPopupChildWindowWithOriginBar(guest, url, options) - } - } else if (externalUrl) { - // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. - void shell.openExternal(redactKagiSessionToken(externalUrl)) - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-external' - }) - } else { - // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(url), - action: 'blocked' - }) - } - return { action: 'deny' } - }) - - const navigationGuard = (event: Electron.Event, url: string): boolean => { - // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. - if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return true - } - // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. - if (url.startsWith('file:')) { - event.preventDefault() - return false - } - if (!normalizeBrowserNavigationUrl(url)) { - // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. - event.preventDefault() - return false - } - return true - } - - const willRedirectHandler = ( - event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - this.updatePendingNavigationForRedirect(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) - } - - const didFailLoadHandler = ( - _event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ): void => { - if (!isMainFrame) { - return - } - // Why: a nav that never committed must not leave its target standing as the tab's host. - const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) - if (failedNavigationWasCurrent) { - // The attempted host never committed, so restore every UA layer to the document that remains. - this.applyGoogleAuthUserAgent(guest, guest.getURL()) - } - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - const certificateFailure = browserPageId - ? this.certificateTrustController?.getFailure(browserPageId) - : null - if ( - certificateFailure && - toSecureCertificateEndpoint(validatedURL || guest.getURL()) === - toSecureCertificateEndpoint(certificateFailure.origin) - ) { - // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. - return - } - if (errorCode === -3) { - // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. - const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) - if (clearedError !== undefined) { - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.loadErrorsByGuestId.set(guest.id, clearedError) - this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) - this.notifyBrowserGuestStateChanged(guest.id) - } - return - } - this.clearedLoadErrorsByGuestId.delete(guest.id) - const loadError = this.buildLoadError( - errorCode, - errorDescription || 'This site could not be reached.', - validatedURL || guest.getURL() || 'about:blank' - ) - this.loadErrorsByGuestId.set(guest.id, loadError) - this.forwardOrQueueGuestLoadFailure(guest.id, loadError) - this.notifyBrowserGuestStateChanged(guest.id) - } - - const didStartNavigationHandler = ( - _event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - // Why: getURL() still reports the previous committed URL until this navigation commits, so - // every UA writer must read the in-flight target or they disagree about the tab's host. - this.startPendingNavigation(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url) - this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) - // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. - this.pendingLoadFailuresByGuestId.delete(guest.id) - const activeError = this.loadErrorsByGuestId.get(guest.id) - if (activeError === undefined) { - // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. - this.clearedLoadErrorsByGuestId.delete(guest.id) - return - } - this.clearedLoadErrorsByGuestId.set(guest.id, activeError) - this.loadErrorsByGuestId.delete(guest.id) - this.notifyBrowserGuestStateChanged(guest.id) - } - - const didNavigateHandler = (_event: Electron.Event, url: string): void => { - // Why: once committed, getURL() reports this url, so the pending target is redundant. - this.pendingNavigationByGuestId.delete(guest.id) - // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) - } - - guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', willRedirectHandler) - guest.on('did-start-navigation', didStartNavigationHandler) - guest.on('did-navigate', didNavigateHandler) - guest.on('did-fail-load', didFailLoadHandler) - const handleDestroyed = (): void => { - // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - - // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. - this.policyCleanupByGuestId.set(guest.id, () => { - disposeAntiDetection() - try { - guest.off('destroyed', handleDestroyed) - guest.off('did-create-window', handleDidCreateWindow) - if (clickedLinkFrameName) { - clickedLinkRoutingActive = false - guest.off('dom-ready', installClickedLinkRouting) - guest.off('frame-created', handleFrameCreated) - for (const [frame, install] of pendingIframeRoutingInstalls) { - if (!frame.isDestroyed()) { - frame.off('dom-ready', install) - } - } - pendingIframeRoutingInstalls.clear() - iframeFrameNameByFrame.clear() - iframeFrameByFrameName.clear() - } - } catch { - // guest may already be destroyed - } - if (!guest.isDestroyed()) { - guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', willRedirectHandler) - guest.off('did-start-navigation', didStartNavigationHandler) - guest.off('did-navigate', didNavigateHandler) - guest.off('did-fail-load', didFailLoadHandler) - } - }) - } - - /** - * A workspace document is not the web: no popups, no link routing, no anti-detection, and no - * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is - * this method's teardown, so a retired preview drops its listeners on the same path. - */ - private attachWorkspaceDocGuestPolicies( - guest: Electron.WebContents, - host: Electron.WebContents - ): void { - const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) - const handleDestroyed = (): void => { - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - this.policyCleanupByGuestId.set(guest.id, () => { - disposeDocPolicy() - try { - guest.off('destroyed', handleDestroyed) - } catch { - // guest may already be destroyed - } - }) - } - - // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the header-level Firefox switch in setupClientHintsOverride - // must be matched here per navigation or the two layers disagree — itself a bot tell. - // Restores the session's base identity off the auth hosts. Native-UA profiles opt out - // of the whole clean-UA path, so they keep their untouched identity everywhere. - private applyGoogleAuthUserAgent( - guest: Electron.WebContents, - url: string, - options: { duringRedirect?: boolean } = {} - ): void { - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct - // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. - // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so - // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. - const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) - // Session state is authoritative before renderer registration and after a native profile imports a source UA. - const mode = - getBrowserSessionUserAgentMode(guest.session) ?? - (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) - if (mode === 'native') { - return - } - const firefoxUa = googleAuthUserAgent() - const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) - const latestPendingOverride = overrideState?.pending.at(-1) - const confirmedOverride = overrideState?.confirmed - const currentOverride = - latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) - ? latestPendingOverride - : confirmedOverride - const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() - const nextUa = isGoogleAuthUrl(url) - ? firefoxUa - : // Only restore when the auth-host override is actually in place, so normal - // navigation never touches the session UA. - currentUa === firefoxUa - ? guest.session.getUserAgent() - : null - let authOverrideIssuedOverCdp = false - if (nextUa !== null && nextUa !== currentUa) { - // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight - // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain - // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without - // touching the navigation, and it outranks the WebContents UA from then on, so a guest that - // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride - // rewrites User-Agent per request for auth-host URLs on its own. - if (options.duringRedirect === true || overrideState !== undefined) { - if (this.canOverrideUserAgentOverCdp(guest)) { - authOverrideIssuedOverCdp = true - // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers - // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off - // them, any mobile preset preserved. Writing the session UA directly would put the - // unlaundered Electron token back on the wire. - void this.applyAuthUserAgentOverrideOverCdp( - guest, - (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? - false, - url, - nextUa - ) - } - // Why: with no debugger there is no way to retarget the identity without cancelling the - // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. - } else { - guest.setUserAgent(nextUa) - } - } - // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of - // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. - if (browserPageId && !authOverrideIssuedOverCdp) { - this.reapplyViewportUserAgentOverride(guest, browserPageId, url) - } - } - - private canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { - try { - return !guest.isDestroyed() && guest.debugger.isAttached() - } catch { - return false - } - } - - private applyAuthUserAgentOverrideOverCdp( - guest: Electron.WebContents, - mobile: boolean, - url: string, - userAgent: string - ): Promise { - if (!this.canOverrideUserAgentOverCdp(guest)) { - return Promise.resolve(false) - } - const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { - confirmed: null, - nextSequence: 0, - pending: [] - } - const operation = { sequence: ++state.nextSequence, userAgent } - state.pending.push(operation) - this.authUserAgentOverrideStateByGuestId.set(guest.id, state) - return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( - () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), - () => { - this.settleAuthUserAgentOverride(guest.id, state, operation, false) - return false - } - ) - } - - private settleAuthUserAgentOverride( - guestId: number, - state: AuthUserAgentOverrideState, - operation: AuthUserAgentOverrideOperation, - succeeded: boolean - ): boolean { - if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { - return false - } - if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { - state.confirmed = operation - } - const pendingIndex = state.pending.indexOf(operation) - if (pendingIndex !== -1) { - state.pending.splice(pendingIndex, 1) - } - if (state.confirmed === null && state.pending.length === 0) { - this.authUserAgentOverrideStateByGuestId.delete(guestId) - } - return true - } - - private startPendingNavigation(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] - }) - } - - private updatePendingNavigationForRedirect(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: [] - }) - return - } - pending.currentUrl = url - } - - private failPendingNavigation(guestId: number, failedUrl: string): boolean { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - return false - } - const supersededIndex = pending.supersededUrls.indexOf(failedUrl) - if (supersededIndex !== -1) { - pending.supersededUrls.splice(supersededIndex, 1) - return false - } - if (pending.currentUrl !== failedUrl) { - return false - } - this.pendingNavigationByGuestId.delete(guestId) - return true - } - - // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host - // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through - // here or two writers racing the same navigation will pick opposite identities. - private resolveTabNavigationUrl(guest: Electron.WebContents): string { - return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() - } - - // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, - // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an - // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the - // request header says Firefox — the two-layer disagreement this scope exists to remove. - private reapplyViewportUserAgentOverride( - guest: Electron.WebContents, - browserTabId: string, - url: string - ): void { - const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - if (mobile === undefined) { - return - } - // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the - // later-issued write wins. What matters is that both writers resolve the SAME host, which they - // now do via the navigation target rather than the stale committed URL. - void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) - } - - private async sendViewportUserAgentOverride( - guest: Electron.WebContents, - mobile: boolean, - url?: string, - baseUserAgent?: string - ): Promise { - if (guest.isDestroyed() || !guest.debugger.isAttached()) { - return - } - await guest.debugger.sendCommand( - 'Emulation.setUserAgentOverride', - buildViewportUserAgentOverride({ - url: url ?? this.resolveTabNavigationUrl(guest), - mobile, - // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: - // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to - // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) - }) - ) - } - - /** Route guests own their own popup handler, so their denials arrive here instead. */ - reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { - this.forwardOrQueuePopupEvent(input.openerWebContentsId, { - origin: safeOrigin(input.url), - action: 'blocked' - }) - } - - private createPopupChildWindowWithOriginBar( - openerGuest: Electron.WebContents, - targetUrl: string, - options: PopupChildWindowOptions - ): Electron.WebContents { - const popup = openPopupWithOriginBar(options, targetUrl) - // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. - this.attachGuestPolicies( - popup.contentWebContents, - this.resolvePopupOwnerContext(openerGuest.id) - ) - this.forwardOrQueuePopupEvent(openerGuest.id, { - origin: safeOrigin(targetUrl), - action: 'opened-in-orca' - }) - // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. - const closePopupWithOpener = (): void => popup.close() - openerGuest.once('destroyed', closePopupWithOpener) - popup.onClosed(() => { - if (!openerGuest.isDestroyed()) { - openerGuest.off('destroyed', closePopupWithOpener) - } - }) - return popup.contentWebContents - } - - private retireStaleGuestWebContents(previousWebContentsId: number): void { - // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. - this.cleanupGuestPolicyAttachment(previousWebContentsId) - } - - private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - const isPrimaryGuest = browserTabId !== undefined - if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { - this.webContentsIdByTabId.delete(browserTabId) - } - this.tabIdByWebContentsId.delete(guestWebContentsId) - this.certificateTrustController?.onGuestRetired(guestWebContentsId) - const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) - if (policyCleanup) { - policyCleanup() - this.policyCleanupByGuestId.delete(guestWebContentsId) - } - this.policyAttachedGuestIds.delete(guestWebContentsId) - this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) - this.offscreenGuestIds.delete(guestWebContentsId) - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) - this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) - this.pendingNavigationByGuestId.delete(guestWebContentsId) - // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. - if (isPrimaryGuest) { - for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { - if (owner.rootGuestWebContentsId === guestWebContentsId) { - this.popupOwnerContextByGuestId.delete(popupGuestId) - } - } - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.loadErrorsByGuestId.delete(guestWebContentsId) - this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - this.cancelPendingDownloadsForGuest(guestWebContentsId) - } - - registerGuest({ - browserPageId, - browserTabId: legacyBrowserTabId, - workspaceId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId, - rendererWebContentsId - }: BrowserGuestRegistration): boolean { - const browserTabId = browserPageId ?? legacyBrowserTabId - // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one - // id resolves in both and the tool door silently prefers the document guest over the page. - if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { - return false - } - // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. - this.cancelGrabOp(browserTabId, 'evicted') - - const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - - // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. - if (guest.getType() !== 'webview') { - return false - } - if (!this.policyAttachedGuestIds.has(webContentsId)) { - // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. - return false - } - - const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserTabId) - this.viewportScrollStateByTabId.delete(browserTabId) - } - this.webContentsIdByTabId.set(browserTabId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserTabId) - if (workspaceId) { - this.workspaceIdByPageId.set(browserTabId, workspaceId) - } - this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserTabId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserTabId) - } - this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) - if (worktreeId) { - this.worktreeIdByTabId.set(browserTabId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) - - this.setupContextMenu(browserTabId, guest) - this.setupGrabShortcut(browserTabId, guest) - this.setupShortcutForwarding(browserTabId, guest) - this.setupMouseWheelZoomForwarding(browserTabId, guest) - this.flushPendingLoadFailure(browserTabId, webContentsId) - this.flushPendingPermissionEvents(browserTabId, webContentsId) - this.flushPendingPopupEvents(browserTabId, webContentsId) - this.flushPendingDownloadRequests(browserTabId, webContentsId) - return true - } - - unregisterGuest(browserTabId: string): void { - // Why the check on the exit door too: a document page withdraws by revoking its grant, never - // through here, so its id arriving is misaddressed — and the cancel below would evict that - // preview's live grab on the strength of it. - if (isWorkspaceDocPageId(browserTabId)) { - return - } - // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. - this.cancelGrabOp(browserTabId, 'evicted') - - // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. - const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (guestWebContentsId !== undefined) { - this.cleanupGuestPolicyAttachment(guestWebContentsId) - } - - const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (cleanup) { - cleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (shortcutCleanup) { - shortcutCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (fwdCleanup) { - fwdCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (mouseWheelZoomCleanup) { - mouseWheelZoomCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. - for (const [downloadId, download] of this.downloadsById.entries()) { - if (download.browserTabId === browserTabId && !download.terminalEvent) { - this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') - } - } - const wcId = this.webContentsIdByTabId.get(browserTabId) - if (wcId !== undefined) { - this.tabIdByWebContentsId.delete(wcId) - } - this.webContentsIdByTabId.delete(browserTabId) - this.rendererWebContentsIdByTabId.delete(browserTabId) - this.workspaceIdByPageId.delete(browserTabId) - this.sessionProfileIdByPageId.delete(browserTabId) - this.userAgentModeByPageId.delete(browserTabId) - this.worktreeIdByTabId.delete(browserTabId) - // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. - this.viewportOpsByTabId.delete(browserTabId) - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - this.viewportPresetActiveByTabId.delete(browserTabId) - this.viewportScrollStateByTabId.delete(browserTabId) - if (wcId !== undefined) { - this.pendingNavigationByGuestId.delete(wcId) - } - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - - // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. - registerOffscreenGuest({ - browserPageId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId - }: { - browserPageId: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - }): boolean { - // Why the same check on both registration doors: one id resolving in both halves is the exact - // confusion the split registries exist to prevent. - if (isWorkspaceDocPageId(browserPageId)) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. - this.offscreenGuestIds.add(webContentsId) - this.attachGuestPolicies(guest) - const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserPageId) - this.viewportScrollStateByTabId.delete(browserPageId) - } - this.webContentsIdByTabId.set(browserPageId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserPageId) - this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserPageId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserPageId) - } - if (worktreeId) { - this.worktreeIdByTabId.set(browserPageId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) - return true - } - - unregisterAll(): void { - // Cancel all active grab ops before tearing down registrations - this.grabSessionController.cancelAll('evicted') - for (const downloadId of this.downloadsById.keys()) { - this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') - } - browserDownloadDestinationReservations.clear() - for (const browserTabId of this.webContentsIdByTabId.keys()) { - this.unregisterGuest(browserTabId) - } - this.policyAttachedGuestIds.clear() - this.offscreenGuestIds.clear() - // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. - for (const cleanup of this.policyCleanupByGuestId.values()) { - cleanup() - } - this.policyCleanupByGuestId.clear() - this.clickedLinkFrameNameByGuestId.clear() - this.tabIdByWebContentsId.clear() - this.popupOwnerContextByGuestId.clear() - this.pageInitiatedTabBudgetByRootGuestId.clear() - this.worktreeIdByTabId.clear() - this.sessionProfileIdByPageId.clear() - this.userAgentModeByPageId.clear() - this.viewportUaOverrideMobileByTabId.clear() - this.viewportPresetActiveByTabId.clear() - this.viewportScrollStateByTabId.clear() - this.authUserAgentOverrideStateByGuestId.clear() - this.pendingNavigationByGuestId.clear() - this.pendingLoadFailuresByGuestId.clear() - this.loadErrorsByGuestId.clear() - this.clearedLoadErrorsByGuestId.clear() - this.pendingPermissionEventsByGuestId.clear() - this.pendingPopupEventsByGuestId.clear() - this.pendingDownloadIdsByGuestId.clear() - this.mouseWheelZoomCleanupByTabId.clear() - this.annotationViewportBridgeOpsByTabId.clear() - } - - getGuestWebContentsId(browserTabId: string): number | null { - return this.webContentsIdByTabId.get(browserTabId) ?? null - } - - getWebContentsIdByTabId(): Map { - return this.webContentsIdByTabId - } - - getTabIdForWebContentsId(webContentsId: number): string | null { - return this.tabIdByWebContentsId.get(webContentsId) ?? null - } - - getWorktreeIdForTab(browserTabId: string): string | undefined { - return this.worktreeIdByTabId.get(browserTabId) - } - - getRendererContextForGuest( - guestWebContentsId: number - ): { browserPageId: string; renderer: Electron.WebContents } | null { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return null - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - return renderer ? { browserPageId, renderer } : null - } - - getSessionProfileIdForTab(browserTabId: string): string | null { - return this.sessionProfileIdByPageId.get(browserTabId) ?? null - } - - getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { - const webContentsId = this.webContentsIdByTabId.get(browserPageId) - return webContentsId === undefined - ? null - : (this.loadErrorsByGuestId.get(webContentsId) ?? null) - } - - getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { - return this.certificateTrustController?.getFailure(browserPageId) ?? null - } - - getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { - if (this.popupOwnerContextByGuestId.has(webContentsId)) { - return null - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null - const offscreen = this.offscreenGuestIds.has(webContentsId) - if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { - return null - } - if (!offscreen) { - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { - return null - } - } - return { - browserPageId, - worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, - sessionProfileId: browserPageId - ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) - : null, - owner: offscreen ? 'offscreen' : 'desktop-webview' - } - } - - // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. - private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { - return { - code, - description, - validatedUrl: redactKagiSessionToken(rawUrl) - } - } - - notifyCertificateFailureChanged( - webContentsId: number, - failure: BrowserCertificateFailure | null, - navigationUrl?: string - ): void { - if (failure && navigationUrl) { - const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl) - this.loadErrorsByGuestId.set(webContentsId, loadError) - this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) - if (!browserPageId) { - return - } - if (this.offscreenGuestIds.has(webContentsId)) { - this.notifyBrowserGuestStateChanged(webContentsId) - return - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) - } - - private notifyBrowserGuestStateChanged(webContentsId: number): void { - if (!this.offscreenGuestIds.has(webContentsId)) { - return - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) - const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null - if (worktreeId) { - // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. - try { - this.browserGuestStateChangedListener?.(worktreeId) - } catch (error) { - console.error('[browser-manager] browserGuestStateChanged listener failed', error) - } - } - } - - notifyPermissionDenied(args: { - guestWebContentsId: number - permission: string - rawUrl: string - }): void { - this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { - permission: args.permission, - origin: safeOrigin(args.rawUrl) - }) - } - - handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { - const { guestWebContentsId, item } = args - const downloadId = randomUUID() - const requestedFilename = (() => { - try { - return item.getFilename() || 'download' - } catch { - return 'download' - } - })() - const totalBytes = (() => { - try { - const total = item.getTotalBytes() - return total > 0 ? total : null - } catch { - return null - } - })() - const mimeType = (() => { - try { - const mime = item.getMimeType() - return mime || null - } catch { - return null - } - })() - const origin = (() => { - try { - return safeOrigin(item.getURL()) - } catch { - return 'unknown' - } - })() - - // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself - // instead of reserving a name in the desktop Downloads folder. A popup downloads to its - // opener's page: the popup itself is a client-local transient with no logical page of its own. - const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) - const decision = routeBrowserClientDownload({ - guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId - }) - const clientRoute = decision.kind === 'remote' ? decision.route : null - const destination = (() => { - if (clientRoute) { - return { - filename: requestedFilename, - savePath: clientRoute.stagingPath, - reservationKey: null - } - } - // Why: a client-hosted download with no resolvable remote destination is canceled rather than - // written to this desktop's Downloads folder. - if (decision.kind === 'blocked') { - return null - } - try { - return browserDownloadDestinationReservations.reserve(requestedFilename) - } catch (error) { - console.error('[browser-download] Failed to choose download destination:', error) - return null - } - })() - - const fallbackSavePath = destination?.savePath ?? '' - - const download: ActiveDownload = { - downloadId, - guestWebContentsId, - browserTabId: null, - rendererWebContentsId: null, - origin, - filename: destination?.filename ?? requestedFilename, - totalBytes, - mimeType, - item, - savePath: fallbackSavePath, - reservationKey: destination?.reservationKey ?? null, - clientRoute, - remoteDestination: undefined, - receivedBytes: 0, - transientState: null, - terminalEvent: null, - startedSent: false, - cleanup: null - } - this.downloadsById.set(downloadId, download) - - const browserTabId = ownerContext?.browserTabId ?? null - if (browserTabId) { - this.bindDownloadToTab(downloadId, browserTabId) - } else { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] - pending.push(downloadId) - this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) - } - - if (!destination) { - this.finishDownloadInternal( - downloadId, - 'failed', - decision.kind === 'blocked' - ? 'Could not save the download to the remote workspace.' - : 'Could not choose a Downloads file name.' - ) - try { - item.cancel() - } catch { - // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. - } - return - } - - try { - item.setSavePath(destination.savePath) - } catch (error) { - console.error('[browser-download] Failed to set download destination:', error) - this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') - try { - item.cancel() - } catch { - // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. - } - return - } - - const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { - download.receivedBytes = this.getDownloadReceivedBytes(download.item) - download.transientState = state - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state - }) - } - const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { - const status: BrowserDownloadFinishedEvent['status'] = - state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' - const failure = - status === 'failed' - ? state === 'interrupted' - ? 'Download was interrupted.' - : 'Download failed.' - : null - if (download.clientRoute) { - void this.settleClientHostedDownload(download, status, failure) - return - } - this.finishDownloadInternal(download.downloadId, status, failure) - } - download.cleanup = (): void => { - try { - download.item.off('updated', updatedHandler) - download.item.off('done', doneHandler) - } catch { - // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. - } - } - item.on('updated', updatedHandler) - item.once('done', doneHandler) - - if (browserTabId) { - this.sendDownloadStarted(downloadId) - } - } - - cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { - const download = this.downloadsById.get(args.downloadId) - if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { - return false - } - this.cancelDownloadInternal(args.downloadId, 'Canceled.') - return true - } - - // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. - async openDevTools(browserTabId: string): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - // Offscreen guests have no visible window on this desktop; detaching DevTools would open it - // on the host display with no route back to the remote client. - if (this.offscreenGuestIds.has(webContentsId)) { - return false - } - guest.openDevTools({ mode: 'detach' }) - return true - } - - // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. - async setViewportOverride( - browserTabId: string, - override: BrowserViewportOverride | null - ): Promise { - // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. - const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (expectedWebContentsId !== undefined) { - // Keep host panning available while CDP applies the requested dimensions. The guest id fence - // prevents this intent from leaking to a replacement guest; clearing the preset removes it. - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: expectedWebContentsId, - active: override !== null - }) - } - // The renderer resizes the host before CDP completes; discard the old geometry until it - // reports the new pane bounds so a pending preset cannot route wheel input using stale limits. - this.viewportScrollStateByTabId.delete(browserTabId) - const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId)) - this.viewportOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. - if (this.viewportOpsByTabId.get(browserTabId) === next) { - this.viewportOpsByTabId.delete(browserTabId) - } - } - } - - async setAnnotationViewportBridge( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) - this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - } - } - - // Why the caller resolves the guest: the same bridge serves browsing pages and workspace - // documents, which live in different halves of the page registry. - // Why a resolver and not the guest itself: this op may have waited behind another one, and a - // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — - // injecting into the guest the request named would bridge a page nobody is looking at. - // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and - // taking an id it cannot act on would invite the next reader to act on it. - private async doSetAnnotationViewportBridgeImpl( - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - // Why no teardown here: the resolver already unregisters a page whose guest died, and the only - // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would - // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. - const guest = resolveGuest() - if (!guest || guest.isDestroyed()) { - return false - } - - try { - // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. - await guest.executeJavaScriptInIsolatedWorld( - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], - false - ) - return true - } catch { - return false - } - } - - private async doSetViewportOverrideImpl( - browserTabId: string, - override: BrowserViewportOverride | null, - expectedWebContentsId: number | undefined - ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId || webContentsId !== expectedWebContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - } catch (err) { - // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. - console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { - browserTabId, - webContentsId, - error: err instanceof Error ? err.message : String(err) - }) - return false - } - - const dbg = guest.debugger - try { - if (override) { - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width: override.width, - height: override.height, - deviceScaleFactor: override.deviceScaleFactor, - mobile: override.mobile - }) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: true - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: override.mobile, - maxTouchPoints: override.mobile ? 5 : 0 - }) - // Why: viewport sizing must not override a profile's explicit native-UA identity. - if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - // Navigation must see the preset intent while the final CDP command is in flight. - this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) - // Why: same sender as the navigation path, so both resolve the tab's host identically. - await this.sendViewportUserAgentOverride(guest, override.mobile) - } - } else { - await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: false - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: false, - maxTouchPoints: 0 - }) - const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - // A navigation after this point must not re-install the override behind the clear. - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - try { - if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { - const url = this.resolveTabNavigationUrl(guest) - const restored = await this.applyAuthUserAgentOverrideOverCdp( - guest, - false, - url, - isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() - ) - if (!restored) { - throw new Error('Failed to preserve auth user agent') - } - } else { - // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) - } - } catch (error) { - if (trackedMobile !== undefined) { - this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) - } - throw error - } - } - if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { - return false - } - return true - } catch { - return false - } - } - - // --- Browser Context Grab — main-owned operations --- - - /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ - /** - * The guest a request from `senderWebContentsId` may act on, across both halves of the page - * registry. This is the only door taught about workspace-document guests: they are kept out of - * the browsing maps entirely, so page management, agent commands, download routing and - * certificate attribution all miss them without a guard of their own — and a reader who opens a - * tool on the document in front of them still gets an answer. - */ - getAuthorizedGuest( - browserTabId: string, - senderWebContentsId: number - ): Electron.WebContents | null { - const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) - if (docGuest) { - return docGuest - } - const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) - if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { - return null - } - const guestId = this.webContentsIdByTabId.get(browserTabId) - if (guestId == null) { - return null - } - const guest = webContents.fromId(guestId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return null - } - return guest - } - - /** Returns true if a grab operation is currently active for this tab. */ - hasActiveGrabOp(browserTabId: string): boolean { - return this.grabSessionController.hasActiveGrabOp(browserTabId) - } - - /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ - async setGrabMode( - browserTabId: string, - enabled: boolean, - guest: Electron.WebContents - ): Promise { - if (!enabled) { - const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) - this.cancelGrabOp(browserTabId, 'user') - if (hadActiveGrabOp) { - return true - } - try { - await guest.executeJavaScript(buildGuestOverlayScript('teardown')) - return true - } catch { - return false - } - } - // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. - try { - await guest.executeJavaScript(buildGuestOverlayScript('arm')) - return true - } catch { - return false - } - } - - /** - * Await a single grab selection on the given tab; resolves once on click, cancel, or error. - * - * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. - */ - awaitGrabSelection( - browserTabId: string, - opId: string, - guest: Electron.WebContents - ): Promise { - return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) - } - - /** Cancel an active grab operation for the given tab. */ - cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { - this.grabSessionController.cancelGrabOp(browserTabId, reason) - } - - /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ - async captureSelectionScreenshot( - _browserTabId: string, - rect: BrowserGrabRect, - guest: Electron.WebContents - ): Promise { - return captureGrabSelectionScreenshot(rect, guest) - } - - /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ - async extractHoverPayload( - _browserTabId: string, - guest: Electron.WebContents - ): Promise { - try { - const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) - if (!rawPayload || typeof rawPayload !== 'object') { - return null - } - return clampGrabPayload(rawPayload) - } catch { - return null - } - } - - private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { - this.contextMenuCleanupByTabId.set( - browserTabId, - setupGuestContextMenu({ - browserTabId, - guest, - resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) - }) - ) - } - - // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. - private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - - this.grabShortcutCleanupByTabId.set( - browserTabId, - setupGrabShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), - getKeybindings: () => this.settingsResolver?.().keybindings - }) - ) - } - - // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. - private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - - this.shortcutForwardingCleanupByTabId.set( - browserTabId, - setupGuestShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, - isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, - getKeybindings: () => this.settingsResolver?.().keybindings, - resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, - resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null - }) - ) - } - - private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - - this.mouseWheelZoomCleanupByTabId.set( - browserTabId, - setupGuestMouseWheelZoomForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - isViewportPresetActive: () => { - const state = this.viewportPresetActiveByTabId.get(browserTabId) - return state?.guestWebContentsId === guest.id && state.active - }, - canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), - onViewportWheelConsumed: (deltaX, deltaY) => - this.recordViewportScrollDelta(browserTabId, deltaX, deltaY) - }) - ) - } - - private canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean { - const state = this.viewportScrollStateByTabId.get(browserTabId) - if (!state) { - return false - } - const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0 - const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0 - const canScrollAxis = (delta: number, position: number, maximum: number): boolean => { - if (delta < 0) { - return position > 0 - } - if (delta > 0) { - return position < maximum - } - return false - } - return ( - canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) || - canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop) - ) - } - - private forwardOrQueueGuestLoadFailure( - guestWebContentsId: number, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (!browserTabId) { - // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. - this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) - return - } - this.sendGuestLoadFailure(browserTabId, loadError) - } - - private forwardOrQueuePermissionDenied( - guestWebContentsId: number, - event: PendingPermissionEvent - ): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPermissionDenied(browserTabId, event) - } - - private flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPermissionDenied(browserTabId, event) - } - } - - private sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:permission-denied', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPermissionDeniedEvent) - } - - private forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPopupEvent(browserTabId, event) - } - - private flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPopupEvent(browserTabId, event) - } - } - - private sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:popup', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPopupEvent) - } - - private bindDownloadToTab(downloadId: string, browserTabId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - download.browserTabId = browserTabId - download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null - } - - private flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - for (const downloadId of pending) { - this.bindDownloadToTab(downloadId, browserTabId) - this.flushDownloadSnapshot(downloadId) - } - } - - private flushDownloadSnapshot(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - this.sendDownloadStarted(downloadId) - if (download.receivedBytes > 0 || download.transientState) { - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state: download.transientState - }) - } - if (download.terminalEvent) { - this.sendDownloadFinished(download.browserTabId, { - ...download.terminalEvent, - browserPageId: download.browserTabId ?? undefined - }) - this.downloadsById.delete(downloadId) - } - } - - private sendDownloadStarted(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download?.browserTabId) { - return - } - if (download.startedSent) { - return - } - const renderer = this.resolveRendererForBrowserTab(download.browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-requested', { - browserPageId: download.browserTabId, - downloadId: download.downloadId, - origin: download.origin, - filename: download.filename, - totalBytes: download.totalBytes, - mimeType: download.mimeType, - savePath: download.savePath, - status: 'downloading' - } satisfies BrowserDownloadRequestedEvent) - download.startedSent = true - } - - private sendDownloadProgress( - browserTabId: string | null, - payload: BrowserDownloadProgressEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-progress', payload) - } - - private sendDownloadFinished( - browserTabId: string | null, - payload: BrowserDownloadFinishedEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-finished', payload) - } - - private async settleClientHostedDownload( - download: ActiveDownload, - status: BrowserDownloadFinishedEvent['status'], - failure: string | null - ): Promise { - const route = download.clientRoute - if (!route) { - return - } - if (status !== 'completed') { - download.clientRoute = null - await route.abort().catch(() => undefined) - this.finishDownloadInternal(download.downloadId, status, failure) - return - } - try { - // Why: the route stays on the record for the whole commit, which spans many round trips -- a - // cancel arriving mid-stream has to find something to abort or the bytes land anyway. - const remoteDestination = await route.complete(download.filename) - download.clientRoute = null - download.remoteDestination = remoteDestination - // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. - download.savePath = '' - this.finishDownloadInternal(download.downloadId, 'completed', null) - } catch (error) { - download.clientRoute = null - if (download.terminalEvent) { - // A cancel already reported the outcome; this rejection is that cancel taking effect. - return - } - console.error('[browser-download] Failed to save download to the remote workspace:', error) - this.finishDownloadInternal( - download.downloadId, - 'failed', - 'Could not save the download to the remote workspace.' - ) - } - } - - private cancelDownloadInternal(downloadId: string, reason: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - const shouldSendCancel = !download.terminalEvent - - try { - download.item.cancel() - } catch { - // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. - } - - if (shouldSendCancel) { - this.finishDownloadInternal(downloadId, 'canceled', reason || null) - return - } - - this.downloadsById.delete(downloadId) - } - - private finishDownloadInternal( - downloadId: string, - status: BrowserDownloadFinishedEvent['status'], - error: string | null - ): void { - const download = this.downloadsById.get(downloadId) - if (!download || download.terminalEvent) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - browserDownloadDestinationReservations.release(download.reservationKey) - download.reservationKey = null - if (download.clientRoute) { - // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. - void download.clientRoute.abort().catch(() => undefined) - download.clientRoute = null - } - const event: BrowserDownloadFinishedEvent = { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - status, - savePath: download.savePath || null, - ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), - error - } - download.terminalEvent = event - if (download.browserTabId) { - this.sendDownloadStarted(downloadId) - this.sendDownloadFinished(download.browserTabId, event) - this.downloadsById.delete(downloadId) - } - } - - private cancelPendingDownloadsForGuest(guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - if (!pending?.length) { - return - } - for (const downloadId of pending) { - const download = this.downloadsById.get(downloadId) - if (!download) { - continue - } - if (download.terminalEvent) { - this.downloadsById.delete(downloadId) - continue - } - this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') - const afterCancel = this.downloadsById.get(downloadId) - if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { - this.downloadsById.delete(downloadId) - } - } - } - - private getDownloadReceivedBytes(item: Electron.DownloadItem): number { - try { - return Math.max(0, item.getReceivedBytes()) - } catch { - return 0 - } - } - - private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) - if (!pending) { - return - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.sendGuestLoadFailure(browserTabId, pending) - } - - private sendGuestLoadFailure( - browserTabId: string, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - - // Why: redact Kagi session tokens before the renderer persists validatedUrl to disk. - renderer.send('browser:guest-load-failed', { - browserPageId: browserTabId, - loadError: { - ...loadError, - validatedUrl: redactKagiSessionToken(loadError.validatedUrl) - } - }) - } - - private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return false - } - const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) - if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { - return false - } - // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. - renderer.send('browser:open-link-in-orca-tab', { - browserPageId: browserTabId, - url: normalizedUrl - }) - return true - } -} +export class BrowserManager extends BrowserManagerFinal {} export const browserManager = new BrowserManager() export const browserCertificateTrustController = new BrowserCertificateTrustController({ diff --git a/src/main/codex-accounts/runtime-home-service-auth-core.ts b/src/main/codex-accounts/runtime-home-service-auth-core.ts new file mode 100644 index 00000000000..090c82626cd --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-core.ts @@ -0,0 +1,174 @@ +import { existsSync, chmodSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from './fs-utils' +import type { + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthProvenance +} from './runtime-home-service-types' +import { CodexRuntimeHomeLegacyMigration } from './runtime-home-service-legacy-migration' + +export abstract class CodexRuntimeHomeAuthCore extends CodexRuntimeHomeLegacyMigration { + protected readSystemDefaultAuth(): string | null { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null + } + + protected writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean { + // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. + const runtimeAuthPath = this.getRuntimeAuthPath() + if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { + return false + } + const provenance: CodexSharedRuntimeAuthProvenance = + owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner + const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) + if (runtimeAuthComparison === null) { + // Why: an unreadable runtime auth.json may hold a token Codex rotated a + // moment ago. Treating "could not read" as "differs" sent execution to the + // unconditional write below, consuming that rotation and logging the user + // out for good. Refuse; the next sync retries. + return false + } + const runtimeAuthAlreadyMatches = runtimeAuthComparison + if ( + runtimeAuthAlreadyMatches && + this.sharedRuntimeAuthProvenanceMatches( + this.resolveSharedRuntimeAuthProvenanceStatus(), + provenance + ) + ) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.clearRuntimeLogoutMarker() + return true + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson: contents + }) + if (runtimeAuthAlreadyMatches) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + const replaced = options + ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { + mode: 0o600 + }) + : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) + if (!replaced) { + return false + } + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + + /** + * `true`/`false` only when the bytes were actually read; `null` when the file + * could not be read at all. The old `catch { return false }` reported "these + * differ" for a file nobody could open, and every caller reads that as + * permission to write. + */ + protected compareFileContents(targetPath: string, contents: string): boolean | null { + try { + return readFileSync(targetPath, 'utf-8') === contents + } catch (error) { + return isDefinitiveAbsence(error) ? false : null + } + } + + protected fileContentsEqual(targetPath: string, contents: string): boolean { + return this.compareFileContents(targetPath, contents) === true + } + + protected fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean { + if (expectedContents === null) { + // Why: `!existsSync` does report `true` for a locked file, but this branch + // is not where that matters — the write it guards is + // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the + // real file and refuses on its own. Classifying here would be a guard no + // test can drive. + return !existsSync(targetPath) + } + return this.fileContentsEqual(targetPath, expectedContents) + } + + protected ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + protected getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { + const marker = this.readRuntimeLogoutMarker() + if (!marker) { + return { kind: 'missing' } + } + const systemDefaultAuthJson = this.readSystemDefaultAuth() + if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { + return { kind: 'applies' } + } + this.clearRuntimeLogoutMarker() + return { kind: 'system-default-changed', systemDefaultAuthJson } + } + + protected persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { + const marker: CodexRuntimeLogoutMarker = { + systemDefaultAuthJson, + loggedOutAt: Date.now() + } + writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { + mode: 0o600 + }) + } + + protected readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown + } catch { + return null + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('systemDefaultAuthJson' in parsed) || + !('loggedOutAt' in parsed) + ) { + return null + } + const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } + if ( + (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || + typeof marker.loggedOutAt !== 'number' + ) { + return null + } + return marker as CodexRuntimeLogoutMarker + } + + protected clearRuntimeLogoutMarker(): void { + rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-provenance.ts b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts new file mode 100644 index 00000000000..d4018741405 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { writeFileAtomically } from './fs-utils' +import type { + CodexSharedRuntimeAuthPendingProvenance, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import { CodexRuntimeHomeAuthCore } from './runtime-home-service-auth-core' + +export abstract class CodexRuntimeHomeAuthProvenance extends CodexRuntimeHomeAuthCore { + protected persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void { + writeFileAtomically( + this.getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify(provenance, null, 2)}\n`, + { mode: 0o600 } + ) + } + + protected markSharedRuntimeAuthManaged(accountId: string): void { + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + status.kind === 'committed' && + status.provenance.owner === 'managed' && + status.provenance.accountId === accountId + ) { + return + } + const runtimeAuthJson = this.readRuntimeAuthForProvenance() + const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) + const provenance: CodexSharedRuntimeAuthProvenance = { + owner: 'managed', + accountId, + ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson + }) + if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { + this.persistSharedRuntimeAuthProvenance(provenance) + } + } + + protected getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null { + if (status.kind !== 'committed') { + return null + } + const baseline = + status.provenance.owner === 'system-default' + ? { authJson: status.provenance.authJson } + : status.provenance.systemDefaultBaseline + return baseline && runtimeAuthJson === baseline.authJson ? baseline : null + } + + protected restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null { + const baseline = provenance.systemDefaultBaseline + if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + const restored = { owner: 'system-default' as const, authJson: baseline.authJson } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: restored, + runtimeAuthJson: baseline.authJson + }) + if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + this.persistSharedRuntimeAuthProvenance(restored) + return restored + } + + protected sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean { + if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { + return false + } + return expected.owner === 'system-default' + ? status.provenance.owner === 'system-default' && + status.provenance.authJson === expected.authJson + : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId + } + + protected resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { + const provenancePath = this.getSharedRuntimeAuthProvenancePath() + if (!existsSync(provenancePath)) { + return { kind: 'missing' } + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown + } catch { + return { kind: 'fenced' } + } + const committed = this.parseSharedRuntimeAuthProvenance(parsed) + if (committed) { + return { kind: 'committed', provenance: committed } + } + const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) + if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { + return { kind: 'fenced' } + } + try { + this.persistSharedRuntimeAuthProvenance(pending.next) + return { kind: 'committed', provenance: pending.next } + } catch { + return { kind: 'fenced' } + } + } + + protected parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const provenance = value as Record + if ( + provenance.owner === 'system-default' && + (typeof provenance.authJson === 'string' || provenance.authJson === null) + ) { + return { owner: 'system-default', authJson: provenance.authJson } + } + if ( + provenance.owner !== 'managed' || + typeof provenance.accountId !== 'string' || + provenance.accountId.length === 0 + ) { + return null + } + const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) + if ('systemDefaultBaseline' in provenance && !baseline) { + return null + } + return { + owner: 'managed', + accountId: provenance.accountId, + ...(baseline ? { systemDefaultBaseline: baseline } : {}) + } + } + + protected parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const baseline = value as Record + return typeof baseline.authJson === 'string' || baseline.authJson === null + ? { authJson: baseline.authJson } + : null + } + + protected parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const pending = value as Record + const next = this.parseSharedRuntimeAuthProvenance(pending.next) + return pending.owner === 'pending' && + next && + (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) + ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } + : null + } + + protected readRuntimeAuthForProvenance(): string | null { + try { + return readFileSync(this.getRuntimeAuthPath(), 'utf-8') + } catch { + return null + } + } + + protected readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + let rawContents: string + try { + rawContents = readFileSync(snapshotPath, 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(rawContents) as unknown + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'authJson' in parsed && + (typeof (parsed as { authJson: unknown }).authJson === 'string' || + (parsed as { authJson: unknown }).authJson === null) + ) { + return parsed as CodexSystemDefaultSnapshot + } + // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + !('authJson' in parsed) + ) { + return { authJson: rawContents } + } + } catch { + return null + } + return null + } + + clearSystemDefaultSnapshot(): void { + rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts new file mode 100644 index 00000000000..d0fa50fa5ec --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts @@ -0,0 +1,37 @@ +import { codexAuthIsFresher } from './codex-auth-identity' + +function readCodexLastRefresh(authJson: string): number | null { + try { + const parsed = JSON.parse(authJson) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const value = (parsed as Record).last_refresh + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? timestamp : null + } catch { + return null + } +} + +export function codexAuthIsMonotonicallyFresher( + candidateAuthJson: string, + baselineAuthJson: string +): boolean { + const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) + const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) + if (candidateLastRefresh !== null || baselineLastRefresh !== null) { + return ( + candidateLastRefresh !== null && + baselineLastRefresh !== null && + candidateLastRefresh > baselineLastRefresh + ) + } + return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync.ts b/src/main/codex-accounts/runtime-home-service-auth-sync.ts new file mode 100644 index 00000000000..3cb06cac9e7 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync.ts @@ -0,0 +1,292 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { removeFileAtomicallyIfUnchanged, writeFileAtomically } from './fs-utils' +import { CodexRuntimeHomeLaunch } from './runtime-home-service-launch' +import type { CodexSystemDefaultSnapshot } from './runtime-home-service-types' + +export abstract class CodexRuntimeHomeAuthSync extends CodexRuntimeHomeLaunch { + protected captureSystemDefaultSnapshot(options: { force: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (!options.force && existsSync(snapshotPath)) { + return + } + + const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') + const snapshot: CodexSystemDefaultSnapshot = { + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null + } + writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) + } + + protected syncRuntimeAuthWithSystemDefault(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (!existsSync(runtimeAuthPath)) { + return + } + + try { + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + if (provenance?.owner === 'managed') { + this.captureSystemDefaultSnapshot({ force: true }) + if (!existsSync(systemDefaultAuthPath)) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { + owner: 'system-default' + }) + return + } + const { + ownershipProven: systemDefaultOwnershipProven, + mirroredAuthJson: mirroredSystemDefaultAuth + } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) + if (!existsSync(systemDefaultAuthPath)) { + if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + } + return + } + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + if (runtimeAuth === systemDefaultAuth) { + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) + return + } + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) + } + } + + protected syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { + if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = false + return + } + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'managed' + ) { + const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( + provenanceStatus.provenance + ) + if (restoredProvenance) { + provenanceStatus = { kind: 'committed', provenance: restoredProvenance } + } + } + if ( + provenanceStatus.kind === 'fenced' || + (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') + ) { + return + } + const systemAuth = this.readSystemDefaultAuth() + if (!existsSync(runtimeAuthPath)) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const knownSystemAuthBaseline = + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' + ? provenanceStatus.provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson) + : undefined + if (systemAuth === null) { + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' && + provenanceStatus.provenance.authJson === null && + logoutMarkerStatus.kind === 'applies' && + snapshot?.authJson === null + ) { + this.lastWrittenAuthJson = null + return + } + // Why: commit a crashed logout before a managed transition can discard its recovery baseline. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if ( + logoutMarkerStatus.kind === 'system-default-changed' || + (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) + ) { + const replaced = this.writeRuntimeAuth( + systemAuth, + { + owner: 'system-default' + }, + { expectedContents: null } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } + return + } + const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const knownSharedAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null + // Why: only bytes Orca can prove it wrote belong to the compatibility + // mirror; retained Codex or a managed transition owns every other value. + if (knownSharedAuth === null) { + return + } + const sharedAuthOwnedBySystemDefault = + runtimeAuthBeforeSync === knownSharedAuth || + (provenance?.owner === 'system-default' && + systemAuth === null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) + if (!sharedAuthOwnedBySystemDefault) { + return + } + if (systemAuth === null) { + removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) + if (existsSync(runtimeAuthPath)) { + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + return + } + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + return + } + if (runtimeAuthBeforeSync !== knownSharedAuth) { + return + } + const replaced = this.writeRuntimeAuth( + systemAuth, + { owner: 'system-default' }, + { expectedContents: runtimeAuthBeforeSync } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) + } + } + + protected restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (existsSync(systemDefaultAuthPath)) { + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + + if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { + // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (options.detectExternalLogin) { + // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (!existsSync(snapshotPath)) { + this.captureSystemDefaultSnapshot({ force: true }) + } + + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!snapshot) { + console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') + rmSync(snapshotPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!refreshedSnapshot) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if (refreshedSnapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) + return + } + if (snapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) + } + + protected writeSystemDefaultAuth(contents: string): void { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) + writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) + this.ensureOwnerOnlyMode(systemDefaultAuthPath) + } + + protected clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { + // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts new file mode 100644 index 00000000000..35573e7f045 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -0,0 +1,297 @@ +import { posix as pathPosix } from 'node:path' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { + getSystemCodexHomePath, + syncCodexGlobalInstructionsIntoManagedHome, + syncSystemCodexResourcesIntoManagedHome +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + getWslSelectionKey, + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { + hasRecordedLegacySharedCodexPane, + getCodexPaneAccount, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexRateLimitHomeResolution } from './runtime-home-service-types' +import { CodexRuntimeHomeManagedHome } from './runtime-home-service-managed-home' + +export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHome { + getHostCodexHomePathsForSessionDiscovery(): string[] { + const homes = [this.getRuntimeHomePath()] + if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME. + // Per-account lanes no longer bridge real-home history into the shared + // mirror, so include the real root for both directly-routed host lanes. + homes.push(getSystemCodexHomePath()) + } + // Why: account-scoped rollouts live in each account's own home, including WSL. + for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { + homes.push(perAccountHome) + } + return homes.filter((home, index) => homes.indexOf(home) === index) + } + + /** + * The account-owned CODEX_HOME the current HOST selection runs against, or + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). + * + * Read-only on purpose: session discovery ranks homes with this before any + * launch prep, so it must create no directories and sync no auth. + */ + getSelectedHostAccountCodexHomePath(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + return selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null + } + + /** + * Same selection, but an unreadable home refuses instead of collapsing to + * `null`. Session resume must not read "no managed selection" out of a failed + * marker stat: another account's readable alias would then win the legacy + * rescan and the pane would resume under that account's credentials while the + * UI still shows this one (#STA-4422). + */ + resolveSelectedHostAccountCodexHomePathForResume(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (!selfContainedAccount) { + return null + } + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(selfContainedAccount) + return null + } + return resolved.homePath + } + + /** Trust-gates host previews without changing WSL routing or durable account state. */ + resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { + if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { + return { kind: 'ready', homePath: account.managedHomePath } + } + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' + ? { kind: 'ready', homePath: resolved.homePath } + : { kind: 'skip' } + } + + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { + if (this.getSelfContainedManagedHostAccount()) { + return 'account-home' + } + return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' + } + + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + + // Why: the real-home hook installer flips this gate off when the trust-grant + // client reports the host incapable, keeping that host byte-identical to the + // managed lane instead of shipping status-blind panes. + protected realHomeLaneGate: () => boolean = () => true + + setRealHomeLaneGate(gate: () => boolean): void { + this.realHomeLaneGate = gate + } + + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. + isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + !isShellStartupEnvProbeSupported() + ) { + return false + } + return !hasCustomCodexHomeOverrideForLaunch(launchEnv) + } + + isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { + return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() + } + + reconcileLegacySharedHomeForRetainedPanes(): void { + if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { + return + } + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + syncLegacySharedCodexConfigForRetainedPanes() + } + + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) + } + + protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + if (home && /^[A-Za-z]:[\\/]/.test(home)) { + const linuxHome = toLinuxPath(home).trim() + return linuxHome.startsWith('/') + ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) + : null + } + return home ? this.joinWslPath(home, '.codex') : null + } + + protected finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void { + this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) + this.startWslSessionBridgeForLaunch(target, homePath) + } + + protected syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath: runtimeHomePath + }) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath, + systemConfigDir: toLinuxPath(systemHomePath) + }) + } + + // Why: `null` is a real value here — it means "use the system-default lane". + // A skipped poll needs its own channel or the fetcher silently retargets the + // user's real ~/.codex (#STA-4422). + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return { + kind: 'ready', + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) + } + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'owned') { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap or per-poll resource relink (that is launch prep). + return { kind: 'ready', codexHomePath: resolved.homePath } + } + if (resolved.kind === 'indeterminate') { + // Why: returning null here would NOT skip — the fetcher maps null to + // ~/.codex and would probe the user's real home with a token-refreshing + // app-server. Skip the poll outright and keep the selection. + return { kind: 'skip' } + } + this.clearSelfContainedManagedSelection(selfContainedAccount) + } + if (this.isHostSystemDefaultRealHome()) { + // Why: null lets the fetcher fall back to the main process's inherited + // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the + // managed home, restarting the background OAuth conflict (#5370), so + // pin this non-interactive lane to the native home explicitly. + if (hasRecordedLegacySharedCodexPane()) { + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + } + return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } + } + this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts new file mode 100644 index 00000000000..e25be92abe1 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -0,0 +1,154 @@ +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { markCodexSessionBackfillMarkerPending } from '../codex/codex-session-backfill-marker' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing' + +export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { + protected initializeLastSyncedState(): void { + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host + } + + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ + prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( + selfContainedAccount, + options?.unavailableManagedHomePath + ) + if (perAccountHome) { + return perAccountHome + } + // Why: only an untrusted home clears the selection; fall through to the + // system default without injecting a path Orca cannot prove it owns. + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: the system default runs Codex on the user's own ~/.codex. + // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; + // the retired mirror is refreshed only for pre-rollout PTYs. + this.reconcileLegacySharedHomeForRetainedPanes() + return null + } + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) + this.syncForCurrentSelection(target, launchEnv) + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground( + {}, + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + return this.getRuntimeHomePath() + } + + async prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise { + if (target?.runtime !== 'wsl') { + return this.prepareForCodexLaunch(target, launchEnv, options) + } + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + // Why: the retired home may hold the freshest credential, so the first + // direct-home Codex spawn must wait for its bounded guest transaction. + await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass( + scanDates: readonly CodexSessionBackfillDate[] = [] + ): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== target + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = target + } + // Why: the launch creates rollouts for these dates; record them durably so a + // force-quit recovers a bounded window instead of re-walking all history. + const markerOwesFullScan = markCodexSessionBackfillMarkerPending( + paths.markerPath, + paths.systemSessionsRoot, + scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] + ) + // Why: the marker is the only place an overflowed pending window survives a + // restart, so its demand has to reach this pass rather than die in the file. + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } +} diff --git a/src/main/codex-accounts/runtime-home-service-legacy-migration.ts b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts new file mode 100644 index 00000000000..dcb337cac29 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts @@ -0,0 +1,204 @@ +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync +} from 'node:fs' +import { dirname, extname, join, parse, relative } from 'node:path' +import { writeFileAtomically } from './fs-utils' +import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { CodexRuntimeHomePaths } from './runtime-home-service-paths' + +export abstract class CodexRuntimeHomeLegacyMigration extends CodexRuntimeHomePaths { + protected safeMigrateLegacySharedAuth(): void { + const settings = this.store.getSettings() + try { + migrateLegacySharedAuthToPerAccountHome({ + activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, + hostAccounts: settings.codexManagedAccounts.filter( + (account) => !this.getWslManagedHomePath(account) + ), + managedAccountsRoot: this.getManagedAccountsRoot(), + metadataDir: this.getRuntimeMetadataDir(), + sharedRuntimeHome: this.getRuntimeHomePath(), + systemCodexHome: getSystemCodexHomePath() + }) + } catch (error) { + // Why: an inconclusive identity, ownership, or filesystem result must + // leave the marker absent so the next startup can retry safely. + console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) + } + } + + protected safeMigrateLegacyManagedState(): void { + try { + this.migrateLegacyManagedStateIfNeeded() + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) + } + } + + protected safeMigrateLegacyActiveHomePointer(): void { + try { + const activeHomePath = this.getLegacyHostActiveHomePath() + if (!this.legacyActiveHomePathExists(activeHomePath)) { + return + } + this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) + } + } + + protected migrateLegacyManagedStateIfNeeded(): void { + if (existsSync(this.getMigrationMarkerPath())) { + return + } + + const managedHomes = this.getLegacyManagedHomes() + for (const managedHomePath of managedHomes) { + const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( + /[\\/]/ + )[0] + if (!accountId) { + continue + } + this.migrateLegacyHistory(managedHomePath) + this.migrateLegacySessions(managedHomePath, accountId) + } + + // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. + writeFileAtomically( + this.getMigrationMarkerPath(), + `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` + ) + } + + protected getLegacyManagedHomes(): string[] { + const managedAccountsRoot = this.getManagedAccountsRoot() + if (!existsSync(managedAccountsRoot)) { + return [] + } + + const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) + const managedHomes: string[] = [] + for (const entry of accountEntries) { + if (!entry.isDirectory()) { + continue + } + const managedHomePath = join(managedAccountsRoot, entry.name, 'home') + if (existsSync(join(managedHomePath, '.orca-managed-home'))) { + managedHomes.push(managedHomePath) + } + } + return managedHomes.sort() + } + + protected migrateLegacyHistory(managedHomePath: string): void { + const legacyHistoryPath = join(managedHomePath, 'history.jsonl') + if (!existsSync(legacyHistoryPath)) { + return + } + + const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') + const existingLines = existsSync(runtimeHistoryPath) + ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) + : [] + const mergedLines = [...existingLines] + const seenLines = new Set(existingLines) + for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { + if (!line || seenLines.has(line)) { + continue + } + seenLines.add(line) + mergedLines.push(line) + } + + if (mergedLines.length === 0) { + return + } + writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) + } + + protected migrateLegacySessions(managedHomePath: string, accountId: string): void { + const legacySessionsRoot = join(managedHomePath, 'sessions') + if (!existsSync(legacySessionsRoot)) { + return + } + + const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') + mkdirSync(runtimeSessionsRoot, { recursive: true }) + for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { + const relativePath = relative(legacySessionsRoot, legacyFilePath) + const runtimeFilePath = join(runtimeSessionsRoot, relativePath) + mkdirSync(dirname(runtimeFilePath), { recursive: true }) + if (!existsSync(runtimeFilePath)) { + copyFileSync(legacyFilePath, runtimeFilePath) + continue + } + + const legacyContents = readFileSync(legacyFilePath) + const runtimeContents = readFileSync(runtimeFilePath) + if (runtimeContents.equals(legacyContents)) { + continue + } + + const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) + copyFileSync(legacyFilePath, preservedPath) + this.appendMigrationDiagnostic({ + type: 'session-conflict', + accountId, + runtimeFilePath, + preservedPath + }) + } + } + + protected listFilesRecursively(rootPath: string): string[] { + const stat = statSync(rootPath) + if (!stat.isDirectory()) { + return [rootPath] + } + + const files: string[] = [] + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + this.appendListedFiles(files, this.listFilesRecursively(childPath)) + continue + } + if (entry.isFile()) { + files.push(childPath) + } + } + return files.sort() + } + + protected appendListedFiles(target: string[], source: readonly string[]): void { + // Why: tolerate directories larger than V8's argument limit for spread calls. + for (const filePath of source) { + target.push(filePath) + } + } + + protected getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { + const extension = extname(runtimeFilePath) + const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) + return `${basename}.orca-legacy-${accountId}${extension}` + } + + protected appendMigrationDiagnostic(record: Record): void { + const diagnosticsPath = this.getMigrationDiagnosticsPath() + try { + appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) + } catch (error) { + // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts new file mode 100644 index 00000000000..6324c0e2307 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -0,0 +1,276 @@ +import { join } from 'node:path' +import { + syncSystemCodexResourcesIntoManagedHome, + getSystemCodexHomePath, + resolveOrcaManagedCodexHomePath +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { + resolveHostCodexManagedHomeVerdict, + ManagedCodexHomeTemporarilyUnavailableError +} from './host-codex-managed-home-ownership' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { getDefaultWslDistro } from '../wsl' +import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeSync } from './runtime-home-service-sync' + +export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. + protected getSelfContainedManagedHostAccount(): CodexManagedAccount | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (!account || this.getWslManagedHomePath(account)) { + return null + } + return account + } + + // Why: session discovery must surface every account's own rollouts wherever they live. + protected getManagedAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + const wslHome = this.getWslManagedHomePath(account) + if (wslHome) { + homes.push(wslHome) + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected getManagedHostAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + if (this.getWslManagedHomePath(account)) { + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: refuse the launch rather than silently falling through to the + // system default, which would run a different account behind a UI still + // showing this one. The selection stays put; a later read may succeed. + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(account) + return null + } + const perAccountHome = resolved.homePath + if ( + unavailableManagedHomePath && + normalizeRuntimePathForComparison(unavailableManagedHomePath) === + normalizeRuntimePathForComparison(perAccountHome) + ) { + const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) + if (absence.state !== 'present' && absence.durable) { + this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') + return null + } + // Why: a transient missing/unreadable auth.json is usually codex rotating + // it; keep the selection and launch — the CLI re-reads the settled file. + } + // Why: link the user's real ~/.codex resources and mirror config into THIS + // home (never symlinking into or mutating ~/.codex), so the per-account home + // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + syncSystemCodexResourcesIntoManagedHome(perAccountHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: perAccountHome, + systemHomePath: getSystemCodexHomePath() + }) + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return perAccountHome + } + + // Why: Codex's own `/resume` picker only lists rollouts under the launch + // CODEX_HOME, so a self-contained account home starts out with no history at + // all. Hardlink every other Orca-visible home's rollouts in — after launch, + // since history trees can be large — so switching accounts no longer hides + // the user's conversations. + protected startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { + void startCodexAccountSessionBridgeInBackground({ + targetCodexHomePath: perAccountHome, + sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() + }) + } + + protected getSelfContainedSessionBridgeSourceHomes(): string[] { + return [ + // Why: history-only override lets custom-CODEX_HOME users bridge from the + // home they actually record sessions in; falls back to the real ~/.codex. + resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), + // Why: path only — a per-account install must not materialize the mirror. + resolveOrcaManagedCodexHomePath(), + ...this.getManagedHostAccountHomesForSessionDiscovery() + ] + } + + // Why: the per-account home is both the launch CODEX_HOME and the credential + // store, so codex reads/refreshes auth.json in place — there is no shared-home + // hot-swap or token read-back to reconcile. A trusted home remains selected + // while Codex atomically replaces auth.json. + protected syncSelfContainedManagedSelection(account: CodexManagedAccount): void { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: a sync runs on every app start, exactly when antivirus is busiest. + // An unreadable home must not deselect the account (#STA-4422). + return + } + const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null + if (perAccountHome) { + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + // Why: selection runs well before the user restarts a pane, so history is + // already linked in by the time the newly launched Codex opens /resume. + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return + } + this.clearSelfContainedManagedSelection(account) + } + + /** + * Why: an unreadable home and an untrustworthy one demand opposite responses. + * Only `untrusted` may clear the user's selection; `indeterminate` means we + * could not tell, so callers refuse the operation and leave durable state + * alone (#STA-4422). + */ + protected resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { + const verdict = resolveHostCodexManagedHomeVerdict({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + if (verdict.kind === 'owned') { + // Preserve the persisted path spelling (notably /var vs /private/var on + // macOS) so injected CODEX_HOME stays stable across the rollout. + return { kind: 'owned', homePath: account.managedHomePath } + } + if (verdict.kind === 'untrusted') { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) + return { kind: 'untrusted' } + } + console.warn( + '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', + verdict.error + ) + return { kind: 'indeterminate' } + } + + /** Read-only callers that mutate nothing and simply skip an unusable home. */ + protected getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' ? resolved.homePath : null + } + + protected clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason = 'home is invalid' + ): void { + console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) + const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== account.id) { + return + } + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + this.lastSyncedAccountId = null + this.lastHostAccountUsedSelfContainedHome = false + } + + protected invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null + } + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( + paths.systemSessionsRoot + ) + this.hostSystemDefaultSessionMigrationPending = true + } + return this.prepareHostSystemDefaultSessionMigrationPass() + } + + protected startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (process.platform !== 'win32' || !runtimeHomePath) { + return + } + const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) + const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() + if (!distro) { + return + } + // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. + const systemCodexHomePath = + resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { + // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-paths.ts b/src/main/codex-accounts/runtime-home-service-paths.ts new file mode 100644 index 00000000000..87ef3cedeaa --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-paths.ts @@ -0,0 +1,187 @@ +import { + lstatSync, + mkdirSync, + readlinkSync, + renameSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { app } from 'electron' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { getOrcaManagedCodexHomePath, getOrcaUserDataPath } from '../codex/codex-home-paths' +import type { CodexMirroredHomeStatus } from './runtime-home-service-types' +import { CodexRuntimeHomeState } from './runtime-home-service-state' + +export abstract class CodexRuntimeHomePaths extends CodexRuntimeHomeState { + protected getRuntimeHomePath(): string { + return getOrcaManagedCodexHomePath() + } + + /** + * Resolves the managed home the config mirror actually targets for the + * current HOST selection, or null when no mirror runs for it. + * + * Read-only on purpose: unlike the launch and quota-fetch paths this prepares + * nothing and creates no directories, so surfacing sync health cannot alter + * the state it is reporting on. Returns null for the system default on the + * real-home lane, which runs Codex directly against ~/.codex — there is no + * mirror there, so there is nothing that can fall behind. + */ + getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + // Why: `null` here is a positive claim that no mirror exists, which the + // status channel reports as healthy. An unreadable home is not that. + return { kind: 'unavailable' } + } + return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } + } + if (this.isHostSystemDefaultRealHome()) { + return { kind: 'ready', homePath: null } + } + return { + kind: 'ready', + homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + } + } + + protected getRuntimeAuthPath(): string { + return join(this.getRuntimeHomePath(), 'auth.json') + } + + protected getSystemDefaultSnapshotPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') + } + + protected getRuntimeLogoutMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') + } + + protected getSharedRuntimeAuthProvenancePath(): string { + return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') + } + + protected getRuntimeMetadataDir(): string { + const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') + mkdirSync(metadataDir, { recursive: true }) + return metadataDir + } + + protected getLegacyHostActiveHomePath(): string { + return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') + } + + protected getMigrationMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-v1.json') + } + + protected getMigrationDiagnosticsPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') + } + + protected getManagedAccountsRoot(): string { + return join(app.getPath('userData'), 'codex-accounts') + } + + protected repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { + if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { + return + } + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + return + } + + mkdirSync(runtimeHomePath, { recursive: true }) + mkdirSync(dirname(activeHomePath), { recursive: true }) + const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + try { + symlinkSync( + runtimeHomePath, + nextLinkPath, + process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() + ? 'junction' + : undefined + ) + try { + renameSync(nextLinkPath, activeHomePath) + } catch (error) { + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + throw error + } + this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) + renameSync(nextLinkPath, activeHomePath) + } + } finally { + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + } + } + + protected activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean { + try { + return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) + } catch { + return false + } + } + + protected linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean { + const resolvedLinkTarget = isAbsolute(linkTarget) + ? resolve(linkTarget) + : resolve(dirname(linkPath), linkTarget) + return resolvedLinkTarget === resolve(expectedTargetPath) + } + + protected legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { + try { + const stat = lstatSync(activeHomePath) + return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) + } catch { + return true + } + } + + protected legacyActiveHomePathExists(activeHomePath: string): boolean { + try { + lstatSync(activeHomePath) + return true + } catch { + return false + } + } + + protected removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { + try { + const stat = lstatSync(activeHomePath) + if (stat.isSymbolicLink()) { + unlinkSync(activeHomePath) + } else if (this.isWindowsReadableLink(activeHomePath)) { + rmdirSync(activeHomePath) + } + } catch { + // Missing or inaccessible temporary links are handled by the caller. + } + } + + protected isWindowsReadableLink(targetPath: string): boolean { + if (process.platform !== 'win32') { + return false + } + try { + readlinkSync(targetPath) + return true + } catch { + return false + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts new file mode 100644 index 00000000000..d6a6691fbb4 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -0,0 +1,264 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' +import type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution, + CodexReadBackMatch, + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthPendingProvenance, + CodexSelfContainedManagedHomeResolution, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain' +import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' + +/** Shared state and method contracts for the focused runtime-home layers. */ +export abstract class CodexRuntimeHomeState { + // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. + protected lastSyncedAccountId: string | null = null + // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). + protected lastWrittenAuthJson: string | null = null + // Why: a managed host account refreshes auth in its own home. Remember that provenance so a later deselect never adopts stale shared bytes. + protected lastHostAccountUsedSelfContainedHome = false + protected sharedAuthRefreshBlockedByManagedTransition = false + // Why: transient auth.json read/parse failures must not deselect an account. + protected readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + protected hostSystemDefaultSessionMigrationPending = false + protected pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + protected pendingHostSystemDefaultSessionMigrationTarget: string | null = null + + protected constructor(protected readonly store: Store) {} + + protected abstract initializeLastSyncedState(): void + abstract prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null + abstract prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise + abstract beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options?: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } + ): boolean | null + abstract isHostSystemDefaultSessionMigrationEligible(): boolean + abstract prepareHostSystemDefaultSessionMigrationPass( + scanDates?: readonly CodexSessionBackfillDate[] + ): boolean + abstract finishHostSystemDefaultSessionMigrationPass(): void + + protected abstract getSelfContainedManagedHostAccount(): CodexManagedAccount | null + protected abstract getManagedAccountHomesForSessionDiscovery(): string[] + protected abstract getManagedHostAccountHomesForSessionDiscovery(): string[] + protected abstract prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null + protected abstract startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void + protected abstract getSelfContainedSessionBridgeSourceHomes(): string[] + protected abstract syncSelfContainedManagedSelection(account: CodexManagedAccount): void + protected abstract resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): CodexSelfContainedManagedHomeResolution + protected abstract getTrustedSelfContainedManagedHomePath( + account: CodexManagedAccount + ): string | null + protected abstract clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason?: string + ): void + protected abstract invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null + protected abstract startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + + abstract getHostCodexHomePathsForSessionDiscovery(): string[] + abstract getSelectedHostAccountCodexHomePath(): string | null + abstract resolveSelectedHostAccountCodexHomePathForResume(): string | null + abstract resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } + abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute + abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] + abstract setRealHomeLaneGate(gate: () => boolean): void + abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean + abstract isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean + abstract reconcileLegacySharedHomeForRetainedPanes(): void + abstract syncActiveWslSelectionsBeforeRestart(): Promise + + protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null + protected abstract finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void + protected abstract syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + abstract prepareForRateLimitFetch( + target?: CodexAccountSelectionTarget + ): CodexRateLimitHomeResolution + abstract syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void + abstract clearLastWrittenAuthJson(accountId?: string | null): void + + protected abstract resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } + protected abstract safeSyncForCurrentSelection(): void + protected abstract safeRecoverInterruptedRuntimeAuthOperation(): void + protected abstract getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null + protected abstract getWslManagedHomePath(account: CodexManagedAccount | null): string | null + protected abstract getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null + protected abstract getPreparedWslRateLimitHomePath( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslCodexHomePathForSelection( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslLaunchCodexHomePath( + account: CodexManagedAccount, + targetDistro: string | undefined + ): string | null + protected abstract startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options?: { throwOnFailure?: boolean } + ): Promise + protected abstract resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise + protected abstract joinWslPath(basePath: string, ...segments: string[]): string + protected abstract resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget + protected abstract findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } + ): CodexReadBackMatch + protected abstract runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean + + protected abstract safeMigrateLegacySharedAuth(): void + protected abstract safeMigrateLegacyManagedState(): void + protected abstract safeMigrateLegacyActiveHomePointer(): void + protected abstract getRuntimeHomePath(): string + abstract getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus + protected abstract getRuntimeAuthPath(): string + protected abstract getSystemDefaultSnapshotPath(): string + protected abstract getRuntimeLogoutMarkerPath(): string + protected abstract getSharedRuntimeAuthProvenancePath(): string + protected abstract getRuntimeMetadataDir(): string + protected abstract getLegacyHostActiveHomePath(): string + protected abstract getMigrationMarkerPath(): string + protected abstract getMigrationDiagnosticsPath(): string + protected abstract getManagedAccountsRoot(): string + protected abstract repointLegacyActiveHomePointer( + activeHomePath: string, + runtimeHomePath: string + ): void + protected abstract activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean + protected abstract linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean + protected abstract legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean + protected abstract legacyActiveHomePathExists(activeHomePath: string): boolean + protected abstract removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void + protected abstract isWindowsReadableLink(targetPath: string): boolean + protected abstract migrateLegacyManagedStateIfNeeded(): void + protected abstract getLegacyManagedHomes(): string[] + protected abstract migrateLegacyHistory(managedHomePath: string): void + protected abstract migrateLegacySessions(managedHomePath: string, accountId: string): void + protected abstract listFilesRecursively(rootPath: string): string[] + protected abstract appendListedFiles(target: string[], source: readonly string[]): void + protected abstract getPreservedLegacySessionPath( + runtimeFilePath: string, + accountId: string + ): string + protected abstract appendMigrationDiagnostic(record: Record): void + + protected abstract captureSystemDefaultSnapshot(options: { force: boolean }): void + protected abstract syncRuntimeAuthWithSystemDefault(): void + protected abstract syncLegacySharedSystemDefaultAuthForRetainedPanes(): void + protected abstract restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void + protected abstract writeSystemDefaultAuth(contents: string): void + protected abstract clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void + protected abstract readSystemDefaultAuth(): string | null + protected abstract writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean + protected abstract compareFileContents(targetPath: string, contents: string): boolean | null + protected abstract fileContentsEqual(targetPath: string, contents: string): boolean + protected abstract fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean + protected abstract ensureOwnerOnlyMode(targetPath: string): void + protected abstract getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus + protected abstract persistRuntimeLogoutMarker(systemDefaultAuthJson?: string | null): void + protected abstract readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null + protected abstract clearRuntimeLogoutMarker(): void + protected abstract persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void + protected abstract markSharedRuntimeAuthManaged(accountId: string): void + protected abstract getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null + protected abstract restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null + protected abstract sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean + protected abstract resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus + protected abstract parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null + protected abstract parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null + protected abstract parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null + protected abstract readRuntimeAuthForProvenance(): string | null + protected abstract readSystemDefaultSnapshot( + snapshotPath: string + ): CodexSystemDefaultSnapshot | null + abstract clearSystemDefaultSnapshot(): void +} diff --git a/src/main/codex-accounts/runtime-home-service-sync.ts b/src/main/codex-accounts/runtime-home-service-sync.ts new file mode 100644 index 00000000000..184faeab241 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-sync.ts @@ -0,0 +1,154 @@ +import { existsSync } from 'node:fs' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { recoverInterruptedGuardedFileOperation } from './fs-utils' +import type { CodexSharedRuntimeAuthProvenanceStatus } from './runtime-home-service-types' +import { codexAuthIsMonotonicallyFresher } from './runtime-home-service-auth-sync-identity' +import { CodexRuntimeHomeWsl } from './runtime-home-service-wsl' + +export abstract class CodexRuntimeHomeSync extends CodexRuntimeHomeWsl { + syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void { + if (target?.runtime === 'wsl') { + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) + return + } + + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + // Why: self-contained managed homes hold their own auth, so the shared + // runtime home's snapshot/hot-swap/read-back machinery below must not run. + this.syncSelfContainedManagedSelection(selfContainedAccount) + return + } + const settings = this.store.getSettings() + if (this.lastHostAccountUsedSelfContainedHome) { + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. + this.lastHostAccountUsedSelfContainedHome = false + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + if (this.isHostSystemDefaultRealHome(launchEnv)) { + return + } + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: retained daemon panes may own shared auth from a managed launch; + // compatibility reconciliation runs later with durable provenance. + if (this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = true + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + } + return + } + const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: false }) + } + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + return + } + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + } + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else { + this.persistRuntimeLogoutMarker() + } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() + } + } + + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { + this.lastWrittenAuthJson = null + } + } + + // Why: which ~/.codex bytes the mirror was seeded from, and whether the system + // default can be proven to own the mirror at all. + protected resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } { + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const snapshotAuth = + this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + return { + ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, + mirroredAuthJson: + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null + } + } + + protected safeSyncForCurrentSelection(): void { + try { + this.syncForCurrentSelection() + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) + } + } + + protected safeRecoverInterruptedRuntimeAuthOperation(): void { + try { + recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-types.ts b/src/main/codex-accounts/runtime-home-service-types.ts new file mode 100644 index 00000000000..1cf9c773fd2 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-types.ts @@ -0,0 +1,62 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' + +export type CodexSystemDefaultSnapshot = { + authJson: string | null +} + +export type CodexRuntimeLogoutMarker = { + systemDefaultAuthJson: string | null + loggedOutAt: number +} + +export type CodexSharedRuntimeAuthProvenance = + | { owner: 'system-default'; authJson: string | null } + | { + owner: 'managed' + accountId: string + systemDefaultBaseline?: { authJson: string | null } + } + +export type CodexSharedRuntimeAuthPendingProvenance = { + owner: 'pending' + next: CodexSharedRuntimeAuthProvenance + runtimeAuthJson: string | null +} + +export type CodexSharedRuntimeAuthProvenanceFile = + | CodexSharedRuntimeAuthProvenance + | CodexSharedRuntimeAuthPendingProvenance + | { owner: 'fenced' } + +export type CodexSharedRuntimeAuthProvenanceStatus = + | { kind: 'missing' | 'fenced' } + | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } + +export type CodexRuntimeLogoutMarkerStatus = + | { kind: 'missing' } + | { kind: 'applies' } + | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } + +export type CodexReadBackMatch = + | { + kind: 'matched' + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + } + | { kind: 'none' | 'ambiguous' } + +export type CodexSelfContainedManagedHomeResolution = + | { kind: 'owned'; homePath: string } + | { kind: 'untrusted' } + | { kind: 'indeterminate' } + +/** Status used by the config-sync surface; `unavailable` is not a healthy null lane. */ +export type CodexMirroredHomeStatus = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable' } + +/** Result used by quota polling, where `skip` means no process should be spawned. */ +export type CodexRateLimitHomeResolution = + | { kind: 'ready'; codexHomePath: string | null } + | { kind: 'skip' } diff --git a/src/main/codex-accounts/runtime-home-service-wsl-core.ts b/src/main/codex-accounts/runtime-home-service-wsl-core.ts new file mode 100644 index 00000000000..4ace9d8dd33 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl-core.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + codexAuthCouldBelongToManagedAccount, + codexAuthMatchesManagedAccount, + codexAuthMatchesSystemDefaultIdentity +} from './codex-auth-identity' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexReadBackMatch } from './runtime-home-service-types' +import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import { CodexRuntimeHomeAuthProvenance } from './runtime-home-service-auth-provenance' + +export abstract class CodexRuntimeHomeWslCore extends CodexRuntimeHomeAuthProvenance { + protected getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null { + if (!activeAccountId) { + return null + } + return accounts.find((account) => account.id === activeAccountId) ?? null + } + + protected getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null + } + + protected getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null { + if (!account) { + return null + } + const distro = account.wslDistro?.trim() + const linuxHomePath = account.wslLinuxHomePath?.trim() + if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { + return { distro, linuxHomePath } + } + const legacyHome = parseWslUncPath(account.managedHomePath) + return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null + } + + protected findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } + ): CodexReadBackMatch { + const matches: { + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + }[] = [] + let unreadableHomeCouldOwnRuntimeAuth = false + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } + const managedAuthPath = join(account.managedHomePath, 'auth.json') + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { + continue + } + if (suppliedRead?.kind === 'unreadable') { + // Why: an unreadable home can never be compared, but letting the read + // throw abandons the scan for every other account — dropping a refresh + // the runtime home holds for one of them. Only its record can rule it + // out as the owner; when it cannot, the scan is no longer unambiguous. + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } + if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { + matches.push({ account, managedAuthPath, managedAuthContents }) + } + } + + if (unreadableHomeCouldOwnRuntimeAuth) { + return { kind: 'ambiguous' } + } + if (matches.length === 1) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + protected runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean { + return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts new file mode 100644 index 00000000000..ea39e40d730 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -0,0 +1,167 @@ +import { join } from 'node:path' +import { win32 as pathWin32 } from 'node:path' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { + getCodexSelectionLaneKey, + getSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { hasRecordedLegacyWslCodexPane } from '../codex/codex-pane-account-registry' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeWslCore } from './runtime-home-service-wsl-core' + +export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { + protected getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + return this.getWslCodexHomePathForSelection(target) + } + + protected getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + if (account) { + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) + if (accountHome) { + return accountHome + } + } + return this.getWslSystemCodexHomePath(target) + } + + protected getWslLaunchCodexHomePath( + account: CodexManagedAccount, + targetDistro: string | undefined + ): string | null { + const wslHome = this.getWslManagedHomeIdentity(account) + if (!wslHome) { + return null + } + const accountDistro = wslHome.distro + if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { + return null + } + if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { + return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + + protected startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options: { throwOnFailure?: boolean } = {} + ): Promise { + if (process.platform !== 'win32') { + return Promise.resolve() + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return Promise.resolve() + } + const guestHome = getWslHome(distro) + const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' + if (!guestHomeLinuxPath.startsWith('/')) { + return Promise.resolve() + } + let legacyPanePresent = true + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + // Why: unknown pane liveness must preserve the source, but promotion can + // still keep the direct home from launching stale auth. + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) + } + return startLegacyWslRuntimeAuthDrain( + { + distro, + guestHomeLinuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }, + options + ) + } + + protected async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { + const wslHome = this.getWslManagedHomeIdentity(account) + return wslHome?.distro.toLowerCase() === distro.toLowerCase() + ? [{ account, linuxPath: wslHome.linuxHomePath }] + : [] + }) + const accounts = accountHomes.map(({ account }) => account) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) + if (!accountHome) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: accountHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null + } + + protected joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + protected resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2d246bf480d..4f08fb0a64c 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,210 +1,14 @@ -/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { - appendFileSync, - copyFileSync, - existsSync, - chmodSync, - lstatSync, - mkdirSync, - readlinkSync, - readdirSync, - readFileSync, - renameSync, - rmdirSync, - rmSync, - statSync, - symlinkSync, - unlinkSync -} from 'node:fs' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { - dirname, - extname, - isAbsolute, - join, - parse, - posix as pathPosix, - relative, - resolve, - win32 as pathWin32 -} from 'node:path' -import { app } from 'electron' -import type { CodexManagedAccount } from '../../shared/managed-account-types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { - recoverInterruptedGuardedFileOperation, - removeFileAtomicallyIfUnchanged, - writeFileAtomically, - writeFileAtomicallyIfUnchanged -} from './fs-utils' -import { - getOrcaManagedCodexHomePath, - getOrcaUserDataPath, - getSystemCodexHomePath, - resolveOrcaManagedCodexHomePath, - syncCodexGlobalInstructionsIntoManagedHome, - syncSystemCodexResourcesIntoManagedHome -} from '../codex/codex-home-paths' -import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' -import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' -import { - resolveHostCodexSessionSourceHome, - resolveWslCodexSessionSourceHome -} from '../codex/codex-session-source-home' -import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' -import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' -import { - getCodexSelectionLaneKey, - getWslSelectionKey, - getSelectedCodexAccountIdForTarget, - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './runtime-selection' -import { getDefaultWslDistro, getWslHome } from '../wsl' -import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { - hasCompletedCodexSessionBackfillMarker, - markCodexSessionBackfillMarkerPending -} from '../codex/codex-session-backfill-marker' -import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' -import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' -import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { - ManagedCodexHomeTemporarilyUnavailableError, - resolveHostCodexManagedHomeVerdict -} from './host-codex-managed-home-ownership' -import { - codexAuthCouldBelongToManagedAccount, - codexAuthIsFresher, - codexAuthMatchesManagedAccount, - codexAuthMatchesSystemDefaultIdentity -} from './codex-auth-identity' -import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' -import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' -import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' -import { - getCodexPaneAccount, - hasRecordedLegacyWslCodexPane, - hasRecordedLegacySharedCodexPane, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' -import { - startLegacyWslRuntimeAuthDrain, - type LegacyWslRuntimeAuthDestination -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import { CodexRuntimeHomeAuthSync } from './runtime-home-service-auth-sync' -type CodexSystemDefaultSnapshot = { - authJson: string | null -} +export type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution +} from './runtime-home-service-types' -type CodexRuntimeLogoutMarker = { - systemDefaultAuthJson: string | null - loggedOutAt: number -} - -type CodexSharedRuntimeAuthProvenance = - | { owner: 'system-default'; authJson: string | null } - | { - owner: 'managed' - accountId: string - systemDefaultBaseline?: { authJson: string | null } - } -type CodexSharedRuntimeAuthPendingProvenance = { - owner: 'pending' - next: CodexSharedRuntimeAuthProvenance - runtimeAuthJson: string | null -} -type CodexSharedRuntimeAuthProvenanceFile = - | CodexSharedRuntimeAuthProvenance - | CodexSharedRuntimeAuthPendingProvenance - | { owner: 'fenced' } -type CodexSharedRuntimeAuthProvenanceStatus = - | { kind: 'missing' | 'fenced' } - | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } - -type CodexRuntimeLogoutMarkerStatus = - | { kind: 'missing' } - | { kind: 'applies' } - | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } - -type CodexReadBackMatch = - | { - kind: 'matched' - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - } - | { kind: 'none' | 'ambiguous' } - -function readCodexLastRefresh(authJson: string): number | null { - try { - const parsed = JSON.parse(authJson) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const value = (parsed as Record).last_refresh - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null - } - if (typeof value !== 'string' || !value.trim()) { - return null - } - const timestamp = Date.parse(value) - return Number.isFinite(timestamp) ? timestamp : null - } catch { - return null - } -} - -function codexAuthIsMonotonicallyFresher( - candidateAuthJson: string, - baselineAuthJson: string -): boolean { - const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) - const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) - if (candidateLastRefresh !== null || baselineLastRefresh !== null) { - return ( - candidateLastRefresh !== null && - baselineLastRefresh !== null && - candidateLastRefresh > baselineLastRefresh - ) - } - return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) -} - -/** - * Why: a skipped Codex quota poll must be distinguishable from "use the - * system-default home". `null` inside `ready` still means the system lane; - * `skip` means do not fetch at all this cycle (#STA-4422). - */ -/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */ -export type CodexMirroredHomeStatus = - | { kind: 'ready'; homePath: string | null } - | { kind: 'unavailable' } - -export type CodexRateLimitHomeResolution = - | { kind: 'ready'; codexHomePath: string | null } - | { kind: 'skip' } - -export class CodexRuntimeHomeService { - // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. - private lastSyncedAccountId: string | null = null - // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). - private lastWrittenAuthJson: string | null = null - // Why: a managed host account refreshes auth in its own home. Remember that - // provenance so a later deselect never adopts stale shared bytes. - private lastHostAccountUsedSelfContainedHome = false - private sharedAuthRefreshBlockedByManagedTransition = false - // Why: transient auth.json read/parse failures must not deselect an account. - private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() - private hostSystemDefaultSessionMigrationPending = false - private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - private pendingHostSystemDefaultSessionMigrationTarget: string | null = null - constructor(private readonly store: Store) { +export class CodexRuntimeHomeService extends CodexRuntimeHomeAuthSync { + constructor(store: Store) { + super(store) this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() this.safeMigrateLegacyManagedState() @@ -212,2066 +16,4 @@ export class CodexRuntimeHomeService { this.initializeLastSyncedState() this.safeSyncForCurrentSelection() } - - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. - this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) - ? null - : normalizeCodexRuntimeSelection(settings).host - } - - /** - * Materializes the runtime home needed before launching the CLI. - * - * Historical session bridging is requested in the background so launch setup - * returns as soon as the active runtime home is ready. - */ - prepareForCodexLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): string | null { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.finishWslLaunchPreparation(wslTarget, homePath) - return homePath - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( - selfContainedAccount, - options?.unavailableManagedHomePath - ) - if (perAccountHome) { - return perAccountHome - } - // Why: only an untrusted home clears the selection; fall through to the - // system default without injecting a path Orca cannot prove it owns. - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: the system default runs Codex on the user's own ~/.codex. - // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; - // the retired mirror is refreshed only for pre-rollout PTYs. - this.reconcileLegacySharedHomeForRetainedPanes() - return null - } - this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) - this.syncForCurrentSelection(target, launchEnv) - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground( - {}, - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - return this.getRuntimeHomePath() - } - - async prepareForCodexLaunchAsync( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): Promise { - if (target?.runtime !== 'wsl') { - return this.prepareForCodexLaunch(target, launchEnv, options) - } - const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - // Why: the retired home may hold the freshest credential, so the first - // direct-home Codex spawn must wait for its bounded guest transaction. - await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) - this.finishWslLaunchPreparation(wslTarget, homePath) - return homePath - } - - beginHostSystemDefaultSessionMigrationLaunch( - codexHomePath: string | null, - options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} - ): boolean | null { - if ( - !this.isHostSystemDefaultSessionMigrationEligible() || - (!codexHomePath && !options.reattached) || - (codexHomePath && - normalizeRuntimePathForComparison(codexHomePath) !== - normalizeRuntimePathForComparison(this.getRuntimeHomePath())) - ) { - return null - } - // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. - return this.invalidateBackfillAfterManagedSystemDefaultLaunch( - options.reattached && !codexHomePath ? undefined : options.launchEnv - ) - } - - isHostSystemDefaultSessionMigrationEligible(): boolean { - return ( - normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && - !hasCustomCodexHomeOverrideForLaunch() - ) - } - - prepareHostSystemDefaultSessionMigrationPass( - scanDates: readonly CodexSessionBackfillDate[] = [] - ): boolean { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - if ( - this.hostSystemDefaultSessionMigrationPending && - this.pendingHostSystemDefaultSessionMigrationTarget !== target - ) { - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true - this.pendingHostSystemDefaultSessionMigrationTarget = target - } - // Why: the launch creates rollouts for these dates; record them durably so a - // force-quit recovers a bounded window instead of re-walking all history. - const markerOwesFullScan = markCodexSessionBackfillMarkerPending( - paths.markerPath, - paths.systemSessionsRoot, - scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] - ) - // Why: the marker is the only place an overflowed pending window survives a - // restart, so its demand has to reach this pass rather than die in the file. - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan - return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan - } - - finishHostSystemDefaultSessionMigrationPass(): void { - this.hostSystemDefaultSessionMigrationPending = false - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - this.pendingHostSystemDefaultSessionMigrationTarget = null - } - - // Why: a managed HOST account runs against its own self-contained CODEX_HOME - // (codex-accounts//home) rather than the shared runtime mirror. Its - // auth.json lives there and codex refreshes it in place, so two accounts never - // race one auth.json. WSL accounts keep their per-distro lane. - private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (!account || this.getWslManagedHomePath(account)) { - return null - } - return account - } - - // Why: session discovery must surface every account's own rollouts wherever they live. - private getManagedAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - const wslHome = this.getWslManagedHomePath(account) - if (wslHome) { - homes.push(wslHome) - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private getManagedHostAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - if (this.getWslManagedHomePath(account)) { - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private prepareSelfContainedManagedHomeForLaunch( - account: CodexManagedAccount, - unavailableManagedHomePath?: string - ): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: refuse the launch rather than silently falling through to the - // system default, which would run a different account behind a UI still - // showing this one. The selection stays put; a later read may succeed. - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(account) - return null - } - const perAccountHome = resolved.homePath - if ( - unavailableManagedHomePath && - normalizeRuntimePathForComparison(unavailableManagedHomePath) === - normalizeRuntimePathForComparison(perAccountHome) - ) { - const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) - if (absence.state !== 'present' && absence.durable) { - this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') - return null - } - // Why: a transient missing/unreadable auth.json is usually codex rotating - // it; keep the selection and launch — the CLI re-reads the settled file. - } - // Why: link the user's real ~/.codex resources and mirror config into THIS - // home (never symlinking into or mutating ~/.codex), so the per-account home - // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - syncSystemCodexResourcesIntoManagedHome(perAccountHome) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath: perAccountHome, - systemHomePath: getSystemCodexHomePath() - }) - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return perAccountHome - } - - // Why: Codex's own `/resume` picker only lists rollouts under the launch - // CODEX_HOME, so a self-contained account home starts out with no history at - // all. Hardlink every other Orca-visible home's rollouts in — after launch, - // since history trees can be large — so switching accounts no longer hides - // the user's conversations. - private startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { - void startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: perAccountHome, - sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() - }) - } - - private getSelfContainedSessionBridgeSourceHomes(): string[] { - return [ - // Why: history-only override lets custom-CODEX_HOME users bridge from the - // home they actually record sessions in; falls back to the real ~/.codex. - resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), - // Why: path only — a per-account install must not materialize the mirror. - resolveOrcaManagedCodexHomePath(), - ...this.getManagedHostAccountHomesForSessionDiscovery() - ] - } - - // Why: the per-account home is both the launch CODEX_HOME and the credential - // store, so codex reads/refreshes auth.json in place — there is no shared-home - // hot-swap or token read-back to reconcile. A trusted home remains selected - // while Codex atomically replaces auth.json. - private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: a sync runs on every app start, exactly when antivirus is busiest. - // An unreadable home must not deselect the account (#STA-4422). - return - } - const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null - if (perAccountHome) { - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - // Why: selection runs well before the user restarts a pane, so history is - // already linked in by the time the newly launched Codex opens /resume. - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return - } - this.clearSelfContainedManagedSelection(account) - } - - /** - * Why: an unreadable home and an untrustworthy one demand opposite responses. - * Only `untrusted` may clear the user's selection; `indeterminate` means we - * could not tell, so callers refuse the operation and leave durable state - * alone (#STA-4422). - */ - private resolveSelfContainedManagedHome( - account: CodexManagedAccount - ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { - const verdict = resolveHostCodexManagedHomeVerdict({ - candidatePath: account.managedHomePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) - if (verdict.kind === 'owned') { - // Preserve the persisted path spelling (notably /var vs /private/var on - // macOS) so injected CODEX_HOME stays stable across the rollout. - return { kind: 'owned', homePath: account.managedHomePath } - } - if (verdict.kind === 'untrusted') { - console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) - return { kind: 'untrusted' } - } - console.warn( - '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', - verdict.error - ) - return { kind: 'indeterminate' } - } - - /** Read-only callers that mutate nothing and simply skip an unusable home. */ - private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' ? resolved.homePath : null - } - - private clearSelfContainedManagedSelection( - account: CodexManagedAccount, - reason = 'home is invalid' - ): void { - console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) - const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== account.id) { - return - } - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - this.lastSyncedAccountId = null - this.lastHostAccountUsedSelfContainedHome = false - } - - private invalidateBackfillAfterManagedSystemDefaultLaunch( - launchEnv?: NodeJS.ProcessEnv - ): boolean | null { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - hasCustomCodexHomeOverrideForLaunch(launchEnv) - ) { - return null - } - if (!this.hostSystemDefaultSessionMigrationPending) { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = - !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) - this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( - paths.systemSessionsRoot - ) - this.hostSystemDefaultSessionMigrationPending = true - } - return this.prepareHostSystemDefaultSessionMigrationPass() - } - - private startWslSessionBridgeForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (process.platform !== 'win32' || !runtimeHomePath) { - return - } - const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) - const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() - if (!distro) { - return - } - // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. - const systemCodexHomePath = - resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { - // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. - void startWslCodexSessionBridgeInBackground({ - distro, - systemCodexHomePath, - managedCodexHomePath: runtimeHomePath - }) - } - } - - getHostCodexHomePathsForSessionDiscovery(): string[] { - const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { - // Why: nested Orca processes can retain an ambient managed CODEX_HOME. - // Per-account lanes no longer bridge real-home history into the shared - // mirror, so include the real root for both directly-routed host lanes. - homes.push(getSystemCodexHomePath()) - } - // Why: account-scoped rollouts live in each account's own home, including WSL. - for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { - homes.push(perAccountHome) - } - return homes.filter((home, index) => homes.indexOf(home) === index) - } - - /** - * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or a WSL - * account, whose home lives inside the distro). - * - * Read-only on purpose: session discovery ranks homes with this before any - * launch prep, so it must create no directories and sync no auth. - */ - getSelectedHostAccountCodexHomePath(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - return selfContainedAccount - ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) - : null - } - - /** - * Same selection, but an unreadable home refuses instead of collapsing to - * `null`. Session resume must not read "no managed selection" out of a failed - * marker stat: another account's readable alias would then win the legacy - * rescan and the pane would resume under that account's credentials while the - * UI still shows this one (#STA-4422). - */ - resolveSelectedHostAccountCodexHomePathForResume(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (!selfContainedAccount) { - return null - } - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(selfContainedAccount) - return null - } - return resolved.homePath - } - - /** Trust-gates host previews without changing WSL routing or durable account state. */ - resolveCodexManagedAccountHomeForInactiveFetch( - account: CodexManagedAccount - ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { - if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { - return { kind: 'ready', homePath: account.managedHomePath } - } - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' - ? { kind: 'ready', homePath: resolved.homePath } - : { kind: 'skip' } - } - - getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { - if (this.getSelfContainedManagedHostAccount()) { - return 'account-home' - } - return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' - } - - getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { - const settings = this.store.getSettings() - const homes = new Map() - for (const ptyId of ptyIds) { - const record = getCodexPaneAccount(ptyId) - if (!record || record.selectionKey !== 'host') { - continue - } - if ( - record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' - ) { - const homePath = this.getRuntimeHomePath() - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - continue - } - if (record.homeRoute !== 'account-home' || !record.accountId) { - continue - } - const account = settings.codexManagedAccounts.find( - (candidate) => candidate.id === record.accountId - ) - if (!account || this.getWslManagedHomePath(account)) { - continue - } - const homePath = this.getTrustedSelfContainedManagedHomePath(account) - if (homePath) { - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - } - } - return [...homes.values()] - } - - // Why: the real-home hook installer flips this gate off when the trust-grant - // client reports the host incapable, keeping that host byte-identical to the - // managed lane instead of shipping status-blind panes. - private realHomeLaneGate: () => boolean = () => true - - setRealHomeLaneGate(gate: () => boolean): void { - this.realHomeLaneGate = gate - } - - // Why: real-home routing applies only to the host system-default selection. - // Managed accounts run in their own homes; Windows (no shell-startup probe) - // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked - // across old homes. - isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - !isShellStartupEnvProbeSupported() - ) { - return false - } - return !hasCustomCodexHomeOverrideForLaunch(launchEnv) - } - - isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { - return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() - } - - reconcileLegacySharedHomeForRetainedPanes(): void { - if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { - return - } - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - syncLegacySharedCodexConfigForRetainedPanes() - } - - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return null - } - const home = getWslHome(distro) - if (home && /^[A-Za-z]:[\\/]/.test(home)) { - const linuxHome = toLinuxPath(home).trim() - return linuxHome.startsWith('/') - ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) - : null - } - return home ? this.joinWslPath(home, '.codex') : null - } - - private finishWslLaunchPreparation( - target: CodexAccountSelectionTarget, - homePath: string | null - ): void { - this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) - this.startWslSessionBridgeForLaunch(target, homePath) - } - - private syncWslConfigAndGlobalInstructionsForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (!runtimeHomePath) { - return - } - const distro = - parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return - } - const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemHomePath || systemHomePath === runtimeHomePath) { - return - } - // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. - syncCodexGlobalInstructionsIntoManagedHome({ - systemHomePath, - managedHomePath: runtimeHomePath - }) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath, - systemHomePath, - systemConfigDir: toLinuxPath(systemHomePath) - }) - } - - // Why: `null` is a real value here — it means "use the system-default lane". - // A skipped poll needs its own channel or the fetcher silently retargets the - // user's real ~/.codex (#STA-4422). - prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - return { - kind: 'ready', - codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) - } - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'owned') { - // Why: the quota fetch reads the account's own auth.json in place; no - // shared-home hot-swap or per-poll resource relink (that is launch prep). - return { kind: 'ready', codexHomePath: resolved.homePath } - } - if (resolved.kind === 'indeterminate') { - // Why: returning null here would NOT skip — the fetcher maps null to - // ~/.codex and would probe the user's real home with a token-refreshing - // app-server. Skip the poll outright and keep the selection. - return { kind: 'skip' } - } - this.clearSelfContainedManagedSelection(selfContainedAccount) - } - if (this.isHostSystemDefaultRealHome()) { - // Why: null lets the fetcher fall back to the main process's inherited - // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the - // managed home, restarting the background OAuth conflict (#5370), so - // pin this non-interactive lane to the native home explicitly. - if (hasRecordedLegacySharedCodexPane()) { - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - } - return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } - } - this.syncForCurrentSelection() - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } - } - - syncForCurrentSelection( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ): void { - if (target?.runtime === 'wsl') { - this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) - return - } - - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - // Why: self-contained managed homes hold their own auth, so the shared - // runtime home's snapshot/hot-swap/read-back machinery below must not run. - this.syncSelfContainedManagedSelection(selfContainedAccount) - return - } - const settings = this.store.getSettings() - if (this.lastHostAccountUsedSelfContainedHome) { - // Why: the account's auth is already canonical in its own home. Reset the - // legacy mirror baseline without reading it; a real-home deselect needs no - // further sync, and the mirror lane below re-seeds from canonical storage. - this.lastHostAccountUsedSelfContainedHome = false - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - if (this.isHostSystemDefaultRealHome(launchEnv)) { - return - } - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: retained daemon panes may own shared auth from a managed launch; - // compatibility reconciliation runs later with durable provenance. - if (this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = true - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - } - return - } - const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: false }) - } - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (activeAccount) { - // Why: only a WSL-managed account can reach here — every host account was - // routed to its own self-contained home above. Its auth lives in the - // distro-local runtime home, so the host mirror only drops its baseline. - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - return - } - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - } - - // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. - clearLastWrittenAuthJson( - accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - ): void { - if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { - this.lastWrittenAuthJson = null - } - } - - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system - // default can be proven to own the mirror at all. - private resolveSystemDefaultMirrorClaim( - runtimeAuth: string, - provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus - ): { ownershipProven: boolean; mirroredAuthJson: string | null } { - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const snapshotAuth = - this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null - const preProvenanceRuntimeRefreshProven = - provenanceStatus.kind === 'missing' && - snapshotAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && - codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) - return { - ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, - mirroredAuthJson: - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshotAuth) - : null - } - } - - private safeSyncForCurrentSelection(): void { - try { - this.syncForCurrentSelection() - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) - } - } - - private safeRecoverInterruptedRuntimeAuthOperation(): void { - try { - recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) - } - } - - private getActiveAccount( - accounts: CodexManagedAccount[], - activeAccountId: string | null - ): CodexManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { - return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null - } - - private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - return this.getWslCodexHomePathForSelection(target) - } - - private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, target) - ) - if (account) { - const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) - if (accountHome) { - return accountHome - } - } - return this.getWslSystemCodexHomePath(target) - } - - private getWslLaunchCodexHomePath( - account: CodexManagedAccount, - targetDistro: string | undefined - ): string | null { - const wslHome = this.getWslManagedHomeIdentity(account) - if (!wslHome) { - return null - } - const accountDistro = wslHome.distro - if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { - return null - } - if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { - return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) - } - return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) - } - - private getWslManagedHomeIdentity( - account: CodexManagedAccount | null - ): { distro: string; linuxHomePath: string } | null { - if (!account) { - return null - } - const distro = account.wslDistro?.trim() - const linuxHomePath = account.wslLinuxHomePath?.trim() - if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { - return { distro, linuxHomePath } - } - const legacyHome = parseWslUncPath(account.managedHomePath) - return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null - } - - private startLegacyWslAuthDrain( - target: CodexAccountSelectionTarget, - options: { throwOnFailure?: boolean } = {} - ): Promise { - if (process.platform !== 'win32') { - return Promise.resolve() - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return Promise.resolve() - } - const guestHome = getWslHome(distro) - const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' - if (!guestHomeLinuxPath.startsWith('/')) { - return Promise.resolve() - } - let legacyPanePresent = true - try { - legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) - } catch (error) { - // Why: unknown pane liveness must preserve the source, but promotion can - // still keep the direct home from launching stale auth. - console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) - } - return startLegacyWslRuntimeAuthDrain( - { - distro, - guestHomeLinuxPath, - legacyPanePresent, - resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) - }, - options - ) - } - - /** Preserve refreshed auth from retained legacy WSL panes before restart. */ - async syncActiveWslSelectionsBeforeRestart(): Promise { - if (process.platform !== 'win32') { - return - } - const settings = this.store.getSettings() - const drains: Promise[] = [] - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() || null - : selectedDistroKey.trim() || null - if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) - } - } - await Promise.all(drains) - } - - private async resolveLegacyWslAuthDestination( - distro: string, - runtimeAuthContents: string - ): Promise { - const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { - const wslHome = this.getWslManagedHomeIdentity(account) - return wslHome?.distro.toLowerCase() === distro.toLowerCase() - ? [{ account, linuxPath: wslHome.linuxHomePath }] - : [] - }) - const accounts = accountHomes.map(({ account }) => account) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null - let reads: WslCodexAuthRead[] - try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) - } catch { - reads = accountHomes.map(() => ({ kind: 'unreadable' })) - if (parsedSystemHome) { - reads.push({ kind: 'unreadable' }) - } - } - const authReads = new Map( - accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) - ) - const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { - accounts, - authReads - }) - if (match.kind === 'ambiguous') { - return null - } - if (match.kind === 'matched') { - const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) - if (!accountHome) { - return null - } - return { - authContents: match.managedAuthContents, - linuxHomePath: accountHome.linuxPath - } - } - - if (!systemHome || !parsedSystemHome) { - return null - } - const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } - if (systemAuth.kind !== 'present') { - return null - } - return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) - ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } - : null - } - - private joinWslPath(basePath: string, ...segments: string[]): string { - return parseWslUncPath(basePath) - ? pathWin32.join(basePath, ...segments) - : join(basePath, ...segments) - } - - private resolveWslDefaultTarget( - target: CodexAccountSelectionTarget - ): CodexAccountSelectionTarget { - if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } - - private findManagedAccountForRuntimeAuth( - runtimeAuthContents: string, - expectedAccountId?: string, - options?: { - accounts: readonly CodexManagedAccount[] - authReads: ReadonlyMap - } - ): CodexReadBackMatch { - const matches: { - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - }[] = [] - let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { - if (expectedAccountId && account.id !== expectedAccountId) { - continue - } - const managedAuthPath = join(account.managedHomePath, 'auth.json') - let managedAuthContents: string - const suppliedRead = options?.authReads.get(account.id) - if (suppliedRead?.kind === 'missing') { - continue - } - if (suppliedRead?.kind === 'unreadable') { - // Why: an unreadable home can never be compared, but letting the read - // throw abandons the scan for every other account — dropping a refresh - // the runtime home holds for one of them. Only its record can rule it - // out as the owner; when it cannot, the scan is no longer unambiguous. - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - if (suppliedRead?.kind === 'present') { - managedAuthContents = suppliedRead.contents - } else { - if (!existsSync(managedAuthPath)) { - continue - } - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - } - if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { - matches.push({ account, managedAuthPath, managedAuthContents }) - } - } - - if (unreadableHomeCouldOwnRuntimeAuth) { - return { kind: 'ambiguous' } - } - if (matches.length === 1) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 ? 'none' : 'ambiguous' } - } - - private runtimeAuthMatchesSystemDefaultIdentity( - runtimeAuthContents: string, - systemDefaultAuthContents: string - ): boolean { - return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) - } - - private safeMigrateLegacySharedAuth(): void { - const settings = this.store.getSettings() - try { - migrateLegacySharedAuthToPerAccountHome({ - activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, - hostAccounts: settings.codexManagedAccounts.filter( - (account) => !this.getWslManagedHomePath(account) - ), - managedAccountsRoot: this.getManagedAccountsRoot(), - metadataDir: this.getRuntimeMetadataDir(), - sharedRuntimeHome: this.getRuntimeHomePath(), - systemCodexHome: getSystemCodexHomePath() - }) - } catch (error) { - // Why: an inconclusive identity, ownership, or filesystem result must - // leave the marker absent so the next startup can retry safely. - console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) - } - } - - private safeMigrateLegacyManagedState(): void { - try { - this.migrateLegacyManagedStateIfNeeded() - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) - } - } - - private safeMigrateLegacyActiveHomePointer(): void { - try { - const activeHomePath = this.getLegacyHostActiveHomePath() - if (!this.legacyActiveHomePathExists(activeHomePath)) { - return - } - this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) - } - } - - private getRuntimeHomePath(): string { - return getOrcaManagedCodexHomePath() - } - - /** - * Resolves the managed home the config mirror actually targets for the - * current HOST selection, or null when no mirror runs for it. - * - * Read-only on purpose: unlike the launch and quota-fetch paths this prepares - * nothing and creates no directories, so surfacing sync health cannot alter - * the state it is reporting on. Returns null for the system default on the - * real-home lane, which runs Codex directly against ~/.codex — there is no - * mirror there, so there is nothing that can fall behind. - */ - getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - // Why: `null` here is a positive claim that no mirror exists, which the - // status channel reports as healthy. An unreadable home is not that. - return { kind: 'unavailable' } - } - return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } - } - if (this.isHostSystemDefaultRealHome()) { - return { kind: 'ready', homePath: null } - } - return { - kind: 'ready', - homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') - } - } - - private getRuntimeAuthPath(): string { - return join(this.getRuntimeHomePath(), 'auth.json') - } - - private getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } - - private getRuntimeLogoutMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') - } - - private getSharedRuntimeAuthProvenancePath(): string { - return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') - } - - private getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - private getLegacyHostActiveHomePath(): string { - return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') - } - - private getMigrationMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-v1.json') - } - - private getMigrationDiagnosticsPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') - } - - private getManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'codex-accounts') - } - - private repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { - if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { - return - } - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - return - } - - mkdirSync(runtimeHomePath, { recursive: true }) - mkdirSync(dirname(activeHomePath), { recursive: true }) - const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - try { - symlinkSync( - runtimeHomePath, - nextLinkPath, - process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() - ? 'junction' - : undefined - ) - try { - renameSync(nextLinkPath, activeHomePath) - } catch (error) { - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - throw error - } - this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) - renameSync(nextLinkPath, activeHomePath) - } - } finally { - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - } - } - - private activeHomeAlreadyPointsToRuntimeHome( - activeHomePath: string, - runtimeHomePath: string - ): boolean { - try { - return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) - } catch { - return false - } - } - - private linkTargetsMatch( - linkTarget: string, - linkPath: string, - expectedTargetPath: string - ): boolean { - const resolvedLinkTarget = isAbsolute(linkTarget) - ? resolve(linkTarget) - : resolve(dirname(linkPath), linkTarget) - return resolvedLinkTarget === resolve(expectedTargetPath) - } - - private legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { - try { - const stat = lstatSync(activeHomePath) - return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) - } catch { - return true - } - } - - private legacyActiveHomePathExists(activeHomePath: string): boolean { - try { - lstatSync(activeHomePath) - return true - } catch { - return false - } - } - - private removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { - try { - const stat = lstatSync(activeHomePath) - if (stat.isSymbolicLink()) { - unlinkSync(activeHomePath) - } else if (this.isWindowsReadableLink(activeHomePath)) { - rmdirSync(activeHomePath) - } - } catch { - // Missing or inaccessible temporary links are handled by the caller. - } - } - - private isWindowsReadableLink(targetPath: string): boolean { - if (process.platform !== 'win32') { - return false - } - try { - readlinkSync(targetPath) - return true - } catch { - return false - } - } - - private migrateLegacyManagedStateIfNeeded(): void { - if (existsSync(this.getMigrationMarkerPath())) { - return - } - - const managedHomes = this.getLegacyManagedHomes() - for (const managedHomePath of managedHomes) { - const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( - /[\\/]/ - )[0] - if (!accountId) { - continue - } - this.migrateLegacyHistory(managedHomePath) - this.migrateLegacySessions(managedHomePath, accountId) - } - - // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. - writeFileAtomically( - this.getMigrationMarkerPath(), - `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` - ) - } - - private getLegacyManagedHomes(): string[] { - const managedAccountsRoot = this.getManagedAccountsRoot() - if (!existsSync(managedAccountsRoot)) { - return [] - } - - const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) - const managedHomes: string[] = [] - for (const entry of accountEntries) { - if (!entry.isDirectory()) { - continue - } - const managedHomePath = join(managedAccountsRoot, entry.name, 'home') - if (existsSync(join(managedHomePath, '.orca-managed-home'))) { - managedHomes.push(managedHomePath) - } - } - return managedHomes.sort() - } - - private migrateLegacyHistory(managedHomePath: string): void { - const legacyHistoryPath = join(managedHomePath, 'history.jsonl') - if (!existsSync(legacyHistoryPath)) { - return - } - - const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') - const existingLines = existsSync(runtimeHistoryPath) - ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) - : [] - const mergedLines = [...existingLines] - const seenLines = new Set(existingLines) - for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { - if (!line || seenLines.has(line)) { - continue - } - seenLines.add(line) - mergedLines.push(line) - } - - if (mergedLines.length === 0) { - return - } - writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) - } - - private migrateLegacySessions(managedHomePath: string, accountId: string): void { - const legacySessionsRoot = join(managedHomePath, 'sessions') - if (!existsSync(legacySessionsRoot)) { - return - } - - const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') - mkdirSync(runtimeSessionsRoot, { recursive: true }) - for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { - const relativePath = relative(legacySessionsRoot, legacyFilePath) - const runtimeFilePath = join(runtimeSessionsRoot, relativePath) - mkdirSync(dirname(runtimeFilePath), { recursive: true }) - if (!existsSync(runtimeFilePath)) { - copyFileSync(legacyFilePath, runtimeFilePath) - continue - } - - const legacyContents = readFileSync(legacyFilePath) - const runtimeContents = readFileSync(runtimeFilePath) - if (runtimeContents.equals(legacyContents)) { - continue - } - - const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) - copyFileSync(legacyFilePath, preservedPath) - this.appendMigrationDiagnostic({ - type: 'session-conflict', - accountId, - runtimeFilePath, - preservedPath - }) - } - } - - private listFilesRecursively(rootPath: string): string[] { - const stat = statSync(rootPath) - if (!stat.isDirectory()) { - return [rootPath] - } - - const files: string[] = [] - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - this.appendListedFiles(files, this.listFilesRecursively(childPath)) - continue - } - if (entry.isFile()) { - files.push(childPath) - } - } - return files.sort() - } - - private appendListedFiles(target: string[], source: readonly string[]): void { - // Why: tolerate directories larger than V8's argument limit for spread calls. - for (const filePath of source) { - target.push(filePath) - } - } - - private getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { - const extension = extname(runtimeFilePath) - const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) - return `${basename}.orca-legacy-${accountId}${extension}` - } - - private appendMigrationDiagnostic(record: Record): void { - const diagnosticsPath = this.getMigrationDiagnosticsPath() - try { - appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) - } catch (error) { - // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. - console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) - } - } - - private captureSystemDefaultSnapshot(options: { force: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') - const snapshot: CodexSystemDefaultSnapshot = { - authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null - } - writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) - } - - private syncRuntimeAuthWithSystemDefault(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (!existsSync(runtimeAuthPath)) { - return - } - - try { - const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') - const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - if (provenance?.owner === 'managed') { - this.captureSystemDefaultSnapshot({ force: true }) - if (!existsSync(systemDefaultAuthPath)) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { - owner: 'system-default' - }) - return - } - const { - ownershipProven: systemDefaultOwnershipProven, - mirroredAuthJson: mirroredSystemDefaultAuth - } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) - if (!existsSync(systemDefaultAuthPath)) { - if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - } - return - } - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - if (runtimeAuth === systemDefaultAuth) { - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - systemDefaultAuth === mirroredSystemDefaultAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) - return - } - // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) - } - } - - private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { - if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = false - return - } - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'managed' - ) { - const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( - provenanceStatus.provenance - ) - if (restoredProvenance) { - provenanceStatus = { kind: 'committed', provenance: restoredProvenance } - } - } - if ( - provenanceStatus.kind === 'fenced' || - (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') - ) { - return - } - const systemAuth = this.readSystemDefaultAuth() - if (!existsSync(runtimeAuthPath)) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const knownSystemAuthBaseline = - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' - ? provenanceStatus.provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson) - : undefined - if (systemAuth === null) { - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' && - provenanceStatus.provenance.authJson === null && - logoutMarkerStatus.kind === 'applies' && - snapshot?.authJson === null - ) { - this.lastWrittenAuthJson = null - return - } - // Why: commit a crashed logout before a managed transition can discard its recovery baseline. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if ( - logoutMarkerStatus.kind === 'system-default-changed' || - (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) - ) { - const replaced = this.writeRuntimeAuth( - systemAuth, - { - owner: 'system-default' - }, - { expectedContents: null } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } - return - } - const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const knownSharedAuth = - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) - : null - // Why: only bytes Orca can prove it wrote belong to the compatibility - // mirror; retained Codex or a managed transition owns every other value. - if (knownSharedAuth === null) { - return - } - const sharedAuthOwnedBySystemDefault = - runtimeAuthBeforeSync === knownSharedAuth || - (provenance?.owner === 'system-default' && - systemAuth === null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) - if (!sharedAuthOwnedBySystemDefault) { - return - } - if (systemAuth === null) { - removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) - if (existsSync(runtimeAuthPath)) { - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - return - } - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - return - } - if (runtimeAuthBeforeSync !== knownSharedAuth) { - return - } - const replaced = this.writeRuntimeAuth( - systemAuth, - { owner: 'system-default' }, - { expectedContents: runtimeAuthBeforeSync } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } catch (error) { - console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) - } - } - - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (existsSync(systemDefaultAuthPath)) { - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - - if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { - // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (options.detectExternalLogin) { - // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (!existsSync(snapshotPath)) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!snapshot) { - console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') - rmSync(snapshotPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!refreshedSnapshot) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if (refreshedSnapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) - return - } - if (snapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) - } - - private writeSystemDefaultAuth(contents: string): void { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) - writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) - this.ensureOwnerOnlyMode(systemDefaultAuthPath) - } - - private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { - // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - } - - private readSystemDefaultAuth(): string | null { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null - } - - private writeRuntimeAuth( - contents: string, - owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, - options?: { expectedContents: string | null } - ): boolean { - // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. - const runtimeAuthPath = this.getRuntimeAuthPath() - if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { - return false - } - const provenance: CodexSharedRuntimeAuthProvenance = - owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner - const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) - if (runtimeAuthComparison === null) { - // Why: an unreadable runtime auth.json may hold a token Codex rotated a - // moment ago. Treating "could not read" as "differs" sent execution to the - // unconditional write below, consuming that rotation and logging the user - // out for good. Refuse; the next sync retries. - return false - } - const runtimeAuthAlreadyMatches = runtimeAuthComparison - if ( - runtimeAuthAlreadyMatches && - this.sharedRuntimeAuthProvenanceMatches( - this.resolveSharedRuntimeAuthProvenanceStatus(), - provenance - ) - ) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.clearRuntimeLogoutMarker() - return true - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson: contents - }) - if (runtimeAuthAlreadyMatches) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - const replaced = options - ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { - mode: 0o600 - }) - : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) - if (!replaced) { - return false - } - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - - /** - * `true`/`false` only when the bytes were actually read; `null` when the file - * could not be read at all. The old `catch { return false }` reported "these - * differ" for a file nobody could open, and every caller reads that as - * permission to write. - */ - private compareFileContents(targetPath: string, contents: string): boolean | null { - try { - return readFileSync(targetPath, 'utf-8') === contents - } catch (error) { - return isDefinitiveAbsence(error) ? false : null - } - } - - private fileContentsEqual(targetPath: string, contents: string): boolean { - return this.compareFileContents(targetPath, contents) === true - } - - private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { - if (expectedContents === null) { - // Why: `!existsSync` does report `true` for a locked file, but this branch - // is not where that matters — the write it guards is - // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the - // real file and refuses on its own. Classifying here would be a guard no - // test can drive. - return !existsSync(targetPath) - } - return this.fileContentsEqual(targetPath, expectedContents) - } - - private ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - private getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { - const marker = this.readRuntimeLogoutMarker() - if (!marker) { - return { kind: 'missing' } - } - const systemDefaultAuthJson = this.readSystemDefaultAuth() - if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { - return { kind: 'applies' } - } - this.clearRuntimeLogoutMarker() - return { kind: 'system-default-changed', systemDefaultAuthJson } - } - - private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { - const marker: CodexRuntimeLogoutMarker = { - systemDefaultAuthJson, - loggedOutAt: Date.now() - } - writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { - mode: 0o600 - }) - } - - private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown - } catch { - return null - } - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - !('systemDefaultAuthJson' in parsed) || - !('loggedOutAt' in parsed) - ) { - return null - } - const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } - if ( - (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || - typeof marker.loggedOutAt !== 'number' - ) { - return null - } - return marker as CodexRuntimeLogoutMarker - } - - private clearRuntimeLogoutMarker(): void { - rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) - } - - private persistSharedRuntimeAuthProvenance( - provenance: CodexSharedRuntimeAuthProvenanceFile - ): void { - writeFileAtomically( - this.getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify(provenance, null, 2)}\n`, - { mode: 0o600 } - ) - } - - private markSharedRuntimeAuthManaged(accountId: string): void { - const status = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - status.kind === 'committed' && - status.provenance.owner === 'managed' && - status.provenance.accountId === accountId - ) { - return - } - const runtimeAuthJson = this.readRuntimeAuthForProvenance() - const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) - const provenance: CodexSharedRuntimeAuthProvenance = { - owner: 'managed', - accountId, - ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson - }) - if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { - this.persistSharedRuntimeAuthProvenance(provenance) - } - } - - private getUntouchedSystemDefaultBaseline( - status: CodexSharedRuntimeAuthProvenanceStatus, - runtimeAuthJson: string | null - ): { authJson: string | null } | null { - if (status.kind !== 'committed') { - return null - } - const baseline = - status.provenance.owner === 'system-default' - ? { authJson: status.provenance.authJson } - : status.provenance.systemDefaultBaseline - return baseline && runtimeAuthJson === baseline.authJson ? baseline : null - } - - private restoreUntouchedSystemDefaultProvenance( - provenance: Extract - ): Extract | null { - const baseline = provenance.systemDefaultBaseline - if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - const restored = { owner: 'system-default' as const, authJson: baseline.authJson } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: restored, - runtimeAuthJson: baseline.authJson - }) - if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - this.persistSharedRuntimeAuthProvenance(restored) - return restored - } - - private sharedRuntimeAuthProvenanceMatches( - status: CodexSharedRuntimeAuthProvenanceStatus, - expected: CodexSharedRuntimeAuthProvenance - ): boolean { - if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { - return false - } - return expected.owner === 'system-default' - ? status.provenance.owner === 'system-default' && - status.provenance.authJson === expected.authJson - : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId - } - - private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { - const provenancePath = this.getSharedRuntimeAuthProvenancePath() - if (!existsSync(provenancePath)) { - return { kind: 'missing' } - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown - } catch { - return { kind: 'fenced' } - } - const committed = this.parseSharedRuntimeAuthProvenance(parsed) - if (committed) { - return { kind: 'committed', provenance: committed } - } - const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) - if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { - return { kind: 'fenced' } - } - try { - this.persistSharedRuntimeAuthProvenance(pending.next) - return { kind: 'committed', provenance: pending.next } - } catch { - return { kind: 'fenced' } - } - } - - private parseSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const provenance = value as Record - if ( - provenance.owner === 'system-default' && - (typeof provenance.authJson === 'string' || provenance.authJson === null) - ) { - return { owner: 'system-default', authJson: provenance.authJson } - } - if ( - provenance.owner !== 'managed' || - typeof provenance.accountId !== 'string' || - provenance.accountId.length === 0 - ) { - return null - } - const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) - if ('systemDefaultBaseline' in provenance && !baseline) { - return null - } - return { - owner: 'managed', - accountId: provenance.accountId, - ...(baseline ? { systemDefaultBaseline: baseline } : {}) - } - } - - private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const baseline = value as Record - return typeof baseline.authJson === 'string' || baseline.authJson === null - ? { authJson: baseline.authJson } - : null - } - - private parsePendingSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthPendingProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const pending = value as Record - const next = this.parseSharedRuntimeAuthProvenance(pending.next) - return pending.owner === 'pending' && - next && - (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) - ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } - : null - } - - private readRuntimeAuthForProvenance(): string | null { - try { - return readFileSync(this.getRuntimeAuthPath(), 'utf-8') - } catch { - return null - } - } - - private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { - let rawContents: string - try { - rawContents = readFileSync(snapshotPath, 'utf-8') - } catch { - return null - } - try { - const parsed = JSON.parse(rawContents) as unknown - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - 'authJson' in parsed && - (typeof (parsed as { authJson: unknown }).authJson === 'string' || - (parsed as { authJson: unknown }).authJson === null) - ) { - return parsed as CodexSystemDefaultSnapshot - } - // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - !('authJson' in parsed) - ) { - return { authJson: rawContents } - } - } catch { - return null - } - return null - } - - clearSystemDefaultSnapshot(): void { - rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) - } } diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 0cb6c02cf3a..1982facd5f3 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -226,16 +226,26 @@ describe('GpuCrashDiagnosticsRecorder', () => { describe('GPU crash diagnostics production wiring', () => { it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const listenerStart = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-preflight.ts'), + 'utf8' + ) + const listenerSource = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") expect(listenerStart).toBeGreaterThan(0) - const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart)) + const listener = listenerSource.slice( + listenerStart, + listenerSource.indexOf('\n })', listenerStart) + ) expect(source).toMatch( /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ ) expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ + /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ ) - expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) + expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) }) }) diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index aec22e2e446..43441250708 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -40,10 +40,13 @@ const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } ] -/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */ +/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ function readChildProcessGoneListener(): string { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const start = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const start = source.indexOf(" app.on('child-process-gone'") expect(start).toBeGreaterThan(0) return source.slice(start, source.indexOf('\n })', start)) } @@ -89,7 +92,7 @@ describe('1.4.190 win32 GPU-child crash cluster', () => { expect(guardStart).toBeGreaterThan(0) expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ + /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ ) // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which // re-couples recovery to the suppression decision, so pin the guard to that check alone. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 3c93f7992ff..23707754ff8 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -108,11 +108,18 @@ describe('handleGpuFallbackRecoveredLaunch', () => { describe('recovered safe-graphics production wiring', () => { it('prompts only after the recovered window is shown and persists both consent states', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - expect(source).toMatch( + const windowSource = readFileSync( + join(__dirname, '..', 'startup', 'main-window-controller.ts'), + 'utf8' + ) + const lifecycleSource = readFileSync( + join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), + 'utf8' + ) + expect(windowSource).toMatch( /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ ) - expect(source).toMatch( + expect(lifecycleSource).toMatch( /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ ) }) diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts index d8cc23c28a3..5f7a10fedec 100644 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ b/src/main/headless-automation-dispatcher-source-boundary.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') function sourceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/main/index.ts b/src/main/index.ts index 5a4964bd729..c66191c82b3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3831 +1,63 @@ -/* eslint-disable max-lines -- main-process entry point; owns app lifecycle, service wiring, window creation, and hook/daemon startup with no cleaner split seam. */ -import { existsSync, statSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { isAbsolute, join } from 'node:path' -import os from 'node:os' -import { - app, - BrowserWindow, - clipboard, - dialog, - ipcMain, - nativeTheme, - powerMonitor, - type Tray, - session -} from 'electron' -import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' -import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' -import { electronApp, is } from '@electron-toolkit/utils' -import { - Store, - initDataPath, - getCanonicalUserDataPath, - migrateMobilePairingDataToCanonicalUserDataPath -} from './persistence' -import { setAppEnvironment } from '../shared/app-environment' -import { ElectronAppEnvironment } from './host/electron-app-environment' -import { setPtyHostBindings } from './ipc/pty-host-bindings' -import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' -import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' -import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' -import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' -import { electronHttpClient } from './host/electron-http-client' -import { setMainHttpClient } from './network/http-client' -import { electronSpeechServiceFactories } from './host/electron-speech-services' -import { setSpeechServiceFactories } from './speech/speech-runtime-service' -import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' -import { setSecretStore } from '../shared/secret-store' -import { ElectronSecretStore } from './host/electron-secret-store' -import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report' -import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' -import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' -import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' -import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' -import { applyAppIcon } from './app-icon' -import { relaunchApp } from './app-relaunch' -import { StatsCollector, initStatsPath } from './stats/collector' -import { initSshHostKeyStoreFile } from './ssh/ssh-host-key-store' -import { AgentSessionTransitionRecorder } from './stats/agent-session-transition-recorder' -import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' -import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' -import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { - killAllPty, - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime, - type CodexHomeLaunchContext -} from './ipc/pty' -import { - initDaemonPtyProvider, - disconnectDaemon, - getDaemonProvider, - listLiveDaemonPtyIds, - shutdownDaemon -} from './daemon/daemon-init' -import { - type CodexPaneHomeRoute, - getCodexPaneAccount, - hasAnyRecordedLegacyWslCodexPane, - hasRecordedManagedHostCodexPane, - isCodexPaneHomeRouteProvenAwayFromSharedHome, - reconcileCodexPaneAccountsWithLivePtys -} from './codex/codex-pane-account-registry' -import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' -import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' -import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' -import { registerCoreHandlers } from './ipc/register-core-handlers/register-core-handlers' -import { initObservability, shutdownObservability } from './observability' -import { registerMobileHandlers } from './ipc/mobile' -import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' -import { classifyError } from './telemetry/classify-error' -import { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry' -import { - indexPersistedPaneKeyPtyIds, - isLocalExecutionHost, - resolveAgentWorkspaceExecutionHostId, - sweepRestoredSubagentsWithoutLiveAgent -} from './agent-hooks/restored-subagent-liveness-sweep' -import { - installManagedAgentHooks, - isAgentStatusHooksEnabled, - removeManagedAgentHooksAsync, - resolveStartupManagedHookAction, - shouldInstallStartupManagedAgentHook, - shouldContinueManagedHookStartup -} from './agent-hooks/managed-agent-hook-controls' -import { initCohortClassifier } from './telemetry/cohort-classifier' -import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' -import { resolveConsent } from './telemetry/consent' -import { triggerStartupNotificationRegistration } from './ipc/startup-notification-registration' -import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' -import { ArtifactCloudService } from './artifacts/artifact-cloud-service' -import { SkillCloudService } from './skills/skill-cloud-service' -import { recoverPendingSkillTransactions } from './skills/skill-transaction-startup-recovery' -import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate' -import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' -import { - fingerprintOrchestrationPeer, - type OrchestrationEnvironmentTransport -} from './runtime/orchestration/environment-transport' -import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing' -import { resolveEnvironment } from '../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../shared/runtime-environments' -import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' -import { - recordRuntimeRpcStartFailure, - showRuntimeRpcStartupFailureDialog -} from './runtime/runtime-rpc-startup-failure' -import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' -import { ServeReadinessPublisher } from './server/serve-readiness' -import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary' -import { DesktopRelayService } from './runtime/relay/desktop-relay-service' -import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker' -import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' -import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' -import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion' -import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n' -import { - getNextDefaultOnAppearanceSettingValue, - registerAppMenu, - rebuildAppMenu -} from './menu/register-app-menu' -import { createGpuAccelerationAboutPanelOptions } from './menu/gpu-acceleration-about-panel' -import { - checkForRemoteServerUpdate, - checkForUpdatesFromMenu, - downloadRemoteServerUpdate, - getRemoteServerUpdaterSnapshot, - installRemoteServerUpdate, - isQuittingForUpdate, - resolveUpdateInstallMode -} from './updater' -import { configureRemoteServerUpdater } from './runtime/remote-server-updater' -import type { UpdateCheckOptions } from '../shared/update-status-types' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { - installServeSupervisorDisconnectQuit, - notifyServeSupervisorReady -} from './serve-update-handoff' -import { - configureElectronNetworkCompatibility, - configureDevUserDataPath, - configureOrcaUserDataPathEnv, - disableUnsupportedChromiumFeatures, - optOutOfHiddenPageWakeUpThrottling, - enableMainProcessGpuFeatures, - installDevParentDisconnectQuit, - installDevParentSignalQuit, - installDevParentWatchdog, - isDevParentShutdownRequested, - patchPackagedProcessPath, - shouldInstallManagedHooks -} from './startup/configure-process' -import { - installUncaughtPipeErrorGuard, - installUnhandledRejectionLogging -} from './startup/main-process-error-guards' -import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' -import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' -import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' -import { - clearGpuFallbackMarker, - readActiveGpuFallbackMarker, - writeGpuFallbackMarker, - type GpuFallbackMarker, - type GpuFallbackEnvironment, - type WindowsGpuFallbackEnvironment -} from './startup/gpu-fallback-marker' -import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' -import { - DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, - DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate -} from './crash-reporting/gpu-crash-fallback-decision' -import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt' -import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement' -import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics' -import { - handleGpuFallbackRecoveredLaunch, - promptForGpuFallbackRecoveredLaunch -} from './crash-reporting/gpu-fallback-recovered-launch' -import { - shouldSuppressDevEducation, - suppressDevEducationForStore -} from './startup/dev-education-suppression' -import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' -import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' -import { startFirstWindowStartupServices } from './startup/first-window-startup-services' -import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery' -import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' -import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './startup/dev-instance-identity' -import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' -import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' -import { - startWindowsDesktopBeforeShellPathReady, - type WindowsDesktopStartupServices -} from './startup/windows-desktop-shell-path-startup' -import { - acquireSingleInstanceLock, - logSingleInstanceLockBypass, - logSingleInstanceLockFailure, - shouldActivateDesktopForSecondInstance, - shouldBypassSingleInstanceLock, - shouldSkipSingleInstanceLock, - SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE -} from './startup/single-instance-lock' -import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' -import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' -import { settledDiffCache } from './git/source-control/git-read-cache-invalidation' +import { app, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' -import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic, - logStartupMilestone -} from './startup/startup-diagnostics' -import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' -import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe' -import { - describeInstallDirAclPoison, - startWindowsInstallDirAclRepairIfPoisoned -} from './startup/windows-install-dir-acl-recovery' -import { presentRendererRecoveryPrompt } from './window/renderer-recovery-prompt' -import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' -import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' -import { registerServeSignalHandlers } from './startup/serve-signal-handlers' -import { - createServeDesktopActivationGate, - settleServeDesktopActivation as settleServeDesktopActivationGate -} from './startup/serve-desktop-activation' -import { RateLimitService } from './rate-limits/service' -import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' -import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' -import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' -import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' -import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' -import { - attachMainWindowServices, - ensureAutoUpdaterConfigured -} from './window/attach-main-window-services' -import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { shutdownPairedRuntimeBrowserClientHosts } from './browser/paired-runtime-browser-client-host-runtime' -import { - getDashboardPopoutWindow, - zoomDashboardPopoutIfFocused -} from './window/dashboard-popout-window' -import { - createSystemTray, - destroySystemTray, - setMacMenuBarIconVisible, - setTrayAttention, - type SystemTrayOptions -} from './tray/system-tray' import { createMacAppActivationHandler } from './window/macos-app-activation' -import { focusExistingMainWindow, safelyRevealWindow } from './window/focus-existing-window' -import { applyBackgroundActivationPolicy } from './window/foreground-activation-policy' -import { notifyMainWindowBecameVisible } from './window/main-window-visibility' -import { CodexAccountService } from './codex-accounts/service' -import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' -import { markCodexProjectTrusted } from './agent-trust-presets' import { - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './codex-accounts/runtime-selection' -import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' -import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' -import { - ensureRealHomeCodexHookState, - isRealHomeCodexHookLaneUsable -} from './codex/codex-real-home-hook-install' -import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry' -import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' -import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' -import { - startCodexStateDbBackfillRecoveryInBackground, - stopCodexStateDbBackfillRecoveries -} from './codex/codex-state-db-backfill-recovery' -import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' -import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume' -import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' -import { ManagedCodexHomeTemporarilyUnavailableError } from './codex-accounts/host-codex-managed-home-ownership' -import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' -import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' -import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' -import { getDefaultWslDistro } from './wsl' -import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash' -import { ClaudeAccountService } from './claude-accounts/service' -import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './claude-accounts/live-pty-gate' -import { StarNagService } from './star-nag/service' -import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent-hooks/server' -import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from './agent-hooks/hook-status-session-tabs-invalidation' -import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' -import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' -import { moveWorktree } from './git/worktree' -import { - configureWindowsHostGitEnvironmentReadiness, - setDefaultWslDistroOverride -} from './git/runner' -import { getRepoIdFromWorktreeId } from '../shared/worktree/id' -import { parseWorkspaceKey } from '../shared/workspace-scope' -import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { AgentBrowserBridge } from './browser/agent-browser-bridge' -import { configureBrowserClientPageAutomationRuntime } from './browser/browser-client-page-automation-runtime' -import { BrowserClientPageCommandError } from './browser/browser-client-page-command-failure' -import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserCertificateTrustController, browserManager } from './browser/browser-manager' -import { RpcDispatcher } from './runtime/rpc/dispatcher' -import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' -import { browserSessionRegistry } from './browser/browser-session-registry' -import { - applyBrowserSessionProxies, - setBrowserNetworkProxySettingsResolver -} from './browser/browser-session-proxy' -import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' -import { - installDocPreviewProtocolHandler, - registerDocPreviewSchemePrivileges -} from './browser/doc-preview-protocol' -import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc' -import { initializeBrowserClientHostId } from './browser/browser-client-host-id' -import { setUnreadDockBadgeCount } from './dock/unread-badge' -import { AutomationService } from './automations/service' -import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' -import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' -import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer' -import { AgentAwakeService } from './agent-awake-service' -import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' -import { registerSystemResumeBroadcast } from './system-resume-broadcast' -import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' -import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime' -import { quitTeardownStartGate } from './quit-teardown-start-gate' -import { beginSshShutdown } from './ipc/ssh-shutdown-drain' -import { PluginService } from './plugins/plugin-service' -import { PluginKillListService } from './plugins/plugin-kill-list-service' -import { getPluginsDataDir } from './plugins/plugin-discovery' -import { PluginMarketplaceService } from './plugins/plugin-marketplace-service' -import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' -import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator' -import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap' -import { resolvePluginHostEntryPath } from './plugins/plugin-host-process' -import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement' -import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins' -import { - normalizePluginConsents, - normalizePluginIdList -} from '../shared/plugins/plugin-consent-state' -import { - recordCoalescedCrashBreadcrumb, - recordCrashBreadcrumb -} from './crash-reporting/crash-breadcrumb-store' -import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' -import { installMainThreadHangWatchdog } from './hang-watchdog/main-thread-hang-watchdog' -import { - consumeHangDetectionMarker, - hangDetectionMarkerPath -} from './hang-watchdog/hang-detection-marker' -import { getMainProcessLifecycleIdentity } from './crash-reporting/main-process-lifecycle-identity' -import { CrashReportStore } from './crash-reporting/crash-report-store' -import { - shouldRecoverRendererAfterProcessGone, - type ExpectedTeardownScope -} from './crash-reporting/process-gone-classification' -import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' -import { startCrashpadCapture } from './crash-reporting/crashpad-capture' -import { startPreGoneProcessMetricsSampling } from './crash-reporting/process-gone-diagnostics' -import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state' -import { - advanceSyntheticTitleSpinnerEntries, - getSyntheticTitleSpinnerPaneKeyToStop, - type SyntheticTitleSpinnerEntry -} from './synthetic-title-spinner' -import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' -import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' -import { - getSyntheticAgentTitleProfile, - shouldDriveSyntheticAgentTitleFromHook, - type SyntheticAgentTitleProfile -} from '../shared/synthetic-agent-title' -import type { AgentStatusState } from '../shared/agent-status-types' -import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' -import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import { - HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDesktopWindowStatus -} from '../shared/runtime-types' -import { LocalPtyProvider } from './providers/local-pty-provider' -import { KeybindingService } from './keybindings/keybinding-service' -import { - applyElectronProxySettings, - setDefaultProxySessionResolver -} from './network/proxy-settings' -import { handleElectronProxyLogin } from './network/electron-proxy-credentials' -import { installElectronProxyRequestGuard } from './network/electron-proxy-request-guard' -import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' -import { CliInstaller } from './cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' -import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' + focusExistingWindow as focusExistingWindowAction, + setMainWindowOpener +} from './startup/main-window-actions' +import { openMainWindow as openMainWindowController } from './startup/main-window-controller' +import { mainProcessState as state } from './startup/main-process-state' +import { runMainProcessPreflight } from './startup/main-process-preflight' +import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-bootstrap' +import { initializeMainProcessReady } from './startup/main-process-ready' +import { installMainProcessQuitHandlers } from './startup/main-process-quit' +import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' -let mainWindow: BrowserWindow | null = null -/** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ -let isQuitting = false -let store: Store | null = null -let stats: StatsCollector | null = null -let claudeUsage: ClaudeUsageStore | null = null -let codexUsage: CodexUsageStore | null = null -let openCodeUsage: OpenCodeUsageStore | null = null -let codexAccounts: CodexAccountService | null = null -let codexRuntimeHome: CodexRuntimeHomeService | null = null -let codexSessionMigration: ReturnType | null = null -let claudeAccounts: ClaudeAccountService | null = null -let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null -let runtime: OrcaRuntimeService | null = null -let rateLimits: RateLimitService | null = null -let runtimeRpc: OrcaRuntimeRpcServer | null = null -const serveReadinessPublisher = new ServeReadinessPublisher() -let desktopRelayService: DesktopRelayService | null = null -let desktopRelayStatus: RelayBrokerStatus = 'offline' -let pendingUnpairedDeviceAuthFailure = false -// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). -let headlessBrowserDisplayAvailable = false - -let starNag: StarNagService | null = null -let agentAwakeService: AgentAwakeService | null = null -let crashReports: CrashReportStore | null = null -let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null -let unsubscribeSystemResumeBroadcast: (() => void) | null = null -let watcherShutdownPromise: Promise | null = null -let watcherShutdownDone = false -let automations: AutomationService | null = null -let pluginService: PluginService | null = null -let pluginKillListService: PluginKillListService | null = null -let pluginMarketplaceService: PluginMarketplaceService | null = null -let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null -let keybindings: KeybindingService | null = null - -function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { - pluginService?.emitEvent( - event.kind === 'created' ? 'worktree.created' : 'worktree.removed', - event.kind === 'created' - ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } - : { worktreeId: event.worktreeId, path: event.path } - ) -} -// Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). -const expectedRendererReload = createWebContentsTimedFlag() -const recoveryReloadInFlight = createWebContentsTimedFlag() -// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. -const pendingOpenSettings = createWebContentsTimedFlag() -const skillShareDeepLinks = new SkillShareDeepLinkState() -let firstWindowStartupServicesReady: Promise = Promise.resolve() -let managedWslCliReconciliationReady: Promise = Promise.resolve() -let managedWslCliStartupBarrierReady: Promise = Promise.resolve() -// Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). -let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled' -const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ - windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD -}) -let activeGpuFallbackMarker: GpuFallbackMarker | null = null -let gpuFallbackActiveThisLaunch = false -let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null -const gpuCrashDiagnostics = - process.platform === 'win32' - ? new GpuCrashDiagnosticsRecorder({ - provider: { - getGPUInfo: (infoType) => app.getGPUInfo(infoType), - getGPUFeatureStatus: () => app.getGPUFeatureStatus() - }, - recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) - }) - : null -let localPtyStartupReady: Promise = Promise.resolve() -let localPtyProviderStartupReady: Promise = Promise.resolve() -const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 - -function handleCodexHomePtySpawned(args: { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -}): void { - // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. - if (args.reattached && args.startedSequence !== undefined) { - const paneAccount = getCodexPaneAccount(args.id) - const homeRoute = - args.reattachedHomeRoute !== undefined - ? (args.reattachedHomeRoute ?? undefined) - : paneAccount?.homeRoute - if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { - codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) - return - } - } - const fullScanRequired = - codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { - reattached: args.reattached, - launchEnv: args.launchEnv - }) ?? null - if (fullScanRequired !== null) { - codexSessionMigration?.beginLaunch( - args.id, - args.reattached === true || fullScanRequired, - args.startedAt, - args.startedSequence - ) - } +function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + return openMainWindowController(options) } -function handlePtyExit(id: string, exitSequence: number): void { - codexSessionMigration?.finishLaunch(id, exitSequence) -} -// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. -// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. -// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its -// command-name lookup finds a port number and strands the launch in an in-process serve. The -// packaged-CLI one matches on the entry path instead, so order cannot affect it either way. -const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (packagedCliEntryRedirect.redirected) { - app.exit(packagedCliEntryRedirect.status) -} -const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (appImageCliRedirect.redirected) { - app.exit(appImageCliRedirect.status) -} -// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the -// Electron process without the CLI rewrite that injects `--serve` (#12677). -// Guarded so a normal GUI launch keeps its original argv array identity. -if (argvRequestsServeMode(process.argv)) { - process.argv = normalizeServeModeArgv(process.argv) -} -const isServeMode = process.argv.includes('--serve') - -function updateGpuAccelerationAboutPanel(): void { - app.setAboutPanelOptions( - createGpuAccelerationAboutPanelOptions({ - appName: app.name, - appVersion: app.getVersion(), - platform: process.platform, - gpuFallbackActive: gpuFallbackActiveThisLaunch, - gpuFeatureStatus - }) - ) -} - -app.on('gpu-info-update', () => { - gpuFeatureStatus = app.getGPUFeatureStatus() - gpuCrashDiagnostics?.warm() - if (app.isReady()) { - updateGpuAccelerationAboutPanel() - } -}) -if (isServeMode) { - reserveServeStdoutForReadiness() -} -const desktopActivationGate = createServeDesktopActivationGate({ - initialState: isServeMode ? 'initializing' : 'ready', - activateWindow: () => { - // Why: an updater replacement must not resurrect the old app bundle. - if (!isQuittingForUpdate()) { - focusExistingWindow() - } - }, - onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) -}) - -// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. -const ENABLE_FIRST_WORK_FOLDER_RENAME = false - -// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. -function maybeAutoRenameBranchOnFirstWorkFromHook(event: { - paneKey: string - tabId: string | undefined - worktreeId: string | undefined - payload: { state: string; prompt?: string; lastAssistantMessage?: string } - isReplay: boolean | undefined -}): void { - const currentStore = store - const currentRuntime = runtime - if (!currentStore || !currentRuntime) { - return - } - void maybeAutoRenameBranchOnFirstWork( - { - paneKey: event.paneKey, - tabId: event.tabId, - worktreeId: event.worktreeId, - state: event.payload.state, - prompt: event.payload.prompt, - assistantMessage: event.payload.lastAssistantMessage, - isReplay: event.isReplay - }, - { - getSettings: () => currentStore.getSettings(), - getRepo: (repoId) => currentStore.getRepo(repoId), - getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name - } - return currentStore.getWorktreeMeta(worktreeId)?.displayName - }, - getFolderWorkspacePath: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath - : undefined - }, - isPendingFirstAgentMessageRename: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return ( - currentStore.getFolderWorkspace(scope.folderWorkspaceId) - ?.pendingFirstAgentMessageRename === true - ) - } - return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true - }, - canRenameOrcaCreatedBranch: (worktreeId) => { - const meta = currentStore.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. - return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true - }, - setDisplayName: (worktreeId, displayName) => { - rememberBranchRenameFailureOutput(worktreeId, null) - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - name: displayName, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentStore.setWorktreeMeta(worktreeId, { - displayName, - // The first-agent title is an intentional user-facing label; keep it stable after the - // generated branch is renamed and across subsequent catalog refreshes. - displayNameIsPinned: true, - pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). - firstAgentMessageRenameError: null - }) - }, - renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME - ? (worktreeId, newLeaf) => - renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getSettings: () => currentStore.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => - currentStore.migrateWorktreeIdentity(oldId, newId), - notifyWorktreeRenamed: (repoId, oldId, newId) => - currentRuntime.notifyWorktreeFolderRenamed(repoId, oldId, newId), - pathExists: async (candidate) => existsSync(candidate), - moveWorktree - }) - : undefined, - setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. - rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const current = currentStore.getFolderWorkspace( - scope.folderWorkspaceId - )?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - firstAgentMessageRenameError: error - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. - currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) - }, - resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId), - onRenamed: (repoIdOrWorktreeId) => { - if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId) - } - } - ) -} - -const devInstanceIdentity = getDevInstanceIdentity(is.dev) -const devAgentHookEndpointNamespace = devInstanceIdentity.isDev - ? devInstanceIdentity.appUserModelId - : undefined - -installUncaughtPipeErrorGuard() -// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). -installUnhandledRejectionLogging() -// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. -process.env.ORCA_APP_VERSION = app.getVersion() -configureRemoteServerUpdater({ - getSnapshot: getRemoteServerUpdaterSnapshot, - check: checkForRemoteServerUpdate, - download: downloadRemoteServerUpdate, - install: installRemoteServerUpdate -}) -patchPackagedProcessPath() -// Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. -if (app.isPackaged && process.platform !== 'win32') { - void hydrateShellPath().then((result) => { - if (result.ok) { - mergePathSegments(result.segments) - return - } - // Why: on failure the seeded fallbacks stay in front. For an nvm user that is - // now their `default` version rather than the newest install, so it is usually - // survivable — but it is still not what their shell would have resolved. Name - // the reason so it shows up in a log bundle instead of as a missing CLI. - console.warn( - `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` - ) - }) -} -configureDevUserDataPath(is.dev) -configureOrcaUserDataPathEnv() -// Why these four lines are one step (#16761): the two above decide where userData lives, and -// everything below may resolve a path. Installing the accessor any later leaves a window where an -// early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the -// pre-override directory and silently writes user state to the wrong place for the whole session. -// Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it -// changes no timing, and initDataPath only joins strings. -setAppEnvironment(new ElectronAppEnvironment()) -// Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) -// changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. -initDataPath() - -// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon. -const TRAY_CREATE_FALLBACK_MS = 12_000 - -const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('before-single-instance-lock', { - version: app.getVersion(), - packaged: app.isPackaged, - platform: process.platform, - osRelease: os.release(), - userData: app.getPath('userData'), - e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - }) - startEventLoopStallProbe() -} -// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). -// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache -// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. -startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) +setMainWindowOpener(openMainWindow) function focusExistingWindow(): void { - focusExistingMainWindow({ - app, - getWindow: () => mainWindow, - openWindow: openMainWindow, - warn: console.warn - }) + focusExistingWindowAction() } function requestDesktopActivation(argv: readonly string[] = []): void { - skillShareDeepLinks.capture(argv, (shareId) => { - mainWindow?.webContents.send('ui:openSkillShare', shareId) + state.skillShareDeepLinks.capture(argv, (shareId) => { + state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) - // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return } - desktopActivationGate.requestActivation() + state.desktopActivationGate?.requestActivation() } -app.on('open-url', (event, url) => { - if (!parseSkillShareId(url)) { - return - } - event.preventDefault() - requestDesktopActivation([url]) -}) - -skillShareDeepLinks.capture(process.argv) - const handleMacAppActivation = createMacAppActivationHandler({ - getWindow: () => mainWindow, + getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation }) -function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { - const state = desktopActivationGate.getState() - return state === 'ready' ? 'openable' : state -} - -function settleServeDesktopActivation(): void { - settleServeDesktopActivationGate(desktopActivationGate, { - hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) - }) -} - -// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. -function createWebContentsTimedFlag(defaultDurationMs = 10_000): { - mark: (webContentsId: number, durationMs?: number) => void - clear: (webContentsId?: number) => void - matches: (webContentsId: number, options?: { consume?: boolean }) => boolean -} { - let state: { webContentsId: number; until: number } | null = null - return { - mark(webContentsId, durationMs = defaultDurationMs) { - state = { webContentsId, until: Date.now() + durationMs } - }, - clear(webContentsId) { - if (webContentsId === undefined || state?.webContentsId === webContentsId) { - state = null - } - }, - matches(webContentsId, options) { - if (!state || Date.now() > state.until) { - state = null - return false - } - if (state.webContentsId !== webContentsId) { - return false - } - if (options?.consume) { - state = null - } - return true - } - } -} - -function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { - expectedRendererReload.mark(webContentsId, durationMs) -} - -function clearExpectedRendererReload(webContentsId?: number): void { - expectedRendererReload.clear(webContentsId) -} - -function getExpectedTeardownScope( - webContentsId?: number, - includeSystemSessionEnd = true -): ExpectedTeardownScope { - return resolveExpectedTeardownScope({ - isQuitting, - isQuittingForUpdate: isQuittingForUpdate(), - isExpectedRendererReload: - webContentsId !== undefined && expectedRendererReload.matches(webContentsId), - includeSystemSessionEnd - }) -} - -function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { - recoveryReloadInFlight.mark(webContentsId, durationMs) -} - -function isRecoveryReloadInFlight(webContentsId: number): boolean { - // Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs. - return recoveryReloadInFlight.matches(webContentsId, { consume: true }) -} - -function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void { - // Why: hook pings arrive many times/sec; coalesce so identical state pings don't fill all 30 breadcrumbs, leaving room for renderer errors. - recordCoalescedCrashBreadcrumb({ - name: 'agent_state_changed', - data: { agentType, state }, - coalesceKey: `agent:${agentType}:${state}`, - minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS - }) -} - -// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. -// Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). -const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -if (bypassSingleInstanceLock) { - // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. - logSingleInstanceLockBypass() -} -const hasSingleInstanceLock = skipSingleInstanceLock - ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, requestDesktopActivation) -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('single-instance-lock-result', { - acquired: hasSingleInstanceLock, - bypassed: bypassSingleInstanceLock, - skippedForDev: skipSingleInstanceLock - }) -} -if (!hasSingleInstanceLock) { - // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() - // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). - app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) -} - -// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. -if (hasSingleInstanceLock) { - // Why first in this block: the accessor throws until installed and everything below may read a - // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so - // installing here changes no timing, in particular not the pre-ready Keychain service-name - // resolution. The app-environment port and the userData capture install earlier still, next to - // the path decision they depend on. - setSecretStore(new ElectronSecretStore()) - // Why at process level, not per-window: pty.ts registers against injected surfaces so - // it can load without electron, and an Electron main process always has ipcMain — - // whether a window exists is irrelevant. Installing this in attachMainWindowServices - // meant `orca serve` registered its PTY handlers against no-ops before any window - // attached, so a paired desktop owner never received them. - setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) - // Why also at process level: the runtime's notification, window-lookup and - // tab-create-reply channel are desktop-only. A Node host installs none and the - // runtime routes notifications to paired clients instead. - setRuntimeDesktopSurface(electronRuntimeDesktopSurface) - // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser - // cluster into the graph. The desktop installs it; a Node host installs none and every - // browser RPC rejects, which capability filtering already tells clients about. - setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) - // Why here: proxy-settings only needed electron for `session.defaultSession`. The - // desktop supplies it; a Node host has no Chromium proxy config to consult, so the - // environment variables are the whole answer there. - setDefaultProxySessionResolver(() => session.defaultSession) - // Why here: integrations use Chromium's network stack on the desktop. A Node host - // falls back to the platform default, which is a real behavioural difference (proxy - // read from the environment, Node's user agent) rather than a transparent swap. - setMainHttpClient(electronHttpClient) - // Why here: constructing the speech services is what pulls Electron's streaming net - // request in. A host without them rejects speech calls rather than pretending. - setSpeechServiceFactories(electronSpeechServiceFactories) - setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) - // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. - const shouldCoupleToDevParent = is.dev && !isServeMode - installDevParentDisconnectQuit(shouldCoupleToDevParent) - installDevParentWatchdog(shouldCoupleToDevParent) - installDevParentSignalQuit(shouldCoupleToDevParent) - // Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff - // path, so it throws until setAppEnvironment() above installs the accessor — which killed every - // `orca serve` process before it could listen. After initDataPath() specifically, so the - // path-equality check against the CLI's env var uses the dir captured before app.setName(). - // Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module - // finishes evaluating, so moving this behind an await would open a real orphan window. - installServeSupervisorDisconnectQuit(isServeMode) - // Why here: initDataPath above gives the canonical userData path for the record file; the write - // itself lands for the next launch (see macos-press-and-hold-default.ts). - applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) - // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. - initSessionParseCachePersistence({ - filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), - appVersion: app.getVersion() - }) - initOrcaProfilePaths() - // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. - initStatsPath() - initClaudeUsagePath() - initCodexUsagePath() - initOpenCodeUsagePath() - // Why: Electron resolves the macOS safeStorage Keychain service name - // (" Safe Storage") before `ready`, so the setName in whenReady is - // too late to move it — dev otherwise lands on the package.json name. Dev-only - // so a packaged build keeps deriving the key from its own CFBundleName. - // Safe here: dev always pins userData via app.setPath (configure-process.ts), - // so setName cannot shift the paths captured just above. - if (shouldApplyPreReadyAppName(devInstanceIdentity)) { - app.setName(devInstanceIdentity.appName) - } - // Why: Electron freezes the privileged scheme table at ready, so the doc-preview - // scheme must be declared here or its webview loses fetch/secure-origin privileges. - registerDocPreviewSchemePrivileges() - // Why: must precede app.whenReady() so Crashpad is installed before the - // first renderer spawns; a CHECK before this point is still exit-code-only. - startCrashpadCapture() - crashReports = CrashReportStore.fromUserData() - recordCrashBreadcrumb('app_started', { - packaged: app.isPackaged, - platform: process.platform, - ...getMainProcessLifecycleIdentity() - }) - disableUnsupportedChromiumFeatures() - // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. - optOutOfHiddenPageWakeUpThrottling() - configureElectronNetworkCompatibility() - enableRendererHeapHeadroom() - maybeApplyGpuFallbackForThisLaunch() - if (!gpuFallbackActiveThisLaunch) { - enableMainProcessGpuFeatures() - } - // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. - headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) -} - -ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) +const preflightReady = runMainProcessPreflight({ + focusExistingWindow, + requestDesktopActivation }) -ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) - await runtime?.prepareStructuredAgentSessionStartupRestoration() -}) - -ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => - recoverLegacyWorkerTerminalsForRendererStartup({ - firstWindowStartupServicesReady, - managedWslCliStartupBarrierReady, - localPtyProviderStartupReady, - reconcile: async () => { - await runtime?.refreshRestoredOrchestrationAuthority() - return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) - }, - onDeferredRecoveryError: (error) => { - console.warn('[orchestration] legacy worker provider-ready recovery failed', error) - } - }) -) - -// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. -ipcMain.handle('ui:consumePendingOpenSettings', (event) => - pendingOpenSettings.matches(event.sender.id, { consume: true }) -) - -ipcMain.handle('ui:consumePendingSkillShare', () => { - return skillShareDeepLinks.consume() -}) - -ipcMain.handle( - 'app:startupDiagnostic', - (_event, event: string, details?: Record) => { - if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) { +if (preflightReady) { + app.on('open-url', (event, url) => { + if (!parseSkillShareId(url)) { return } - logStartupMilestone(event, details && typeof details === 'object' ? details : {}) - } -) - -/** A PTY that dies while Orca is down never runs the teardown that clears pane - * state, so hydrate can rebuild a Claude subagent roster that no later hook can - * retire — pinning the pane 'working' and locking its agent out of hibernation - * for good. Once provider and hook hydration settle, targeted PTY liveness can - * retire only rows whose local owner is proven gone. */ -async function reapRestoredSubagentsWithoutLiveAgent(): Promise { - const currentStore = store - if (!currentStore) { - return - } - const provider = getDaemonProvider() - if (!provider) { - return - } - const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( - currentStore.getWorkspaceSession().terminalLayoutsByTabId ?? {} - ) - await sweepRestoredSubagentsWithoutLiveAgent({ - probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), - isLocalExecutionHost: (worktreeId) => - isLocalExecutionHost( - resolveAgentWorkspaceExecutionHostId(worktreeId, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getWorktreeMeta: (resolvedWorktreeId) => currentStore.getWorktreeMeta(resolvedWorktreeId), - getFolderWorkspace: (folderWorkspaceId) => - currentStore.getFolderWorkspace(folderWorkspaceId), - getProjectGroups: () => currentStore.getProjectGroups() - }) - ), - getBoundPtyIdForPaneKey: getPtyIdForPaneKey, - getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), - reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => - agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalHost, - isLocalPaneAgentLive, - isLocalPaneLivenessEvidenceCurrent - ) + event.preventDefault() + requestDesktopActivation([url]) }) -} - -function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { - logStartupMilestone('first-window-startup-services-start') - const startupServices = startFirstWindowStartupServices({ - // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. - startDaemonPtyProvider: async (signal) => { - logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) - // Why: only GUI-spawned macOS daemons watch for login-session death; a headless - // serve daemon must survive its spawning session ending (SSH disconnect). - await initDaemonPtyProvider(signal, { - macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode - }) - // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. - const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() - if (codexRuntimeHome && (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane())) { - const livePtyIds = await listLiveDaemonPtyIds() - if (livePtyIds) { - reconcileCodexPaneAccountsWithLivePtys(livePtyIds) - const settings = store?.getSettings() - // Why (#16441): each retained home can run a codex app-server grant - // session. Awaiting them here delayed the first window by N sessions; - // a retained shell cannot invoke Codex before this provider serves. - if (hasRetainedManagedHostPane) { - void reconcileRetainedCodexHookHomes({ - hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, - runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) - }).catch((error: unknown) => { - console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) - }) - } - } - } - // Why: retained shells can invoke Codex immediately after the startup gate. - codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() - logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) - }, - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. - startAgentHookServer: async () => { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) - // Why (#11217): the hook listener fails open on every request error, so an IDS resetting - // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. - // Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture. - agentHookServer.setTransportInterferenceListener((report) => { - track('agent_hook_transport_blocked', { count: report.count }) - }) - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) - }, - onDaemonError: (error) => { - // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). - const reason = error instanceof Error ? error.message : String(error) - console.error( - `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` - ) - track('daemon_start_failed', classifyError(error)) - }, - onAgentHookServerError: (error) => { - // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) - void startupServices.firstWindowReady.then(() => { - logStartupMilestone('first-window-startup-services-ready') - }) - void startupServices.localPtyReady.then(() => { - logStartupMilestone('local-pty-startup-ready') - void reapRestoredSubagentsWithoutLiveAgent().catch((error) => { - console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + state.skillShareDeepLinks.capture(process.argv) + registerMainProcessIpcHandlers() + installMainProcessQuitHandlers() + void app.whenReady().then(async () => { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation }) }) - return startupServices } - -function bindTerminalRuntimeStartupServices( - services: Promise -): void { - firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) - localPtyStartupReady = services.then((value) => value.localPtyReady) - localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) -} - -async function prepareCodexRuntimeHomeForLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -): Promise { - if ( - target?.runtime !== 'wsl' && - launchContext?.launchAgent === 'codex' && - launchContext.workspacePath - ) { - try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. - await markCodexProjectTrusted(launchContext.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) - } - } - const ensureRealHomeHooksIfSelected = async (): Promise => { - if ( - target?.runtime === 'wsl' || - !codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv) - ) { - return false - } - // Why (flag ON, system default): the hook entry must exist — appended last - // and trusted by codex's own app-server grant — in the real ~/.codex before - // the pane spawns. An incapable grant flips the lane gate so the launch - // below falls back to the managed home instead of a status-blind pane. - await ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), - userDataPath: app.getPath('userData') - }) - return true - } - let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — - // the fallbacks below all key off `null`, which means "system default", so - // swallowing the refusal would launch the wrong account (#STA-4422). - let runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - if (runtimeHomePath === null && !realHomeHooksPrepared) { - // Why: launch prep can reject an untrusted managed home and clear its - // selection. Establish hook capability for that newly selected lane, then - // re-resolve if the capability gate rejects it. - realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - if (realHomeHooksPrepared) { - runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - } - } - if (runtimeHomePath === null && target?.runtime !== 'wsl') { - // Why: Codex runs on the user's real ~/.codex; the managed-home hook - // install below would target a home Codex never reads on this lane. - return null - } - const hookTarget = - target?.runtime === 'wsl' - ? { - runtime: 'wsl' as const, - wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() - } - : target - const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) - try { - // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. - const status = await codexHookService.prepareRuntimeHomeForLaunch( - runtimeHomePath, - hookTarget, - hooksEnabled - ) - if (status.state === 'error') { - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - status.detail - ) - } - } catch (error) { - // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - error - ) - } - return runtimeHomePath -} - -async function prepareCodexSessionResumeForLaunch(args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}): Promise { - if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { - return null - } - const systemHomePath = getSystemCodexHomePath() - // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. - const trustedHomes = [ - systemHomePath, - ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() - ] - const settingsStore = store - // Why: resolved eagerly, once, before any ranking or provenance match. The - // marker read used to be deferred into the ranking thunk so a - // provenance-present resume never paid for it, but that optimisation let an - // unreadable selected home reach the PTY as "no selection": the provenance - // branch simply omits the account from `trustedHomes` and another account's - // readable alias wins. A throw here refuses the whole resume instead - // (#STA-4422). - const selectedAccountCodexHome = - codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume() - // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is - // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). - const preparation = await prepareCodexSessionResume({ - sessionId: args.providerSession.id, - transcriptPath: args.providerSession.transcriptPath, - trustedCodexHomes: trustedHomes, - // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; - // rank it by the current selection so settings insertion order can never decide the account. - getSelectedAccountCodexHome: () => selectedAccountCodexHome, - systemCodexHomePath: systemHomePath, - // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must - // outrank the path-sorted account homes or a system-default selection resumes as an account. - sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), - resolveVerifiedResumeHome: async (sessionSource) => { - let migrated = { useRealCodexHome: false } - try { - migrated = await prepareLegacySharedCodexSessionResume( - { - agent: 'codex', - executionHostId: 'local', - filePath: sessionSource.transcriptPath, - codexHome: sessionSource.homePath - }, - { - isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), - systemCodexHomePath: systemHomePath - } - ) - } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw error - } - // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. - console.warn( - '[codex-session-resume] Legacy rollout migration failed; using origin home:', - error - ) - } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - - if (args.workspacePath) { - try { - await markCodexProjectTrusted(args.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) - } - } - const isSystemHome = - normalizeRuntimePathForComparison(resumeHome) === - normalizeRuntimePathForComparison(systemHomePath) - const hooksEnabled = isAgentStatusHooksEnabled(settingsStore.getSettings()) - try { - if (isSystemHome) { - await ensureRealHomeCodexHookState({ - hooksEnabled, - userDataPath: app.getPath('userData') - }) - } else if (hooksEnabled) { - await codexHookService.installForLaunchPrep(resumeHome) - } else { - await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) - } - } catch (error) { - // Why: hook repair is best-effort; session provenance must still win over the currently selected home. - console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) - } - return resumeHome - } - }) - return preparation.outcome === 'resume' - ? { - ...preparation, - reconcileSharedRuntimeAuth: - normalizeRuntimePathForComparison(preparation.codexHomePath) === - normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) - } - : preparation -} - -// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. -function showMainWindowFromTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - safelyRevealWindow(mainWindow) - return - } - if (!isQuittingForUpdate()) { - openMainWindow() - } -} - -function openSettingsFromSystemMenu(): void { - showMainWindowFromTray() - const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null - if (!targetWindow) { - return - } - recordCrashBreadcrumb('settings_opened') - - // Why: no signal proves the renderer listener is attached — push, and also leave a one-shot intent the unmounted renderer pulls at mount. - targetWindow.webContents.send('ui:openSettings') - // Why: untimed — any TTL can be outrun by a slow cold start; id-scoping + consume-on-read still prevent leaking to a later renderer. - pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) -} - -function quitFromSystemTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a hidden session may veto shutdown with a save/discard prompt, so make the window visible. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the 'close' handler tears down instead of re-hiding to tray. - isQuitting = true - app.quit() -} - -// Why: menu/tray are clickable before anything else configures the updater. -function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { - ensureAutoUpdaterConfigured() - checkForUpdatesFromMenu(options) -} - -function getSystemTrayOptions(): SystemTrayOptions | null { - if (!store) { - return null - } - return { - appIcon: store.getSettings().appIcon, - isDevInstance: devInstanceIdentity.isDev, - devInstanceLabel: devInstanceIdentity.devLabel, - onOpen: showMainWindowFromTray, - onOpenSettings: openSettingsFromSystemMenu, - onCheckForUpdates: () => { - // Why: updater status renders in the main window, so a bare check would complete invisibly. - showMainWindowFromTray() - runUserInitiatedUpdateCheck() - }, - onQuit: quitFromSystemTray - } -} - -function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { - if (process.platform !== 'darwin' || isServeMode) { - return null - } - const options = getSystemTrayOptions() - return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null -} - -function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { - logStartupMilestone('open-main-window-start') - if (!store) { - throw new Error('Store must be initialized before opening the main window') - } - if (!runtime) { - throw new Error('Runtime must be initialized before opening the main window') - } - if (!stats) { - throw new Error('Stats must be initialized before opening the main window') - } - if (!claudeUsage) { - throw new Error('Claude usage store must be initialized before opening the main window') - } - if (!codexUsage) { - throw new Error('Codex usage store must be initialized before opening the main window') - } - if (!openCodeUsage) { - throw new Error('OpenCode usage store must be initialized before opening the main window') - } - if (!rateLimits) { - throw new Error('Rate limit service must be initialized before opening the main window') - } - if (!automations) { - throw new Error('Automation service must be initialized before opening the main window') - } - if (!codexAccounts) { - throw new Error('Codex account service must be initialized before opening the main window') - } - if (!codexRuntimeHome) { - throw new Error('Codex runtime home service must be initialized before opening the main window') - } - if (!claudeAccounts) { - throw new Error('Claude account service must be initialized before opening the main window') - } - if (!claudeRuntimeAuth) { - throw new Error( - 'Claude runtime auth service must be initialized before opening the main window' - ) - } - if (!keybindings) { - throw new Error('Keybinding service must be initialized before opening the main window') - } - - // Why: Chromium's BrowserWindow ctor resets userData to a Protected DACL, breaking writes; re-grant ACEs (marker-gated to avoid a ~60s startup stall). - if (process.platform === 'win32') { - logStartupMilestone('acl-grant-start') - ensureWindowsUserDataAclGrant(app.getPath('userData'), { - onDone: (result) => { - logStartupMilestone('acl-grant-done', { mode: result.mode }) - if (result.mode === 'failed') { - console.warn('[win32-acl] userData ACL grant failed:', result.reason) - } - } - }) - // Why here: read-only, and the install DACL is the one thing a 0x80000003 - // child death cannot tell us about itself. See electron/electron#51761. - probeWindowsInstallDirAcl({ - isServeMode, - onDone: (data) => - startWindowsInstallDirAclRepairIfPoisoned(data, { - isServeMode, - userDataPath: app.getPath('userData'), - appVersion: app.getVersion() - }) - }) - } - - const window = createMainWindow(store, { - getIsQuitting: () => isQuitting, - onQuitAborted: () => { - isQuitting = false - clearExpectedRendererReload() - }, - onRendererProcessGone: (details, webContentsId) => { - recordProcessGoneCrash( - 'renderer', - 'renderer', - details.reason, - details.exitCode ?? null, - { - processType: 'renderer' - }, - webContentsId - ) - }, - shouldRecoverRenderer: (details, webContentsId) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: getExpectedTeardownScope(webContentsId, false) - }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { - recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { - reason: details.reason, - exitCode: details.exitCode ?? null, - recentRecoveryCount - }) - void showRendererRecoveryPrompt(recentRecoveryCount) - }, - deferLoad: true, - ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), - title: devInstanceIdentity.name, - getKeybindings: () => keybindings?.getOverrides(), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - // Why: the recovery reload re-fires did-finish-load; flag it so the local-PTY orphan sweep skips that reload (#5787). - onBeforeRecoveryReload: (webContentsId) => { - markRecoveryReloadInFlight(webContentsId) - recordDurableCrashBreadcrumb('renderer_recovery_reload') - } - }) - recordCrashBreadcrumb('main_window_created') - logStartupMilestone('window-created') - // Why: Windows Tray construction can block synchronously on Shell_NotifyIcon, so both platforms defer creation to after first paint. - let trayCreated = false - const createSystemTrayDeferred = (): void => { - if (trayCreated || window.isDestroyed() || isQuitting || !store) { - return - } - trayCreated = true - if (process.platform === 'darwin') { - // Why: route through syncMacMenuBarIcon so startup and the live toggle share one serve-mode/visibility policy. - if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { - logStartupMilestone('tray-created') - } - return - } - const options = getSystemTrayOptions() - if (options && createSystemTray(options)) { - logStartupMilestone('tray-created') - } - } - window.once('ready-to-show', () => { - logStartupMilestone('ready-to-show') - setImmediate(createSystemTrayDeferred) - }) - window.once('show', () => { - logStartupMilestone('window-shown') - void presentGpuFallbackRecoveredLaunchPrompt(window) - }) - const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) - trayCreateFallback.unref?.() - - // Why: telemetry-plan.md anchors default-on app_opened to the first main-window load; this path fires only once consent is already enabled. - const rendererWebContentsId = window.webContents.id - const onFirstWindowLoad = (): void => { - clearExpectedRendererReload(rendererWebContentsId) - recordCrashBreadcrumb('main_window_loaded') - logStartupMilestone('did-finish-load') - if (!store) { - return - } - const consent = resolveConsent(store.getSettings()) - if (consent.effective !== 'enabled') { - return - } - trackAppOpenedOnce() - } - window.webContents.on('did-finish-load', onFirstWindowLoad) - - registerCoreHandlers( - store, - runtime, - stats, - claudeUsage, - codexUsage, - openCodeUsage, - codexAccounts, - claudeAccounts, - rateLimits, - rendererWebContentsId, - automations, - { - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }, - agentAwakeService ?? undefined, - crashReports ?? undefined, - keybindings, - { - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - onBeforeRelaunch: async () => { - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - }, - onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), - onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() - }, - pluginService ?? undefined, - pluginMarketplaceService && pluginMarketplaceInstaller - ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } - : undefined - ) - automations.setWebContents(window.webContents) - automations.start() - attachMainWindowServices( - window, - store, - runtime, - prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - { - prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, - awaitLocalPtyStartup: () => localPtyStartupReady, - awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, - onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { - if (window.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) - }, - // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). - isRecoveryReloadInFlight, - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode), - onWorktreeLifecycle: emitPluginWorktreeLifecycle - } - ) - // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. - initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) - rateLimits.attach(window) - // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. - rateLimits.start({ fetchImmediately: false }) - window.on('closed', () => { - if (mainWindow === window) { - mainWindow = null - } - clearExpectedRendererReload(rendererWebContentsId) - automations?.setWebContents(null) - // Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations. - agentHookServer.setListener(null) - agentHookServer.setPaneStatusClearListener(null) - setMigrationUnsupportedPtyListener(null) - // Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes. - stopAllSyntheticTitleSpinners() - }) - mainWindow = window - window.on('show', resumeSyntheticTitleSpinnerTimer) - window.on('restore', resumeSyntheticTitleSpinnerTimer) - window.on('hide', stopSyntheticTitleSpinnerTimer) - window.on('minimize', stopSyntheticTitleSpinnerTimer) - // Why: visibility-gated pollers (SSH port scanner) park while hidden and resume on this signal; re-wired per window since dock re-activation recreates it. - window.on('show', notifyMainWindowBecameVisible) - window.on('restore', notifyMainWindowBecameVisible) - // Why: user is back on show/restore, so clear the tray attention dot set while hidden (see notifications.ts). - window.on('show', () => setTrayAttention(false)) - window.on('restore', () => setTrayAttention(false)) - agentHookServer.setListener( - ({ - paneKey, - tabId, - worktreeId, - connectionId, - payload, - receivedAt, - stateStartedAt, - launchToken, - providerSession, - providerSessionOnly, - promptInteractionKey, - restoredUnconfirmed, - observation, - isReplay - }) => { - if (mainWindow?.isDestroyed()) { - return - } - if (providerSessionOnly) { - // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. - mainWindow?.webContents.send('agentStatus:set', { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(observation ? { observation } : {}), - providerSessionOnly: true - }) - return - } - if (!restoredUnconfirmed) { - maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) - } - const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) - const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false - const statusEvent = { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(promptInteractionKey ? { promptInteractionKey } : {}), - ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(observation ? { observation } : {}), - ...(orchestration ? { orchestration } : {}) - } - mainWindow?.webContents.send('agentStatus:set', statusEvent) - if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { - getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) - } - recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) - // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. - const profile = getSyntheticAgentTitleProfile(payload.agentType) - if ( - profile && - shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && - !suppressSyntheticCodexAutoApprovalTitle - ) { - driveSyntheticTitleFromHook(paneKey, payload.state, profile) - } - } - ) - agentHookServer.setPaneStatusClearListener((clear) => { - if (mainWindow?.isDestroyed()) { - return - } - mainWindow?.webContents.send('agentStatus:clear', clear) - getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) - }) - setMigrationUnsupportedPtyListener((event) => { - if (mainWindow?.isDestroyed()) { - return - } - if (event.type === 'set') { - mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) - } else { - mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { - ptyId: event.ptyId - }) - } - }) - logStartupMilestone('load-start') - loadMainWindow(window) - return window -} - -function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openFeatureTour') -} - -function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openSetupGuide') -} - -function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openCrashReport') -} - -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. -async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { - await presentRendererRecoveryPrompt({ - recentRecoveryCount, - isQuitting: () => isQuitting, - diagnose: describeInstallDirAclPoison, - showMessageBox: (options) => { - const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) - }, - copyToClipboard: (text) => clipboard.writeText(text), - reload: () => { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') - // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. - loadMainWindow(mainWindow) - }, - quit: () => { - isQuitting = true - app.quit() - } - }) -} - -function getGpuFallbackEnvironment(): GpuFallbackEnvironment { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - platform: process.platform - } -} - -function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { - const environment = getGpuFallbackEnvironment() - if (environment.platform !== 'win32') { - return null - } - return { ...environment, platform: 'win32' } -} - -// Writes both crash-time and post-recovery consent states through one build-scoped path. -function persistGpuFallbackMarker( - userDataPath: string, - info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } -): boolean { - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return false - } - try { - writeGpuFallbackMarker(userDataPath, info, environment) - return true - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return false - } -} - -// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. -function maybeApplyGpuFallbackForThisLaunch(): void { - if (isServeMode || process.platform !== 'win32') { - return - } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) - if (!marker) { - return - } - activeGpuFallbackMarker = marker - app.disableHardwareAcceleration() - const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) - gpuFallbackActiveThisLaunch = true - // Why: with no GPU child left, child-process-gone can't report a GPU fault, so - // name the applied switches in the trail any later crash report carries. - recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow, - switches: appliedSwitches.join(',') - }) -} - -async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise { - const marker = activeGpuFallbackMarker - if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) { - return - } - // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. - activeGpuFallbackMarker = null - const userDataPath = app.getPath('userData') - await handleGpuFallbackRecoveredLaunch({ - isQuitting: () => isQuitting, - prompt: () => promptForGpuFallbackRecoveredLaunch(window), - confirmSafeGraphics: () => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: marker.engagedAt, - crashesInWindow: marker.crashesInWindow, - userConfirmed: true - }) - }, - clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), - onSafeGraphicsKept: () => - recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { - crashesInWindow: marker.crashesInWindow - }), - restartWithHardware: () => { - isQuitting = true - relaunchApp('gpu-fallback', { - mode: 'hardware-retry', - crashesInWindow: marker.crashesInWindow - }) - destroySystemTray() - app.exit(0) - } - }) -} - -// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. -async function handleGpuChildCrash( - reason: string, - exitCode: number | null, - crashedAt: number -): Promise { - // Software rendering already active or shutting down: nothing more to do. - if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) { - return - } - const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt) - if (!result.shouldEngageFallback) { - return - } - const fallbackData = { - processReason: reason, - exitCode, - crashesInWindow: result.crashesInWindow - } - const userDataPath = app.getPath('userData') - await engageGpuFallbackAfterCrashBurst( - { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, - { - isQuitting: () => isQuitting, - onEngaged: (engagement) => - recordCrashBreadcrumb('gpu_fallback_engaged', { - reason: engagement.reason, - exitCode: engagement.exitCode, - crashesInWindow: engagement.crashesInWindow - }), - persistMarker: (engagement) => - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: false - }), - confirmMarker: (engagement) => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: true - }) - }, - clearMarker: () => clearGpuFallbackMarker(userDataPath), - promptForRestart: () => - promptForGpuFallbackRestart( - mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - ), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show restart prompt:', error), - onRestartDeferred: () => - recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), - restartIntoSafeGraphics: () => { - isQuitting = true - relaunchApp('gpu-fallback', fallbackData) - // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. - destroySystemTray() - app.exit(0) - } - } - ) -} - -function recordProcessGoneCrash( - source: 'renderer' | 'child', - processType: string, - reason: string, - exitCode: number | null, - details: Record, - webContentsId?: number -): void { - recordProcessGoneCrashEvent(crashReports, { - source, - processType, - reason, - exitCode, - expectedTeardown: getExpectedTeardownScope(webContentsId), - details, - ...(webContentsId !== undefined ? { webContentsId } : {}) - }) -} - -function shutdownWatchersOnce(): Promise { - if (watcherShutdownDone) { - return Promise.resolve() - } - if (!watcherShutdownPromise) { - // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. - stopFolderRepoGitUpgradeWatch() - watcherShutdownPromise = Promise.allSettled([ - closeAllWatchers(), - disposeWorktreeBaseDirectoryWatchers() - ]) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - console.error('[filesystem-watcher] shutdown failed:', result.reason) - } - } - }) - .then(() => { - watcherShutdownDone = true - }) - } - return watcherShutdownPromise -} - -// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. -// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX. -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -const SPINNER_INTERVAL_MS = 80 - -const syntheticTitleSpinnerByPaneKey = new Map< - string, - SyntheticTitleSpinnerEntry ->() -let syntheticTitleSpinnerTimer: ReturnType | null = null - -type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} - -function getBundledWebClientRoot(): string | undefined { - const appPath = app.getAppPath() - const roots = [ - join(appPath, 'out', 'web'), - // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. - join(appPath, '..', 'web') - ] - return roots.find((root) => existsSync(join(root, 'web-index.html'))) -} - -async function renderTerminalPairingQr(pairingUrl: string): Promise { - // Why dynamic: qrcode is only reachable from mobile pairing, so launch should - // not parse it for the majority who never pair a device. - const QRCode = await import('qrcode') - try { - return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) - } catch { - try { - return await QRCode.toString(pairingUrl, { type: 'utf8' }) - } catch { - return null - } - } -} - -async function printServeReady(options: ServeOptions): Promise { - if (!runtime || !runtimeRpc) { - throw new Error('Runtime server must be initialized before printing serve readiness') - } - if (options.recipeJson) { - if (!options.projectRoot) { - throw new Error('--serve-recipe-json requires --serve-project-root') - } - if (!isAbsolute(options.projectRoot)) { - throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) - } - const projectRootStats = statSync(options.projectRoot) - if (!projectRootStats.isDirectory()) { - throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) - } - } - const boundEndpoint = runtimeRpc.getWebSocketEndpoint() - const advertised = boundEndpoint - ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) - : null - const pairing = options.noPairing - ? ({ - available: false, - reason: 'disabled_by_operator', - guidance: 'Restart without --no-pairing to create a client pairing offer.' - } as const) - : runtimeRpc.createPairingOffer({ - address: options.pairingAddress, - name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' - }) - const pairingQr = - pairing.available && options.mobilePairing - ? await renderTerminalPairingQr(pairing.pairingUrl) - : null - await serveReadinessPublisher.publish( - { - runtimeId: runtime.getRuntimeId(), - boundEndpoint, - advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, - // Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair. - managedWslCliReconciliation: managedWslCliReconciliationStatus, - pairing: pairing.available - ? { - available: true, - url: pairing.pairingUrl, - endpoint: pairing.endpoint, - deviceId: pairing.deviceId, - webClientUrl: pairing.webClientUrl, - scope: options.mobilePairing ? 'mobile' : 'runtime', - qr: pairingQr - } - : pairing - }, - options.recipeJson - ? { mode: 'recipe-json', projectRoot: options.projectRoot! } - : { mode: options.json ? 'json' : 'human' } - ) - notifyServeSupervisorReady(runtime.getRuntimeId()) -} - -// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. -registerPaneKeyTeardownListener((paneKey) => { - stopSyntheticTitleSpinner(paneKey) -}) - -// Why: the spinner is a stand-in for a live hook status, so it must retire with the row it -// stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a -// working title long after the agent finished (#13890). Both paths are covered: the -// pane-scoped clear fan-out, and user dismissal, which never routes through it. -agentHookServer.subscribePaneStatusClear((clear) => { - const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) - if (paneKey) { - stopSyntheticTitleSpinner(paneKey) - } -}) -agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) - -function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - // Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL. - if ( - !shouldSendSyntheticTitleFrame({ - force: options.force === true, - windowVisible: isSyntheticTitleWindowVisible() - }) - ) { - return - } - // Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes. - runtime?.ingestSyntheticTitleFrame(ptyId, data) - // Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts). - if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { - mainWindow.webContents.send('pty:data', { id: ptyId, data }) - } -} - -function isSyntheticTitleWindowVisible(): boolean { - return ( - mainWindow !== null && - !mainWindow.isDestroyed() && - mainWindow.isVisible() && - !mainWindow.isMinimized() - ) -} - -function canSendDecorativeSyntheticTitle(): boolean { - return shouldSendSyntheticTitleFrame({ - force: false, - windowVisible: isSyntheticTitleWindowVisible() - }) -} - -function stopSyntheticTitleSpinner(paneKey: string): void { - if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { - stopSyntheticTitleSpinnerTimerIfIdle() - } -} - -function stopAllSyntheticTitleSpinners(): void { - syntheticTitleSpinnerByPaneKey.clear() - stopSyntheticTitleSpinnerTimer() -} - -function stopSyntheticTitleSpinnerTimer(): void { - if (!syntheticTitleSpinnerTimer) { - return - } - clearInterval(syntheticTitleSpinnerTimer) - syntheticTitleSpinnerTimer = null -} - -function stopSyntheticTitleSpinnerTimerIfIdle(): void { - if (syntheticTitleSpinnerByPaneKey.size === 0) { - stopSyntheticTitleSpinnerTimer() - } -} - -function tickSyntheticTitleSpinners(): void { - if (!canSendDecorativeSyntheticTitle()) { - stopSyntheticTitleSpinnerTimer() - return - } - const ticks = advanceSyntheticTitleSpinnerEntries({ - entries: syntheticTitleSpinnerByPaneKey, - frameCount: SPINNER_FRAMES.length, - getPtyIdForPaneKey - }) - for (const tick of ticks) { - sendSyntheticTitle( - tick.ptyId, - `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` - ) - } - stopSyntheticTitleSpinnerTimerIfIdle() -} - -function ensureSyntheticTitleSpinnerTimer(): void { - if ( - syntheticTitleSpinnerTimer || - syntheticTitleSpinnerByPaneKey.size === 0 || - !canSendDecorativeSyntheticTitle() - ) { - return - } - // Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working. - syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) -} - -function resumeSyntheticTitleSpinnerTimer(): void { - ensureSyntheticTitleSpinnerTimer() -} - -function driveSyntheticTitleFromHook( - paneKey: string, - state: AgentStatusState, - profile: SyntheticAgentTitleProfile -): void { - const ptyId = getPtyIdForPaneKey(paneKey) - if (!ptyId) { - return - } - if (state === 'working') { - // Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick. - const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) - const frame = existing ? existing.frame : 0 - sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) - if (existing) { - // Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state. - existing.profile = profile - return - } - syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) - ensureSyntheticTitleSpinnerTimer() - return - } - // Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame. - // Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention). - stopSyntheticTitleSpinner(paneKey) - const needsUserInput = state === 'blocked' || state === 'waiting' - const label = needsUserInput ? profile.permissionLabel : profile.idleLabel - sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { - force: true - }) -} - -function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { - agentType: string | null | undefined - state: AgentStatusState - launchConfig: - | { - agentArgs?: string | null - agentEnv?: Record | null - } - | null - | undefined -}): boolean { - if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { - return false - } - if (!args.launchConfig) { - return false - } - return ( - resolveTuiAgentPermissionMode({ - agent: 'codex', - agentArgs: args.launchConfig.agentArgs, - agentEnv: args.launchConfig.agentEnv - }) === 'yolo' - ) -} - -void app.whenReady().then(async () => { - logStartupMilestone('app-ready') - // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. - applyBackgroundActivationPolicy({ warn: console.warn }) - installElectronProxyRequestGuard(session.defaultSession) - app.on('login', (event, webContents, details, authInfo, callback) => { - handleElectronProxyLogin( - event, - webContents, - details, - authInfo, - callback, - session.defaultSession - ) - }) - installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() }) - const hangDetection = consumeHangDetectionMarker( - hangDetectionMarkerPath(getCanonicalUserDataPath()) - ) - if (hangDetection) { - recordDurableCrashBreadcrumb('main_thread_hang_detected', { - unresponsiveMs: hangDetection.unresponsiveMs, - previousPid: hangDetection.parentPid, - selfRecovered: hangDetection.selfRecovered - }) - } - // Why: install certificate decisions before any webview or headless window issues its first TLS request. - app.on( - 'certificate-error', - (event, webContents, url, error, certificate, callback, isMainFrame) => { - browserCertificateTrustController.handleCertificateError({ - event, - webContents, - url, - error, - certificate, - callback, - isMainFrame - }) - } - ) - electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) - // Why: names the app menu/About panel. Dev already applied this pre-ready (see the - // safeStorage note above); this call stays unconditional so packaged builds keep their - // existing post-ready rename, which lands after the Keychain name is already resolved. - app.setName(devInstanceIdentity.appName) - updateGpuAccelerationAboutPanel() - - // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. - managedWslCliReconciliationStatus = 'pending' - managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ - isPackaged: app.isPackaged, - userDataPath: getCanonicalUserDataPath(), - appVersion: app.getVersion() - }) - .then((results) => { - for (const result of results) { - if (result.outcome === 'failed') { - console.warn( - `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` - ) - } else if (result.outcome === 'repaired') { - console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) - } - } - managedWslCliReconciliationStatus = 'settled' - }) - .catch((error) => { - managedWslCliReconciliationStatus = 'failed' - console.warn( - '[wsl-cli] Managed registration reconciliation discovery failed:', - error instanceof Error ? error.message : String(error) - ) - }) - managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( - managedWslCliReconciliationReady - ) - - const activeOrcaProfile = ensureActiveOrcaProfile() - // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable - // read has to have happened by then or the renderer and the browser-host lease disagree. - initializeBrowserClientHostId(activeOrcaProfile.profileDirectory) - store = new Store({ - dataFile: activeOrcaProfile.dataFile, - storageAuthority: isServeMode ? 'runtime' : 'desktop' - }) - // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) - installElectronProxyRequestGuard(session.defaultSession) - // Why armed here and not at install time: the report remembers what it last said, and - // that state lives beside the profile data file, which does not exist until now. - // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux - // and must not gate the first window (STA-5765). - scheduleSecretProtectionGapReport({ - dataFile: activeOrcaProfile.dataFile, - force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', - deferUntilFirstWindow: !isServeMode - }) - // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults - // it. Left unbound it reports nothing trusted, which is safe but silently discards our own - // accept records on every launch. - initSshHostKeyStoreFile(activeOrcaProfile.dataFile) - // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. - neutralizeLegacyTerminalShimDir(app.getPath('userData')) - const windowsShellPathHydration = createWindowsShellPathHydration() - configureWindowsHostGitEnvironmentReadiness( - process.platform === 'win32' ? windowsShellPathHydration.whenReady : null - ) - if (process.platform === 'win32') { - const settings = store.getSettings() - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null) - logStartupMilestone('store-loaded') - // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. - setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) - store.onSettingsChanged((updates, settings) => { - if ('terminalWindowsWslDistro' in updates) { - // Why: synchronize fallback WSL distro updates to runner. - setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) - } - if ( - ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && - process.platform === 'win32' - ) { - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - if ('showMenuBarIcon' in updates) { - // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. - syncMacMenuBarIcon(settings.showMenuBarIcon !== false) - } - if ('agentStatusHooksEnabled' in updates) { - // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running - // guest process and timers, and on must restart them — otherwise open WSL panes report no - // status until their next spawn. - if (isAgentStatusHooksEnabled(settings)) { - wslHookRelayManager.resumeStoppedRelays() - } else { - wslHookRelayManager.disposeAll({ permanent: false }) - } - } - }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } - applyAppIcon(store.getSettings().appIcon) - if (shouldSuppressDevEducation({ isDev: is.dev })) { - suppressDevEducationForStore(store) - } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } - // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. - setBrowserNetworkProxySettingsResolver(() => store!.getSettings()) - // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. - installDocPreviewProtocolHandler() - registerDocPreviewGrantHandlers() - // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. - initializeBrowserSessionsForApp({ - orcaProfileId: activeOrcaProfile.profile.id, - profileDirectory: activeOrcaProfile.profileDirectory, - // Why: local direct-SSH partitions are scoped to targets, and the orphan - // sweep must see the live target list or it would clear their cookie jars. - listLocalSshTargetIds: () => { - if (!store) { - // Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep. - throw new Error('ssh target store unavailable at partition sweep') - } - return store.getSshTargets().map((target) => target.id) - } - }) - try { - // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. - await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) - } catch { - console.warn('[proxy] Failed to apply network proxy settings to browser sessions') - } - unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() - agentAwakeService = new AgentAwakeService() - agentAwakeService.setMode( - normalizeComputerAwakeMode( - store.getSettings().computerAwakeMode, - store.getSettings().keepComputerAwakeWhileAgentsRun - ) - ) - // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. - agentAwakeService.setStatuses([]) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { - agentAwakeService?.setStatuses(statuses) - }) - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => { - // Healthy session.tabs streams need a push when transcript identity changes. - publishProviderSessionChanges(sessions) - } - ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - runtime?.touchMobileSessionTabsForPane(paneKey) - } - }) - unsubscribeAgentAwakeStatusChanges = () => { - unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() - } - // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. - initTelemetry(store) - // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not - // a crash (the app is force-quit, so no report is ever generated). Without this the incidence - // number the watchdog exists to produce would sit unread on the user's disk. Must run after - // initTelemetry: track() drops silently until the client and store are wired. - if (hangDetection) { - track('main_thread_hang_detected', { - unresponsive_ms: Math.round(hangDetection.unresponsiveMs), - self_recovered: hangDetection.selfRecovered - }) - } - // Why: the trust-grant module is bundled into plain-node CLI entries where - // the telemetry client cannot load, so the tracker is injected here instead - // of imported there. - setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { - track('codex_trust_grant', { - outcome, - host_kind: hostKind, - lane, - ...(reason !== undefined ? { fallback_reason: reason } : {}), - ...(errorClass !== undefined ? { error_class: errorClass } : {}), - ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) - }) - }) - // Why: the error-tracking lane (telemetry-error-tracking.md) is its own - // composition root — independent of product telemetry — and must - // initialize before any IPC handler / runtime span is created so the - // tracer's active sink is populated at the moment the first span fires. - // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED - // / CI internally; those gates do not need to be re-checked here. - initObservability() - recordDurableCrashBreadcrumb('main_process_lifecycle_started', { - packaged: app.isPackaged, - platform: process.platform - }) - const skillTransactionRecovery = recoverPendingSkillTransactions( - join(app.getPath('userData'), 'skill-installs') - ) - void skillTransactionRecovery - .then((report) => { - if (report.scanned || report.failures.length || report.truncated) { - console.info('[skills] startup transaction recovery:', { - scanned: report.scanned, - recovered: report.recovered, - failures: report.failures.map((failure) => failure.code), - truncated: report.truncated - }) - } - }) - .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) - // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). - initCohortClassifier(store) - initOnboardingCohortClassifier(store) - stats = new StatsCollector() - // Agent-session stats come from hook status transitions, the same truth the - // sidebar and dashboard read — never from OSC terminal titles, which miss - // hook-only agents and count any spinner TUI as an agent (#10201). - const agentSessionRecorder = new AgentSessionTransitionRecorder(stats) - agentHookServer.subscribeEnrichedStatus((enriched) => { - agentSessionRecorder.onStatus(enriched) - }) - agentHookServer.subscribePaneStatusClear((clear) => { - agentSessionRecorder.onCleared(clear) - }) - claudeUsage = new ClaudeUsageStore(store) - codexUsage = new CodexUsageStore(store) - openCodeUsage = new OpenCodeUsageStore(store) - rateLimits = new RateLimitService() - codexRuntimeHome = new CodexRuntimeHomeService(store) - void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) - // Why: an incapable trust-grant host must fall back to the managed home for - // every consumer (PTY env, rate limits, commit messages) in one place. - codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) - // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy - // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - codexRuntimeHome !== null && - codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(store?.getSettings()) - ) - codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, - isQuitting: () => isQuitting, - resolveSystemCodexHomePathOverride: () => - resolveHostCodexSessionSourceHome(store!.getSettings()), - prepareScheduledRun: (scanDates) => - codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), - finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), - startBackfill: startCodexSessionBackfillInBackground, - startIndexHeal: startCodexSessionIndexHealInBackground - }) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { - onHostSystemDefaultSelected: codexSessionMigration.requestRun - }) - // Why: migrate historical shared-home sessions after startup; compatibility - // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). - codexSessionMigration.scheduleInitialRun() - claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) - claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver((target) => - codexRuntimeHome!.prepareForRateLimitFetch(target) - ) - rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) - // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the - // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). - rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings()))) - rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) - const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( - rateLimits, - store.getSettings() - ) - store.onSettingsChanged((updates, settings) => { - // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. - void syncAccountRuntimeTargets(updates, settings).catch((error) => - console.warn('[rate-limits] Failed to apply account runtime target:', error) - ) - }) - rateLimits.setClaudeAuthPreparationResolver((target) => - claudeRuntimeAuth!.prepareForRateLimitFetch(target) - ) - // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). - agentHookServer.setClaudeStatusLineListener((event) => { - rateLimits?.ingestLiveClaudeRateLimits(event) - }) - rateLimits.setOpenCodeGoConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId - } - }) - rateLimits.setMiniMaxConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: readMiniMaxSessionCookie() ?? '', - groupId: settings.minimaxGroupId, - models: settings.minimaxUsageModels - } - }) - rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) - rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) - keybindings = new KeybindingService({ - homePath: app.getPath('home'), - getLegacyOverrides: () => store!.getSettings().keybindings, - legacyTabSwitchSeed: { - isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending', - markSeeded: () => { - store!.updateSettings({ tabSwitchKeybindingSeed: 'done' }) - } - } - }) - browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) - rateLimits.setInactiveClaudeAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeClaudeRuntimeSelection(settings).host, - ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.claudeManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - managedAuthPath: account.managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - wslLinuxAuthPath: account.wslLinuxAuthPath - })) - }) - rateLimits.setInactiveCodexAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeCodexRuntimeSelection(settings).host, - ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.codexManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - resolveHome: () => { - const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) - return resolved.kind === 'ready' - ? { kind: 'ready' as const, managedHomePath: resolved.homePath } - : { kind: 'skip' as const } - } - })) - }) - const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { - resolve: (selector) => { - const environment = resolveEnvironment(app.getPath('userData'), selector) - const pairing = getPreferredPairingOffer(environment) - return { - environmentId: environment.id, - name: environment.name, - peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) - } - }, - call: (selector, method, params, timeoutMs, envelope) => - callRuntimeEnvironment( - app.getPath('userData'), - selector, - method, - params, - timeoutMs, - undefined, - envelope - ) - } - const runtimeService = new OrcaRuntimeService(store, stats, { - agentSessionClaimSigner: loadAgentSessionClaimSigner( - getProfileUserDataPath(), - getProfileUserDataPath() - ), - // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). - getLocalProvider: () => getLocalPtyProvider(), - // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. - getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - onPtyStopped: clearProviderPtyState, - onTerminalAgentStatus: (event) => { - agentHookServer.ingestTerminalStatus(event) - }, - // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - }, - getDesktopWindowStatus: getDesktopWindowStatus, - // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. - getAgentStatusSnapshot: () => - agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - // Why: the filter above hides resume-identity rows from the live-agent views, but - // those rows carry the provider session mobile native chat addresses transcripts - // by — Pi publishes identity that way and would otherwise be unreachable. - getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => - agentHookServer.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - agentHookServer.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey) => - agentHookServer.retirePaneAuthority(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => { - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) - }, - canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, - // Why: evaluated per call, not captured — the RPC server that owns the device registry is - // constructed with this runtime and does not exist yet at this point. - getPairedDeviceName: (pairedDeviceId) => - runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, - // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { - launchAgent: 'codex', - workspacePath - }), - buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, - orchestrationEnvironmentTransport, - skillTransactionRecovery - }) - runtime = runtimeService - runtimeService.prepareLegacyWorkerTerminalRecovery() - // Why before anything can attach: a client host that reattaches to a restarted runtime is only - // handed its pages back if the runtime found them first. - runtimeService.rehydrateClientHostedBrowserPages() - publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) - browserManager.setBrowserGuestStateChangedListener((worktreeId) => { - runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - automations = new AutomationService(store, { - claudeUsage, - codexUsage, - terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService), - onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload), - // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. - allowRemoteHostScheduling: isServeMode, - headlessDispatcher: isServeMode - ? async ({ automation, run, target }) => { - const terminalSnapshotLimit = 2_000 - let terminalHandle: string - let terminalSessionId: string | null = null - let terminalPaneKey: string | null = null - let terminalPtyId: string | null = null - let workspaceId: string - let workspaceDisplayName: string | null = null - - if (automation.workspaceMode === 'new_per_run') { - const created = await runtimeService.createManagedWorktree({ - ...buildHeadlessAutomationWorktreeCreateArgs({ - automation, - run, - repo: target.repo - }) - }) - terminalHandle = created.startupTerminal?.handle ?? '' - terminalSessionId = created.startupTerminal?.tabId ?? null - terminalPaneKey = created.startupTerminal?.paneKey ?? null - terminalPtyId = created.startupTerminal?.ptyId ?? null - workspaceId = created.worktree.id - workspaceDisplayName = created.worktree.displayName ?? null - if (!terminalHandle) { - throw new Error( - created.warning || - 'Automation workspace was created, but no agent terminal started.' - ) - } - } else { - if (!automation.workspaceId) { - throw new Error('The target workspace is no longer available.') - } - const terminal = await runtimeService.launchAgentTerminal( - `id:${automation.workspaceId}`, - { - agent: automation.agentId, - prompt: automation.prompt, - title: run.title - } - ) - terminalHandle = terminal.handle - terminalSessionId = terminal.tabId ?? null - terminalPaneKey = terminal.paneKey ?? null - terminalPtyId = terminal.ptyId ?? null - workspaceId = terminal.worktreeId - const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`) - workspaceDisplayName = worktree.displayName ?? null - } - - const completion = (async () => { - const wait = await runtimeService.waitForTerminal(terminalHandle, { - condition: 'tui-idle' - }) - const read = await runtimeService.readTerminal(terminalHandle, { - limit: terminalSnapshotLimit - }) - const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() - snapshotBuffer.append(read.tail.join('\n')) - if (wait.satisfied) { - return { - status: 'completed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: null - } - } - return { - status: 'dispatch_failed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: wait.blockedReason - ? `Automation agent is blocked: ${wait.blockedReason}.` - : 'Automation agent did not report completion.' - } - })() - - return { - workspaceId, - workspaceDisplayName, - terminalSessionId, - terminalPaneKey, - terminalPtyId, - completion - } - } - : undefined - }) - runtimeService.setAutomationService(automations) - runtimeService.setArtifactService( - new ArtifactCloudService(app.getPath('userData'), () => - isArtifactSharingEnabled(store?.getSettings()) - ) - ) - runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) - runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) - runtimeService.setCommitMessageAgentEnvironmentResolvers({ - // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }) - const pluginSystemStartupStartedAt = performance.now() - pluginKillListService = new PluginKillListService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')) - }) - await pluginKillListService.initialize() - pluginMarketplaceService = new PluginMarketplaceService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')), - getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null - }) - const requestOfficialMarketplaceSeed = (): void => { - if (store?.getSettings().pluginSystemEnabled !== true) { - return - } - void pluginMarketplaceService?.seedOfficialSource().catch((error) => { - console.warn('[plugins] failed to configure the official marketplace:', error) - }) - } - pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ - marketplace: pluginMarketplaceService, - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null - }) - pluginService = new PluginService({ - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - // Feature flag: with the setting off, discovery returns nothing and no - // plugin code path runs at all. - isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, - getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), - getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), - getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), - getKeybindings: () => keybindings?.getOverrides() ?? {}, - getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, - hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) - }) - const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ - root: resolveBundledPluginRoot({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }), - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - isEnabled: () => store?.getSettings().pluginSystemEnabled === true, - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, - refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() - }) - const requestBundledPluginBootstrap = (): void => { - void bundledPluginBootstrap - .request() - .then((result) => { - for (const failure of result?.errors ?? []) { - console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) - } - }) - .catch((error) => { - console.warn('[plugins] failed to bootstrap bundled plugins:', error) - }) - } - pluginKillListService.onChanged(() => { - void pluginService?.reconcileActivationState().catch((error) => { - console.warn('[plugins] failed to apply plugin safety-list refresh:', error) - }) - }) - store.onSettingsChanged((updates) => { - if (updates.pluginSystemEnabled === true) { - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - } - if (app.isPackaged && updates.pluginSystemEnabled === true) { - void pluginKillListService?.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - }) - // Why: headless `orca serve` clients reach plugins through the runtime RPC - // methods, which resolve the service via this module-level setter. Consent - // over RPC uses the same hash-keyed write path as the desktop dialog. - setPluginServiceForRpc(pluginService, { - applyConsent: (request) => - applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }), - applyEnablement: (pluginKey, enabled) => - applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled }) - }) - // Lazy kernel: initialize() only discovers manifests — no worker forks, no - // panel reads. Zero plugin code runs before an explicit trigger. - void pluginService - .initialize() - .then(() => { - logStartupMilestone('plugin-system-initialized', { - durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), - installedPlugins: pluginService?.getDiscovered().length ?? 0 - }) - }) - .catch((error) => { - console.warn('[plugins] failed to initialize plugin service:', error) - }) - if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) { - void pluginKillListService.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - pluginService.onChanged((event) => { - if ( - event.contentPacksChanged && - setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) - ) { - void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) - } - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('plugins:changed', event) - } - } - }) - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - // v0 plugin event seams: agent status (hook pipeline tap) + worktree - // lifecycle (runtime tap). Server-side filtered per plugin subscription. - agentHookServer.subscribeEnrichedStatus((enriched) => { - // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. - if (enriched.restoredUnconfirmed) { - return - } - pluginService?.emitEvent('agent.status.changed', { - worktreeId: enriched.worktreeId ?? null, - paneKey: enriched.paneKey, - state: enriched.payload.state, - receivedAt: enriched.receivedAt - }) - }) - runtimeService.onWorktreeLifecycle((event) => { - emitPluginWorktreeLifecycle(event) - }) - starNag = new StarNagService(store, stats) - starNag.start() - starNag.registerIpcHandlers() - const agentBrowserBridge = new AgentBrowserBridge(browserManager, { - onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - runtimeService.setAgentBrowserBridge(agentBrowserBridge) - // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. - void agentBrowserBridge.sweepOrphanedSessions() - const browserClientAutomationDispatcher = new RpcDispatcher({ runtime: runtimeService }) - configureBrowserClientPageAutomationRuntime({ - browserManager, - getAgentBrowserBridge: () => agentBrowserBridge, - executeRpc: async (method, params, signal) => { - const response = await browserClientAutomationDispatcher.dispatch( - { - id: randomUUID(), - authToken: 'local-browser-client-automation', - method, - params - }, - { signal } - ) - if (!response.ok) { - throw new BrowserClientPageCommandError(response.error.code) - } - return response.result - } - }) - - // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. - // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. - const emulatorBridge = new EmulatorBridge() - runtimeService.setEmulatorBridge(emulatorBridge) - // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. - void sweepStaleWorktreeTrash( - collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) - ).catch((error) => { - console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) - }) - nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why (#16441): the real-home grant runs a codex app-server session. It stays - // ordered before managed-hook reconciliation — an incapable host must re-arm - // and complete the legacy real-home sweep first — but awaiting it inline - // stalled app init behind that session, so chain instead of blocking. - const startupManagedHookSettings = store.getSettings() - const shouldReconcileStartupManagedHooks = - shouldInstallManagedHooks(is.dev) && - resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' - const realHomeCodexHookState = - shouldReconcileStartupManagedHooks && - shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && - codexRuntimeHome.isHostSystemDefaultRealHomeSelected() - ? ensureRealHomeCodexHookState({ - hooksEnabled: true, - userDataPath: app.getPath('userData') - }).catch((error: unknown) => { - console.warn('[codex-real-home-hooks] startup ensure failed:', error) - }) - : Promise.resolve() - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. - if (shouldReconcileStartupManagedHooks) { - const managedHookStore = store - void realHomeCodexHookState - .then(() => - installManagedAgentHooks(managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = managedHookStore.getSettings() - return shouldContinueManagedHookStartup(isQuitting, settings, agent) - } - }) - ) - .catch((error: unknown) => { - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - }) - } - // Why: process-gone metrics only see survivors; retain a recent whole-app - // snapshot for comparison in crash reports. - startPreGoneProcessMetricsSampling() - app.on('child-process-gone', (_event, details) => { - recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { - name: details.name, - serviceName: details.serviceName, - type: details.type - }) - if ( - isGpuFallbackCrashCandidate({ - platform: process.platform, - processType: details.type, - reason: details.reason - }) - ) { - const crashedAt = performance.now() - void gpuCrashDiagnostics?.record() - void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) - } - }) - - logStartupMilestone('services-initialized') - await ensureMainI18n() - await setMainUiLanguage(store.getSettings().uiLanguage) - logStartupMilestone('i18n-ready') - - registerAppMenu({ - appMenuLabel: devInstanceIdentity.name, - onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - onOpenSettings: openSettingsFromSystemMenu, - onOpenSetupGuide: (targetWindow) => { - recordCrashBreadcrumb('setup_guide_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenSetupGuide(targetBrowserWindow) - }, - onOpenCrashReport: (targetWindow) => { - recordCrashBreadcrumb('crash_report_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenCrashReport(targetBrowserWindow) - }, - onOpenFeatureTour: (targetWindow) => { - recordCrashBreadcrumb('feature_tour_opened') - // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenFeatureTour(targetBrowserWindow) - }, - // Why: menu zoom must act on the window the user is looking at — routing to - // the main window while the dashboard pop-out is focused zooms behind it. - onZoomIn: () => { - if (!zoomDashboardPopoutIfFocused('in')) { - mainWindow?.webContents.send('terminal:zoom', 'in') - } - }, - onZoomOut: () => { - if (!zoomDashboardPopoutIfFocused('out')) { - mainWindow?.webContents.send('terminal:zoom', 'out') - } - }, - onZoomReset: () => { - if (!zoomDashboardPopoutIfFocused('reset')) { - mainWindow?.webContents.send('terminal:zoom', 'reset') - } - }, - onToggleLeftSidebar: () => { - mainWindow?.webContents.send('ui:toggleLeftSidebar') - }, - onToggleRightSidebar: () => { - mainWindow?.webContents.send('ui:toggleRightSidebar') - }, - onToggleAppearance: (key) => { - if (!store) { - return - } - if (key === 'statusBarVisible') { - // Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store. - mainWindow?.webContents.send('ui:toggleStatusBar') - return - } - const current = store.getSettings() - // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. - const next = getNextDefaultOnAppearanceSettingValue(current[key]) - store.updateSettings({ [key]: next }, { notifyListeners: true }) - rebuildAppMenu() - }, - getAppearanceState: () => { - const settings = store?.getSettings() - const ui = store?.getUI() - return { - showTasksButton: settings?.showTasksButton !== false, - showAutomationsButton: settings?.showAutomationsButton !== false, - showMobileButton: settings?.showMobileButton !== false, - showTitlebarAppName: settings?.showTitlebarAppName !== false, - statusBarVisible: ui?.statusBarVisible !== false - } - }, - getKeybindings: () => keybindings?.getOverrides() - }) - // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. - const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT - const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) - if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { - throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) - } - // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). - const devWsPort = is.dev && !isE2E ? 6769 : undefined - let serveOptions: ServeOptions | null = null - try { - serveOptions = isServeMode ? getServeOptions() : null - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - app.exit(1) - return - } - // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. - migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) - runtimeRpc = new OrcaRuntimeRpcServer({ - runtime, - // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. - userDataPath: getCanonicalUserDataPath(), - enableWebSocket: true, - // Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device; - // `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over. - exposeNetworkByDefault: Boolean(serveOptions) || isE2E, - ...(isE2E ? { wsPort: e2eWsPort } : {}), - ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined - ? { - wsPort: serveOptions.wsPort, - // Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability. - preferPinnedWsPort: true - } - : {}), - webClientRoot: getBundledWebClientRoot() - }) - registerMobileHandlers(runtimeRpc, { - getRelayStatus: () => desktopRelayStatus, - consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - mainWindow.webContents.id !== webContentsId || - !pendingUnpairedDeviceAuthFailure - ) { - return false - } - pendingUnpairedDeviceAuthFailure = false - return true - } - }) - // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. - runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { - // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. - pendingUnpairedDeviceAuthFailure = true - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') - } - }) - - const shellPathReady = windowsShellPathHydration.whenReady() - let desktopWindow: BrowserWindow | null = null - if (process.platform === 'win32' && app.isPackaged && !serveOptions) { - const desktopStartup = startWindowsDesktopBeforeShellPathReady({ - bindServices: bindTerminalRuntimeStartupServices, - openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), - shellPathReady, - startServices: startTerminalRuntimeStartupServices - }) - desktopWindow = desktopStartup.window - } else { - await shellPathReady - bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) - } - app.on('activate', handleMacAppActivation) - - if (serveOptions) { - // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. - logStartupMilestone('wsl-cli-barrier-start') - await managedWslCliStartupBarrierReady - logStartupMilestone('wsl-cli-barrier-resolved', { - reconciliation: managedWslCliReconciliationStatus - }) - // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. - await localPtyStartupReady - await localPtyProviderStartupReady - await registerHeadlessPtyRuntime( - runtime, - prepareCodexRuntimeHomeForLaunch, - () => store!.getSettings(), - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store, - prepareCodexSessionResumeForLaunch, - { - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit - } - ) - await runtime.refreshRestoredOrchestrationAuthority() - await runtime.reconcileLegacyWorkerTerminals() - // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. - if (headlessBrowserDisplayAvailable) { - runtime.setOffscreenBrowserBackend( - new OffscreenBrowserBackend(browserManager, { - getAgentBrowserBridge: () => agentBrowserBridge - }) - ) - } - // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. - runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) - await runtimeRpc.start().catch((error) => { - console.error('[runtime] Failed to start headless RPC transport:', error) - throw error - }) - settleServeDesktopActivation() - // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. - registerServeSignalHandlers(process, () => app.quit()) - // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). - if (process.platform === 'darwin' || process.platform === 'linux') { - try { - // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ - privilegedRunner: async () => { - throw new Error('serve CLI auto-install must not request administrator privileges') - } - }).install() - console.log( - `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` - ) - } catch (error) { - console.warn( - '[serve] orca CLI install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. - if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { - try { - const dispatcher = await installLinuxBareOrcaDispatcher({ - resourcesPath: process.resourcesPath - }) - console.log( - `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + - `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` - ) - } catch (error) { - console.warn( - '[serve] bare orca dispatcher install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. - automations.start() - // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is - // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. - scheduleAllPendingHistoryTreeRemovals() - await printServeReady(serveOptions) - return - } - - // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. - const desktopRuntimeRpc = runtimeRpc - if (!desktopRuntimeRpc) { - throw new Error('runtime_rpc_unavailable') - } - const [win, runtimeRpcStartResult] = await Promise.all([ - Promise.resolve(desktopWindow ?? openMainWindow()), - shellPathReady - .then(() => desktopRuntimeRpc.start()) - .then( - () => ({ ok: true as const }), - (error: unknown) => { - recordRuntimeRpcStartFailure(error) - return { ok: false as const, error } - } - ) - ]) - if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) - } - - const cloudAuth = getOrcaCloudAuthConfig() - if (cloudAuth.configured) { - try { - const relayService = new DesktopRelayService({ - authConfig: cloudAuth.config, - userDataPath: getProfileUserDataPath(), - appVersion: app.getVersion(), - runtimeRpc, - onStatus: (status) => { - desktopRelayStatus = status - mainWindow?.webContents.send('mobile:relayStatusChanged', status) - } - }) - desktopRelayService = relayService - runtimeRpc.setMobileRelayPairingProvider({ - createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), - onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), - onDemandStateChanged: () => relayService.demandStateChanged(), - getEndpoints: (context, params) => relayService.getEndpoints(context, params), - provisionRelay: (context, params) => relayService.provisionRelay(context, params) - }) - relayService.start() - // Why: sleeping past relay-token expiry kills the broker with no retry - // timer; resume is the moment that state becomes recoverable. - powerMonitor.on('resume', () => desktopRelayService?.ensureLive()) - } catch (error) { - console.warn( - '[relay] Desktop relay startup unavailable:', - error instanceof Error ? error.message : String(error) - ) - } - } - - // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. - win.once('show', () => { - // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. - if (!store) { - return - } - const onboarding = store.getOnboarding() - if (onboarding.closedAt !== null) { - triggerStartupNotificationRegistration(store) - } - }) -}) - -// Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. -process.once('exit', stopTccPromptNotice) - -app.on('before-quit', () => { - if (isQuittingForUpdate()) { - recordUpdaterLifecycle('before_quit_allowed', undefined, { - message: 'before-quit allowed for update install' - }) - } - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - runtimeRpc?.setMobileRelayPairingProvider(null) - unsubscribeAgentAwakeStatusChanges?.() - unsubscribeAgentAwakeStatusChanges = null - agentAwakeService?.dispose() - agentAwakeService = null - // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). - rateLimits?.stop() -}) - -// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. -let daemonDisconnectDone = false -// Why 2s: a config delete is best-effort, not durable state. -const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 - -app.on('will-quit', (e) => { - // Why return instead of re-running teardown: the second pass is Electron re-firing after - // our own app.quit(), so every step below already ran and every durable write already - // landed. Re-entering would start a fresh unawaited write that the exit then tears down. - if (daemonDisconnectDone) { - return - } - // Why preventDefault before any work: everything below must be free to await, and a - // synchronous durable write here parks the main thread — uninterruptibly, on a stalled - // network profile mount. The teardown deadline cannot rescue that, because its timer - // lives on the same thread it would need to bound (#9447 covers the wedged-transport - // half; this covers the blocked-syscall half). - if (!quitTeardownStartGate.tryStart(e)) { - return - } - unsubscribeSystemResumeBroadcast?.() - unsubscribeSystemResumeBroadcast = null - // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. - stopTccPromptNotice() - const updateQuitInProgress = isQuittingForUpdate() - if (updateQuitInProgress) { - recordUpdaterLifecycle( - 'will_quit_cleanup_started', - { daemonTeardown: 'disconnect' }, - { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } - ) - } - // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. - destroySystemTray() - // Why: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost. - starNag?.stop() - automations?.stop() - // Why: plugin hosts are forked children; dispose sends shutdown and - // escalates to SIGKILL so they cannot outlive the app. The promise joins - // the teardown barrier below — quitting before it resolves would let - // Electron exit first and orphan the hosts. - setPluginServiceForRpc(null) - pluginKillListService = null - pluginMarketplaceService = null - pluginMarketplaceInstaller = null - const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() - const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() - const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() - pluginService = null - setUnreadDockBadgeCount(0) - agentHookServer.stop() - // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a - // bare script path that cannot express the guard and would otherwise keep spawning after quit. - // Why bounded here: every other teardown member carries its own ceiling, and this one reaches - // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the - // shared 20s deadline becomes the only thing ending the quit. - const grokHookCleanup = - process.platform === 'win32' - ? settleWithinMs( - removeManagedAgentHooksAsync({ agents: ['grok'] }), - GROK_HOOK_CLEANUP_DEADLINE_MS - ).then((settled) => { - if (settled.outcome === 'timed-out') { - console.warn('[agent-hooks] Grok hook cleanup on quit timed out') - return - } - if (settled.outcome === 'failed') { - console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) - return - } - // Why: removers report failures as statuses, so inspect details even after fulfillment. - for (const status of settled.value.filter((entry) => entry.detail)) { - console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) - } - }) - : Promise.resolve() - // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. - wslHookRelayManager.disposeAll() - const statsFlush = stats?.flushAsync() ?? Promise.resolve() - // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. - // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, - // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). - // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. - const browserShutdown = (async (): Promise => { - await runtime?.getOffscreenBrowserBackend()?.destroyAll?.() - await runtime?.getAgentBrowserBridge()?.destroyAllSessions() - })() - // Why (review P2-4): local SSH browser routes own loopback listeners and, on the - // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. - const localSshRouteShutdown = import('./browser/local-ssh-browser-route') - .then((routes) => routes.closeAllLocalSshBrowserRoutes()) - .catch(() => {}) - browserManager.setBrowserGuestStateChangedListener(null) - const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every - // active SSH lease detached in memory synchronously, and that flush is what persists it. - const sshShutdown = beginSshShutdown() - killAllPty() - const watcherShutdown = shutdownWatchersOnce() - const storeFlush = store?.flushAsync() ?? Promise.resolve() - // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a - // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. - const usageCacheFlush = Promise.all([ - claudeUsage?.flush(), - codexUsage?.flush(), - openCodeUsage?.flush() - ]).then(() => {}) - const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() - const skillUploadShutdown = runtime?.disposeSkillUploadSessions() ?? Promise.resolve() - - // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. - const ownedPid = process.pid - const ownedRuntimeId = runtime?.getRuntimeId() - const rpcStopAndClear = runtimeRpc - ? runtimeRpc - .stop() - .then(() => awaitRuntimeFileWatcherUnsubscribes()) - .then(() => { - if (ownedRuntimeId) { - // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). - clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) - } - }) - .catch((error) => { - console.error('[runtime] Failed to stop local RPC transport:', error) - }) - : Promise.resolve() - // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. - // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. - // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() - // Why: a wedged transport (half-open post-sleep socket) can leave one - // member unsettled forever and block app.quit() until Force Quit (#9447). - // Why stats/state join here: their writes are durable but not worth hanging the app for. - // Losing at most the last debounce interval beats a quit that never completes, and the - // temp+rename swap means a write cut short by the deadline leaves the old file intact. - settleTeardownWithinDeadline([ - { name: 'daemon', promise: daemonTeardown }, - { name: 'browser', promise: browserShutdown }, - { name: 'runtime-rpc', promise: rpcStopAndClear }, - { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown }, - { name: 'browser-client-hosts', promise: browserClientHostShutdown }, - { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, - { name: 'ssh', promise: sshShutdown }, - { name: 'plugin-hosts', promise: pluginHostShutdown }, - { name: 'skill-uploads', promise: skillUploadShutdown }, - { name: 'grok-hooks', promise: grokHookCleanup }, - { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, - { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, - { name: 'usage-cache', promise: usageCacheFlush }, - { name: 'stats', promise: statsFlush }, - { name: 'state', promise: storeFlush } - ]) - .then((pendingTeardowns) => { - if (pendingTeardowns.length > 0) { - console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) - } - }) - .then(() => shutdownTelemetry()) - .then(() => shutdownObservability()) - .catch(() => { - /* swallow — telemetry must never prevent app.quit() */ - }) - .then(() => { - daemonDisconnectDone = true - app.quit() - }) -}) - -app.on('window-all-closed', () => { - // Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive. - // Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits. - if ( - shouldQuitWhenAllWindowsClosed({ - platform: process.platform, - isQuitting, - isServeMode - }) - ) { - app.quit() - } -}) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 41b8b8e8da7..e517a834d5e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,2441 +1,48 @@ -/* eslint-disable max-lines */ -import { BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import { dirname, extname, join, resolve } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner' -import { parseWslPath, toWindowsWslPath } from '../wsl' -import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, MarkdownDocument } from '../../shared/filesystem-entry-types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { Repo } from '../../shared/repo-types' -import type { TuiAgent } from '../../shared/tui-agent' -import type { GitPushTarget } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import type { SshMutationExpectation } from '../../shared/ssh-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { - buildRgArgs, - createAccumulator, - DEFAULT_SEARCH_MAX_RESULTS, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import { - getStatus, - getSubmoduleStatus, - abortMerge, - abortRebase, - detectConflictOperation, - getDiff, - commitChanges, - stageFile, - unstageFile, - bulkStageFiles, - bulkUnstageFiles, - bulkDiscardChanges, - discardChanges, - getStagedCommitContext, - getBranchCompare, - getBranchDiff, - getCommitCompare, - getCommitDiff -} from '../git/status' -import { getHistory } from '../git/history' -import { - cancelGenerateCommitMessageLocal, - cancelGeneratePullRequestFieldsLocal, - discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote, - generateCommitMessageFromContext, - generatePullRequestFieldsFromContext, - resolveCommitMessageSettings, - type DiscoverCommitMessageModelsResult, - type CommitMessageGenerationTarget, - type GenerateCommitMessageResult, - type GeneratePullRequestFieldsResult -} from '../text-generation/commit-message-text-generation' -import { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import { getUpstreamStatus } from '../git/upstream' -import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' -import { gitSyncForkDefaultBranch } from '../git/fork-sync' -import { validateGitForkSyncExpectedUpstream } from '../../shared/git-fork-sync' -import { checkIgnoredPaths } from '../git/check-ignored-paths' -import { - appendFolderToGitignore, - findKnownHugeFolderPathsToIgnore -} from '../git/huge-folder-ignore' -import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' -import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' -import type { HostedReviewProvider } from '../../shared/hosted-review' -import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' -import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' -import { validateGitPushTarget } from '../git/push-target-validation' -import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' -import { resolveAuthorizedPath, authorizeExternalPath } from './filesystem-auth' -import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { validateGitRelativeFilePath, isENOENT } from './filesystem-path-containment' -import { listQuickOpenFiles } from './filesystem-list-files' -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { searchWithGitGrep } from './filesystem-search-git' -import { - getLocalGitOptionsForRegisteredWorktree, - getLocalGitOptionsForRepo, - getLocalRepoForRegisteredWorktree -} from './local-worktree-runtime-options' -import { - resolveSourceControlAiLinkedIssue, - resolveSourceControlAiLinkedIssueMeta -} from './source-control-ai-linked-issue' -import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' -import { checkRgAvailable } from './rg-availability' -import { - absorbPendingRipgrepSpawnError, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess -} from '../../shared/ripgrep-process-availability' -import { - getSshFilesystemProvider, - requireSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' -import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue' -import { - prepareLocalCommitMessageAgentEnv, - type CommitMessageAgentRuntimeTarget, - type CommitMessageAgentEnvironmentResolvers -} from '../text-generation/commit-message-agent-environment' -import { listRepoWorktrees } from '../repo-worktrees' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' -import { splitWorktreeId } from '../../shared/worktree/id' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerLocalLogTailHandlers } from './local-log-tail' -import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { - applyGitStatusUpstreamRefWatchRequest, - type GitStatusUpstreamRefWatchRequest -} from './git-status-upstream-ref-watch-request' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -const BINARY_PROBE_BYTES = 8192 -const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} -async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - if (encoding === 'base64') { - return Buffer.from(content, 'base64') - } - return Buffer.from(content, 'utf8') -} - -type DownloadSession = { - destinationPath: string - tempPath: string - destinationExisted: boolean - handle: FileHandle - cleanupTimer: ReturnType - senderId: number -} - -const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 - -function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -async function inspectDownloadDestination(destinationPath: string): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -function comparableLocalPath(value: string): string { - const normalized = resolve(value) - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function getCandidateLocalWorktreePaths( - worktreePath: string, - resolvedWorktreePath: string -): Set { - return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) -} - -function hasRegisteredWorktreeMetaForRepo( - store: Store, - repoId: string, - candidatePaths: Set -): boolean { - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { - return true - } - } - return false -} - -function comparableRemotePath(value: string): string { - return value.replace(/[/\\]+$/g, '') -} - -function hasRegisteredRemoteWorktreeMetaForRepo( - store: Store, - repoId: string, - worktreePath: string -): boolean { - const comparableWorktreePath = comparableRemotePath(worktreePath) - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if ( - parsed?.repoId === repoId && - comparableRemotePath(parsed.worktreePath) === comparableWorktreePath - ) { - return true - } - } - return false -} - -async function localRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string -): Promise { - let resolvedWorktreePath: string - try { - resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) - } catch { - return false - } - const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) - if (candidatePaths.has(comparableLocalPath(repo.path))) { - return true - } - if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { - return true - } - try { - const worktrees = await listRepoWorktrees(repo) - return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) - } catch { - return false - } -} - -async function remoteRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string, - connectionId: string -): Promise { - const comparableWorktreePath = comparableRemotePath(worktreePath) - if (comparableRemotePath(repo.path) === comparableWorktreePath) { - return true - } - const provider = getSshGitProvider(connectionId) - if (!provider) { - return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) - } - try { - const worktrees = await provider.listWorktrees(repo.path) - return worktrees.some( - (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath - ) - } catch { - return false - } -} - -async function getRepoForSourceControlAi( - store: Store, - args: { repoId?: string; worktreePath: string; connectionId?: string } -): Promise { - if (!args.repoId) { - return null - } - const repo = store.getRepo(args.repoId) - if (!repo) { - return null - } - if (args.connectionId) { - if (repo.connectionId !== args.connectionId) { - return null - } - // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. - return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) - ? repo - : null - } - if (repo.connectionId) { - return null - } - // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. - return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null -} - -function getLocalAgentRuntimeTarget( - gitOptions: LocalProjectWorktreeGitOptions -): CommitMessageAgentRuntimeTarget { - return gitOptions.wslDistro - ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } - : { runtime: 'host' } -} - -async function resolveModelDiscoveryLocalPath( - store: Store, - requestedPath: string -): Promise { - try { - return await resolveRegisteredWorktreePath(requestedPath, store) - } catch (error) { - const folderWorkspaces = - typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] - const isFolderWorkspaceRoot = folderWorkspaces.some( - (workspace) => - comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) - ) - if (!isFolderWorkspaceRoot) { - throw error - } - return resolveAuthorizedPath(requestedPath, store) - } -} - -function getLocalTextGenerationTarget( - worktreePath: string, - gitOptions: LocalProjectWorktreeGitOptions, - env?: NodeJS.ProcessEnv -): Extract { - return { - kind: 'local', - cwd: worktreePath, - ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), - ...(env ? { env } : {}) - } -} - -function validateFullGitObjectId(value: string, label: string): string { - if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} - -/** - * Check if a buffer appears to be binary (contains null bytes in first 8KB). - */ -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -async function isBinaryFilePrefix(filePath: string): Promise { - const handle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -function isDirectoryEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} + createFilesystemHandlerContext, + type FilesystemHandlerContext +} from './filesystem/filesystem-handler-context' +import { registerFilesystemReadHandlers } from './filesystem/filesystem-read-handlers' +import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' +import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' +import { registerFilesystemSearchHandlers } from './filesystem/filesystem-search-handlers' +import { registerFilesystemGitStatusHandlers } from './filesystem/filesystem-git-status-handlers' +import { registerFilesystemGitCommitHandlers } from './filesystem/filesystem-git-commit-handlers' +import { registerFilesystemGitCommitGenerationHandlers } from './filesystem/filesystem-git-commit-generation-handlers' +import { registerFilesystemGitModelDiscoveryHandlers } from './filesystem/filesystem-git-model-discovery-handlers' +import { registerFilesystemGitPullRequestGenerationHandlers } from './filesystem/filesystem-git-pull-request-generation-handlers' +import { registerFilesystemGitRemoteHandlers } from './filesystem/filesystem-git-remote-handlers' +import { registerFilesystemGitDiffHandlers } from './filesystem/filesystem-git-diff-handlers' +import { registerFilesystemGitIndexHandlers } from './filesystem/filesystem-git-index-handlers' +import { registerFilesystemGitUrlHandlers } from './filesystem/filesystem-git-url-handlers' export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { - const activeTextSearches = new Map() - const downloadSessions = new Map() - - async function closeDownloadSession( - transferId: string, - cleanupTemp: boolean - ): Promise { - const session = downloadSessions.get(transferId) - if (!session) { - return null - } - downloadSessions.delete(transferId) - clearTimeout(session.cleanupTimer) - await session.handle.close().catch(() => {}) - if (cleanupTemp) { - await cleanupLocalTransferPath(session.tempPath) - } - return session - } - - function cleanupDownloadSessionsForSender(senderId: number): void { - for (const [transferId, session] of Array.from(downloadSessions)) { - if (session.senderId === senderId) { - void closeDownloadSession(transferId, true) - } - } - } - - // ─── Filesystem ───────────────────────────────────────── - ipcMain.handle( - 'fs:readDir', - async (_event, args: { dirPath: string; connectionId?: string }): Promise => { - // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. - let throwSite: ReadDirThrowSite = 'authorize' - try { - if (args.connectionId) { - throwSite = 'ssh-provider' - const provider = requireSshFilesystemProvider(args.connectionId) - // Why: re-sort locally — the remote relay may be an older build with - // lexicographic ordering. - return sortDirEntries(await provider.readDir(args.dirPath)) - } - throwSite = 'authorize' - const dirPath = await resolveAuthorizedPath(args.dirPath, store) - throwSite = 'readdir' - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries.map((entry) => ({ - name: entry.name, - isDirectory: isDirectoryEntry(entry), - isSymlink: entry.isSymbolicLink() - })) - return sortDirEntries(mapped) - } catch (error: unknown) { - recordCrashBreadcrumb( - 'fs_readdir_error', - buildReadDirErrorBreadcrumb({ - dirPath: args.dirPath, - connectionId: args.connectionId, - throwSite, - error - }) - ) - throw error - } - } - ) - - ipcMain.handle( - 'fs:readFile', - async ( - _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.readFile(args.filePath) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - if (args.includeLocalLogMetadata === true) { - return readLocalLogSnapshot(filePath) - } - const stats = await stat(filePath) - const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. - isImage: true, - mimeType - } - } - - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - - return { content: buffer.toString('utf-8'), isBinary: false } - } - ) - - ipcMain.handle( - 'fs:downloadFile', - async ( - event, - args: { filePath?: string; connectionId?: string } - ): Promise => { - const filePath = validateRequiredString(args?.filePath, 'filePath') - const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(filePath) - if (remoteStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const remoteBasename = getRuntimePathBasename(filePath) - const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath }) - : await dialog.showSaveDialog({ defaultPath }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await provider.downloadFile(filePath, tempPath) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - registerFilesystemDownloadFolderHandlers() - - ipcMain.handle( - 'fs:saveDownloadedFile', - async ( - event, - args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } - ): Promise => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - if (typeof args?.content !== 'string') { - throw new Error('content is required') - } - const content = args.content - const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:startDownloadedFile', - async ( - event, - args: { suggestedName?: string } - ): Promise< - | { canceled: true } - | { - canceled: false - transferId: string - destinationPath: string - } - > => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - const transferId = randomUUID() - try { - const handle = await open(tempPath, 'wx') - const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN - const cleanupTimer = setTimeout(() => { - void closeDownloadSession(transferId, true) - }, DOWNLOAD_SESSION_TTL_MS) - if (typeof cleanupTimer.unref === 'function') { - cleanupTimer.unref() - } - downloadSessions.set(transferId, { - destinationPath, - tempPath, - destinationExisted: existed, - handle, - cleanupTimer, - senderId - }) - event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) - return { canceled: false, transferId, destinationPath } - } catch (error) { - await cleanupLocalTransferPath(tempPath) - throw error - } - } - ) - - ipcMain.handle( - 'fs:appendDownloadedFileChunk', - async ( - _event, - args: { transferId?: string; contentBase64?: string } - ): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') - const session = downloadSessions.get(transferId) - if (!session) { - throw new Error('Download session not found') - } - await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:finishDownloadedFile', - async ( - _event, - args: { transferId?: string } - ): Promise<{ canceled: false; destinationPath: string }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const session = await closeDownloadSession(transferId, false) - if (!session) { - throw new Error('Download session not found') - } - let promoted = false - try { - await promoteDownloadedFile( - session.tempPath, - session.destinationPath, - session.destinationExisted - ) - promoted = true - return { canceled: false, destinationPath: session.destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(session.tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:cancelDownloadedFile', - async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - await closeDownloadSession(transferId, true) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:listMarkdownDocuments', - async ( - _event, - args: { rootPath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const relativePaths = await provider.listFiles(args.rootPath) - return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) - } - - const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) - return listMarkdownDocuments(rootPath) - } - ) - - ipcMain.handle( - 'fs:writeFile', - async ( - _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.writeFile(args.filePath, args.content) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - - await writeFile(filePath, args.content, 'utf-8') - } - ) - - ipcMain.handle( - 'fs:deletePath', - async ( - _event, - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.deletePath(args.targetPath, args.recursive) - } - // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). - const targetPath = await resolveAuthorizedPath(args.targetPath, store, { - preserveSymlink: true - }) - - // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). - if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { - return - } - - // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). - try { - await shell.trashItem(targetPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - } - ) - - registerFilesystemMutationHandlers(store) - - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) - - ipcMain.handle( - 'fs:stat', - async ( - _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const s = await provider.stat(args.filePath) - return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime } - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - const stats = await stat(filePath) - return { - size: stats.size, - isDirectory: stats.isDirectory(), - mtime: stats.mtimeMs - } - } - ) - - ipcMain.handle( - 'fs:pathExists', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { - try { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - await provider.stat(args.filePath) - return true - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - await stat(filePath) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } - } - ) - - // ─── Search ──────────────────────────────────────────── - ipcMain.handle( - 'fs:search', - async (event, args: SearchOptions & { connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.search(args) - } - const rootPath = await resolveAuthorizedPath(args.rootPath, store) - const localGitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.rootPath, - rootPath - ) - const maxResults = Math.max( - 1, - Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const searchKey = `${event.sender.id}:${rootPath}` - // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - - if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { - return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) - - // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - const previousChild = activeTextSearches.get(searchKey) - if (previousChild) { - killSpawnedRipgrepProcess(previousChild) - } - - const acc = createAccumulator() - let stdoutBuffer = '' - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let child: ChildProcess | null = null - let killTimeout: ReturnType - - const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) - : undefined - - const finish = (result: SearchResult | PromiseLike): void => { - if (resolved) { - return - } - resolved = true - if (activeTextSearches.get(searchKey) === child) { - activeTextSearches.delete(searchKey) - } - clearTimeout(killTimeout) - // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. - child?.stdout?.off('data', handleStdoutData) - child?.stderr?.off('data', handleStderrData) - child?.off('error', handleError) - child?.off('close', handleClose) - if (child) { - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - resolvePromise(result) - } - const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) - - const processLine = (line: string): void => { - const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop' && child) { - killSpawnedRipgrepProcess(child) - } - } - - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - activeTextSearches.set(searchKey, nextChild) - - const handleStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } - } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. - } - const handleError = (): void => { - processErrorObserved = true - if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() - return - } - resolveOnce() - } - const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if ( - child && - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !wslDistroForOutput - }) - ) { - unavailableExitObserved = true - resolveWithoutRipgrep() - return - } - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - } - - nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', handleStdoutData) - nextChild.stderr!.on('data', handleStderrData) - nextChild.once('error', handleError) - nextChild.once('close', handleClose) - - // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. - killTimeout = setTimeout(() => { - acc.truncated = true - if (child) { - killSpawnedRipgrepProcess(child) - } - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) - } - ) - - // ─── List all files (for quick-open) ───────────────────── - // Why #7721: token-keyed so a workspace switch aborts the prior full-tree scan (SSH otherwise stacks scans past the 30s timeout). - const listFilesCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'fs:listFiles', - async ( - event, - args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - } - ): Promise => { - const controller = listFilesCancellations.begin(event, args.requestToken) - try { - if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. - if (!provider) { - return [] - } - // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. - if ( - args.searchQuery !== undefined && - provider.supportsQuickOpenSearch && - !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) - ) { - const legacyFiles = await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, - signal: controller?.signal - }) - const ranker = new QuickOpenPathRanker( - args.searchQuery, - args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT - ) - for (const file of legacyFiles) { - ranker.consider(file) - } - return ranker.result().paths - } - return await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), - ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), - signal: controller?.signal - }) - } - return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) - } finally { - listFilesCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { - listFilesCancellations.cancel(event, args.requestToken) - }) - - // ─── Git operations ───────────────────────────────────── - const gitStatusCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'git:status', - async ( - event, - args: { - worktreePath: string - connectionId?: string - admissionTier?: GitAdmissionTier - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - } - ): Promise => { - const controller = gitStatusCancellations.begin(event, args.requestToken) - const options = { - includeIgnored: args.includeIgnored ?? false, - admissionTier: args.admissionTier ?? ('status' as const), - ...(args.includeLineStats === false ? { includeLineStats: false } : {}), - ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(args.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), - ...(args.bypassEffectiveUpstreamNegativeCache === true - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(controller ? { signal: controller.signal } : {}) - } - try { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). - return await provider.getStatus(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - // Why: one registered-worktree lookup feeds both — status polls this - // handler, and the scan walks every repo's worktree meta. - const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) - const gitOptions = getLocalGitOptionsForRepo(store, repo) - const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] - return await getStatus(worktreePath, { - ...options, - ...gitOptions, - ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) - }) - } finally { - gitStatusCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { - gitStatusCancellations.cancel(event, args.requestToken) - }) - - ipcMain.handle( - 'git:setStatusUpstreamRefWatch', - (_event, args: GitStatusUpstreamRefWatchRequest): Promise => - applyGitStatusUpstreamRefWatchRequest(store, args) - ) - - // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. - ipcMain.handle( - 'git:submoduleStatus', - async ( - _event, - args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getSubmoduleStatus(worktreePath, args.submodulePath, { - ...gitOptions, - ...(args.area === 'staged' ? { staged: true } : {}) - }) - } - ) - - ipcMain.handle( - 'git:checkIgnored', - async ( - _event, - args: { worktreePath: string; paths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.checkIgnoredPaths(args.worktreePath, paths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return checkIgnoredPaths(worktreePath, paths, gitOptions) - } - ) - - // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. - ipcMain.handle( - 'git:findHugeFoldersToIgnore', - async (_event, args: { worktreePath: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) - } - ) - - ipcMain.handle( - 'git:appendGitignore', - async (_event, args: { worktreePath: string; folderName: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return appendFolderToGitignore(worktreePath, args.folderName) - } - ) - - ipcMain.handle( - 'git:history', - async ( - _event, - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => { - const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getHistory(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getHistory(worktreePath, { ...options, ...gitOptions }) - } - ) - - // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. - ipcMain.handle( - 'git:conflictOperation', - async ( - _event, - args: { worktreePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.detectConflictOperation(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return detectConflictOperation(worktreePath) - } - ) - - ipcMain.handle( - 'git:abortMerge', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortMerge(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:abortRebase', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortRebase(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:diff', - async ( - _event, - args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getDiff( - args.worktreePath, - args.filePath, - args.staged, - args.compareAgainstHead - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:commit', - async ( - _event, - args: { worktreePath: string; message: string; connectionId?: string } - ): Promise<{ success: boolean; error?: string }> => { - // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. - if (typeof args.message !== 'string' || args.message.trim().length === 0) { - throw new Error('Commit message is required') - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.commit(args.worktreePath, args.message) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return commitChanges(worktreePath, args.message, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:generateCommitMessage', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'commitMessage', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - let context - try { - context = await provider.getStagedCommitContext(args.worktreePath) - } catch (error) { - console.error('[filesystem] Failed to read remote staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args) - ) - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - let context - try { - context = await getStagedCommitContext(worktreePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } catch (error) { - console.error('[filesystem] Failed to read staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args, worktreePath) - ) - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generateCommitMessageFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGenerateCommitMessage', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGenerateCommitMessageLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:discoverCommitMessageModels', - async ( - _event, - args: { agentId: string; worktreePath?: string; connectionId?: string } - ): Promise => { - const agentId = args.agentId - const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] - if (args.connectionId) { - if (!args.worktreePath) { - return { success: false, error: 'Missing worktree path for remote model discovery.' } - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: `No git provider for connection "${args.connectionId}"` - } - } - return discoverCommitMessageModelsRemote( - agentId as TuiAgent, - args.worktreePath, - (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), - agentCommandOverride - ) - } - let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } - let localDiscoveryOptions: Parameters[3] - if (args.worktreePath) { - const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro - localRuntimeTarget = wslDistro - ? { runtime: 'wsl', wslDistro } - : getLocalAgentRuntimeTarget(gitOptions) - localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - agentId, - commitMessageAgentEnv, - localRuntimeTarget - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return localDiscoveryOptions - ? discoverCommitMessageModelsLocal( - agentId as TuiAgent, - localEnv.env, - agentCommandOverride, - localDiscoveryOptions - ) - : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) - } - ) - - ipcMain.handle( - 'git:generatePullRequestFields', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: HostedReviewProvider - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'pullRequest', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: args.worktreePath, - connectionId: args.connectionId - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: args.worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, commandOptions) => - commandOptions?.timeoutMs !== undefined - ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeoutMs }) - : commandOptions?.timeout !== undefined - ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeout }) - : provider.exec(argv, args.worktreePath), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: worktreePath, - connectionId: args.connectionId, - localGitOptions: gitOptions - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, options) => - gitExecFileAsync(argv, { - cwd: worktreePath, - ...gitOptions, - ...(options?.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), - ...(options?.timeoutMs === undefined && options?.timeout === undefined - ? {} - : { timeout: options?.timeoutMs ?? options?.timeout }) - }), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generatePullRequestFieldsFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGeneratePullRequestFields', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGeneratePullRequestFieldsLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:branchCompare', - async ( - _event, - args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: GitAdmissionTier - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return args.admissionTier - ? provider.getBranchCompare(args.worktreePath, args.baseRef, { - admissionTier: args.admissionTier - }) - : provider.getBranchCompare(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchCompare(worktreePath, args.baseRef, { - ...gitOptions, - ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) - }) - } - ) - - ipcMain.handle( - 'git:commitCompare', - async ( - _event, - args: { worktreePath: string; commitId: string; connectionId?: string } - ): Promise => { - const commitId = validateFullGitObjectId(args.commitId, 'commitId') - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitCompare(args.worktreePath, commitId) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitCompare(worktreePath, commitId, gitOptions) - } - ) - - ipcMain.handle( - 'git:upstreamStatus', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) - } - ) - - ipcMain.handle( - 'git:fetch', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fetchRemote(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFetch(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:syncFork', - async ( - _event, - args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - } - ): Promise => { - const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { - required: true - }) - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:push', - async ( - _event, - args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: GitPushTarget - } - ): Promise => { - // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. - const publish = args.publish === true - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPush(worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true, - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:pull', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pullBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPull(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:fastForward', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFastForward(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:rebaseFromBase', - async ( - _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.rebaseFromBase(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await gitPullRebaseFromBase(worktreePath, args.baseRef, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:branchDiff', - async ( - _event, - args: { - worktreePath: string - compare: { - baseRef: string - baseOid: string - headOid: string - mergeBase: string - } - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { - includePatch: true, - headOid: args.compare.headOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - return ( - results[0] ?? { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchDiff( - worktreePath, - { - mergeBase: args.compare.mergeBase, - headOid: args.compare.headOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:commitDiff', - async ( - _event, - args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') - const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitDiff(args.worktreePath, { - commitOid, - parentOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitDiff( - worktreePath, - { - commitOid, - parentOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:stage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.stageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:unstage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.unstageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:discard', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.discardChanges(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await discardChanges(worktreePath, filePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkDiscard', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkDiscardChanges(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkStage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkStageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkStageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkUnstage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkUnstageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:remoteFileUrl', - async ( - _event, - args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } - ): Promise => { - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteFileUrl(worktreePath, args.relativePath, args.line) - } - ) - - ipcMain.handle( - 'git:remoteCommitUrl', - async ( - _event, - args: { worktreePath: string; sha: string; connectionId?: string } - ): Promise => { - const sha = validateFullGitObjectId(args.sha, 'sha') - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteCommitUrl(args.worktreePath, sha) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteCommitUrl(worktreePath, sha) - } + const context: FilesystemHandlerContext = createFilesystemHandlerContext( + store, + commitMessageAgentEnv, + createSenderScopedRequestCancellations(), + createSenderScopedRequestCancellations() ) + registerFilesystemReadHandlers(context) + registerFilesystemDownloadHandlers(context) + registerFilesystemWriteHandlers(context) + registerFilesystemSearchHandlers(context) + registerFilesystemGitStatusHandlers(context) + registerFilesystemGitCommitHandlers(context) + registerFilesystemGitCommitGenerationHandlers(context) + registerFilesystemGitModelDiscoveryHandlers(context) + registerFilesystemGitPullRequestGenerationHandlers(context) + registerFilesystemGitRemoteHandlers(context) + registerFilesystemGitDiffHandlers(context) + registerFilesystemGitIndexHandlers(context) + registerFilesystemGitUrlHandlers(context) registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts new file mode 100644 index 00000000000..51a90c36213 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -0,0 +1,210 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' +import { randomUUID } from 'node:crypto' +import { open, writeFile } from 'node:fs/promises' +import { getRuntimePathBasename } from '../../../shared/cross-platform-path' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { sanitizeLocalDownloadFilename } from '../../local-download-filename' +import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + cleanupLocalTransferPath, + decodeDownloadedFileContent, + DOWNLOAD_SESSION_TTL_MS, + inspectDownloadDestination, + promoteDownloadedFile, + validateRequiredString, + createSiblingTransferPath, + type DownloadFileResult +} from './filesystem-file-helpers' + +export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { + const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context + + ipcMain.handle( + 'fs:downloadFile', + async ( + event, + args: { filePath?: string; connectionId?: string } + ): Promise => { + const filePath = validateRequiredString(args?.filePath, 'filePath') + const connectionId = validateRequiredString(args?.connectionId, 'connectionId') + const provider = requireSshFilesystemProvider(connectionId) + const remoteStat = await provider.stat(filePath) + if (remoteStat.type === 'directory') { + throw new Error('Cannot download a directory') + } + if (!provider.downloadFile) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + + const remoteBasename = getRuntimePathBasename(filePath) + const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath }) + : await dialog.showSaveDialog({ defaultPath }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await provider.downloadFile(filePath, tempPath) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + registerFilesystemDownloadFolderHandlers() + + ipcMain.handle( + 'fs:saveDownloadedFile', + async ( + event, + args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } + ): Promise => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + if (typeof args?.content !== 'string') { + throw new Error('content is required') + } + const content = args.content + const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:startDownloadedFile', + async ( + event, + args: { suggestedName?: string } + ): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + const transferId = randomUUID() + try { + const handle = await open(tempPath, 'wx') + const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN + const cleanupTimer = setTimeout(() => { + void closeDownloadSession(transferId, true) + }, DOWNLOAD_SESSION_TTL_MS) + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref() + } + downloadSessions.set(transferId, { + destinationPath, + tempPath, + destinationExisted: existed, + handle, + cleanupTimer, + senderId + }) + event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) + return { canceled: false, transferId, destinationPath } + } catch (error) { + await cleanupLocalTransferPath(tempPath) + throw error + } + } + ) + + ipcMain.handle( + 'fs:appendDownloadedFileChunk', + async ( + _event, + args: { transferId?: string; contentBase64?: string } + ): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') + const session = downloadSessions.get(transferId) + if (!session) { + throw new Error('Download session not found') + } + await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) + return { ok: true } + } + ) + + ipcMain.handle( + 'fs:finishDownloadedFile', + async ( + _event, + args: { transferId?: string } + ): Promise<{ canceled: false; destinationPath: string }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const session = await closeDownloadSession(transferId, false) + if (!session) { + throw new Error('Download session not found') + } + let promoted = false + try { + await promoteDownloadedFile( + session.tempPath, + session.destinationPath, + session.destinationExisted + ) + promoted = true + return { canceled: false, destinationPath: session.destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(session.tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:cancelDownloadedFile', + async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + await closeDownloadSession(transferId, true) + return { ok: true } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-file-helpers.ts b/src/main/ipc/filesystem/filesystem-file-helpers.ts new file mode 100644 index 00000000000..53e2eb3ec30 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-helpers.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'node:crypto' +import { open, rename, rm, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' +import { isENOENT } from '../filesystem-path-containment' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +export const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +export const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +export function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts new file mode 100644 index 00000000000..4e55a39f76a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -0,0 +1,157 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import { + cancelGenerateCommitMessageLocal, + generateCommitMessageFromContext, + resolveCommitMessageSettings, + type GenerateCommitMessageResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getStagedCommitContext } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssue } from '../source-control-ai-linked-issue' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-worktree-helpers' + +export function registerFilesystemGitCommitGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generateCommitMessage', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'commitMessage', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + let context + try { + context = await provider.getStagedCommitContext(args.worktreePath) + } catch (error) { + console.error('[filesystem] Failed to read remote staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + let context + try { + context = await getStagedCommitContext(worktreePath, { + ...gitOptions, + admissionTier: 'interactive' + }) + } catch (error) { + console.error('[filesystem] Failed to read staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generateCommitMessageFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGenerateCommitMessage', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGenerateCommitMessageLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts new file mode 100644 index 00000000000..8040a766c2a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron' +import { commitChanges } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:commit', + async ( + _event, + args: { worktreePath: string; message: string; connectionId?: string } + ): Promise<{ success: boolean; error?: string }> => { + // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + if (typeof args.message !== 'string' || args.message.trim().length === 0) { + throw new Error('Commit message is required') + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.commit(args.worktreePath, args.message) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return commitChanges(worktreePath, args.message, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts new file mode 100644 index 00000000000..ff706fcb078 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { getBranchDiff, getCommitDiff } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:branchDiff', + async ( + _event, + args: { + worktreePath: string + compare: { + baseRef: string + baseOid: string + headOid: string + mergeBase: string + } + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { + includePatch: true, + headOid: args.compare.headOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchDiff( + worktreePath, + { + mergeBase: args.compare.mergeBase, + headOid: args.compare.headOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) + + ipcMain.handle( + 'git:commitDiff', + async ( + _event, + args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') + const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitDiff(args.worktreePath, { + commitOid, + parentOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitDiff( + worktreePath, + { + commitOid, + parentOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts new file mode 100644 index 00000000000..fbd550c70e5 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts @@ -0,0 +1,173 @@ +import { ipcMain } from 'electron' +import { + stageFile, + unstageFile, + discardChanges, + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles +} from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:stage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.stageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:unstage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.unstageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:discard', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.discardChanges(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await discardChanges(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:bulkDiscard', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkDiscardChanges(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkStage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkStageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkStageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkUnstage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkUnstageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts new file mode 100644 index 00000000000..0933c08587e --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -0,0 +1,82 @@ +import { ipcMain } from 'electron' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import { + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, + type DiscoverCommitMessageModelsResult +} from '../../text-generation/commit-message-text-generation' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { parseWslPath } from '../../wsl' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { + resolveModelDiscoveryLocalPath, + getLocalAgentRuntimeTarget +} from './filesystem-worktree-helpers' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitModelDiscoveryHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:discoverCommitMessageModels', + async ( + _event, + args: { agentId: string; worktreePath?: string; connectionId?: string } + ): Promise => { + const agentId = args.agentId + const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] + if (args.connectionId) { + if (!args.worktreePath) { + return { success: false, error: 'Missing worktree path for remote model discovery.' } + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + return discoverCommitMessageModelsRemote( + agentId as TuiAgent, + args.worktreePath, + (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + agentCommandOverride + ) + } + let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } + let localDiscoveryOptions: Parameters[3] + if (args.worktreePath) { + const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro + localRuntimeTarget = wslDistro + ? { runtime: 'wsl', wslDistro } + : getLocalAgentRuntimeTarget(gitOptions) + localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + agentId, + commitMessageAgentEnv, + localRuntimeTarget + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return localDiscoveryOptions + ? discoverCommitMessageModelsLocal( + agentId as TuiAgent, + localEnv.env, + agentCommandOverride, + localDiscoveryOptions + ) + : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts new file mode 100644 index 00000000000..b779fb44429 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -0,0 +1,226 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import type { HostedReviewProvider } from '../../../shared/hosted-review' +import { + cancelGeneratePullRequestFieldsLocal, + generatePullRequestFieldsFromContext, + resolveCommitMessageSettings, + type GeneratePullRequestFieldsResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getPullRequestDraftContext } from '../../text-generation/pull-request-context' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { gitExecFileAsync } from '../../git/runner' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssueMeta } from '../source-control-ai-linked-issue' +import { resolveHostedReviewBodyForGeneration } from '../../source-control/pull-request-template' +import { loadPullRequestLinkedIssue } from '../../source-control/pull-request-linked-issue' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-worktree-helpers' + +export function registerFilesystemGitPullRequestGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: HostedReviewProvider + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'pullRequest', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: args.worktreePath, + connectionId: args.connectionId + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: args.worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, commandOptions) => + commandOptions?.timeoutMs !== undefined + ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeoutMs }) + : commandOptions?.timeout !== undefined + ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeout }) + : provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: worktreePath, + connectionId: args.connectionId, + localGitOptions: gitOptions + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, options) => + gitExecFileAsync(argv, { + cwd: worktreePath, + ...gitOptions, + ...(options?.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), + ...(options?.timeoutMs === undefined && options?.timeout === undefined + ? {} + : { timeout: options?.timeoutMs ?? options?.timeout }) + }), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts new file mode 100644 index 00000000000..83d1422e112 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts @@ -0,0 +1,322 @@ +import { ipcMain } from 'electron' +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../../shared/git-diff-compare-types' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../../shared/git-fork-sync' +import type { GitUpstreamStatus } from '../../../shared/git-status-types' +import type { GitPushTarget } from '../../../shared/worktree/types' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { getBranchCompare, getCommitCompare } from '../../git/status' +import { gitFetch, gitPush, gitPull, gitFastForward, gitPullRebaseFromBase } from '../../git/remote' +import { gitSyncForkDefaultBranch } from '../../git/fork-sync' +import { getUpstreamStatus } from '../../git/upstream' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitPushTarget } from '../../git/push-target-validation' +import { assertGitPushTargetShape } from '../../../shared/git-push-target-validation' +import { validateGitForkSyncExpectedUpstream } from '../../../shared/git-fork-sync' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitRemoteHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:branchCompare', + async ( + _event, + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) + } + ) + + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitCompare(worktreePath, commitId, gitOptions) + } + ) + + ipcMain.handle( + 'git:upstreamStatus', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) + } + ) + + ipcMain.handle( + 'git:fetch', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fetchRemote(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFetch(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:syncFork', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + } + ): Promise => { + const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { + required: true + }) + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:push', + async ( + _event, + args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: GitPushTarget + } + ): Promise => { + // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. + const publish = args.publish === true + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPush(worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true, + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:pull', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pullBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPull(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:fastForward', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFastForward(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:rebaseFromBase', + async ( + _event, + args: { worktreePath: string; baseRef: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.rebaseFromBase(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await gitPullRebaseFromBase(worktreePath, args.baseRef, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts new file mode 100644 index 00000000000..09a80f9f993 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -0,0 +1,307 @@ +import { ipcMain } from 'electron' +import type { + GitConflictOperation, + GitStagingArea, + GitStatusResult +} from '../../../shared/git-status-types' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' +import type { GitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { + getStatus, + getSubmoduleStatus, + abortMerge, + abortRebase, + detectConflictOperation, + getDiff +} from '../../git/status' +import { getHistory } from '../../git/history' +import { checkIgnoredPaths } from '../../git/check-ignored-paths' +import { + appendFolderToGitignore, + findKnownHugeFolderPathsToIgnore +} from '../../git/huge-folder-ignore' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { + getLocalGitOptionsForRegisteredWorktree, + getLocalGitOptionsForRepo, + getLocalRepoForRegisteredWorktree +} from '../local-worktree-runtime-options' +import { getWorktreeSharedLinkPaths } from '../../git/worktree-shared-directories' +import { applyGitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerContext): void { + const { store, gitStatusCancellations } = context + ipcMain.handle( + 'git:status', + async ( + event, + args: { + worktreePath: string + connectionId?: string + admissionTier?: GitAdmissionTier + includeIgnored?: boolean + includeLineStats?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + } + ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) + const options = { + includeIgnored: args.includeIgnored ?? false, + admissionTier: args.admissionTier ?? ('status' as const), + ...(args.includeLineStats === false ? { includeLineStats: false } : {}), + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(controller ? { signal: controller.signal } : {}) + } + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). + return await provider.getStatus(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + // Why: one registered-worktree lookup feeds both — status polls this + // handler, and the scan walks every repo's worktree meta. + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] + return await getStatus(worktreePath, { + ...options, + ...gitOptions, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + + ipcMain.handle( + 'git:setStatusUpstreamRefWatch', + (_event, args: GitStatusUpstreamRefWatchRequest): Promise => + applyGitStatusUpstreamRefWatchRequest(store, args) + ) + + // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. + ipcMain.handle( + 'git:submoduleStatus', + async ( + _event, + args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getSubmoduleStatus(worktreePath, args.submodulePath, { + ...gitOptions, + ...(args.area === 'staged' ? { staged: true } : {}) + }) + } + ) + + ipcMain.handle( + 'git:checkIgnored', + async ( + _event, + args: { worktreePath: string; paths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.checkIgnoredPaths(args.worktreePath, paths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return checkIgnoredPaths(worktreePath, paths, gitOptions) + } + ) + + // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. + ipcMain.handle( + 'git:findHugeFoldersToIgnore', + async (_event, args: { worktreePath: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:appendGitignore', + async (_event, args: { worktreePath: string; folderName: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return appendFolderToGitignore(worktreePath, args.folderName) + } + ) + + ipcMain.handle( + 'git:history', + async ( + _event, + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getHistory(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getHistory(worktreePath, { ...options, ...gitOptions }) + } + ) + + // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. + ipcMain.handle( + 'git:conflictOperation', + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.detectConflictOperation(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return detectConflictOperation(worktreePath) + } + ) + + ipcMain.handle( + 'git:abortMerge', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortMerge(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:abortRebase', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortRebase(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:diff', + async ( + _event, + args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getDiff( + args.worktreePath, + args.filePath, + args.staged, + args.compareAgainstHead + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts new file mode 100644 index 00000000000..cb14d98db60 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -0,0 +1,54 @@ +import { ipcMain } from 'electron' +import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' +import { getRemoteCommitUrl, getRemoteFileUrl } from '../../git/repo' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:remoteFileUrl', + async ( + _event, + args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } + ): Promise => { + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteFileUrl(worktreePath, args.relativePath, args.line) + } + ) + + ipcMain.handle( + 'git:remoteCommitUrl', + async ( + _event, + args: { worktreePath: string; sha: string; connectionId?: string } + ): Promise => { + const sha = validateFullGitObjectId(args.sha, 'sha') + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteCommitUrl(args.worktreePath, sha) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteCommitUrl(worktreePath, sha) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts new file mode 100644 index 00000000000..3aeb8320cc3 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -0,0 +1,75 @@ +import type { ChildProcess } from 'node:child_process' +import type { FileHandle } from 'node:fs/promises' +import type { Store } from '../../persistence' +import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import { cleanupLocalTransferPath } from './filesystem-file-helpers' + +export type DownloadSession = { + destinationPath: string + tempPath: string + destinationExisted: boolean + handle: FileHandle + cleanupTimer: ReturnType + senderId: number +} + +export type FilesystemHandlerContext = { + store: Store + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers + activeTextSearches: Map + downloadSessions: Map + listFilesCancellations: SenderScopedRequestCancellations + gitStatusCancellations: SenderScopedRequestCancellations + closeDownloadSession: ( + transferId: string, + cleanupTemp: boolean + ) => Promise + cleanupDownloadSessionsForSender: (senderId: number) => void +} + +export function createFilesystemHandlerContext( + store: Store, + commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | undefined, + listFilesCancellations: SenderScopedRequestCancellations, + gitStatusCancellations: SenderScopedRequestCancellations +): FilesystemHandlerContext { + const activeTextSearches = new Map() + const downloadSessions = new Map() + + const closeDownloadSession = async ( + transferId: string, + cleanupTemp: boolean + ): Promise => { + const session = downloadSessions.get(transferId) + if (!session) { + return null + } + downloadSessions.delete(transferId) + clearTimeout(session.cleanupTimer) + await session.handle.close().catch(() => {}) + if (cleanupTemp) { + await cleanupLocalTransferPath(session.tempPath) + } + return session + } + + const cleanupDownloadSessionsForSender = (senderId: number): void => { + for (const [transferId, session] of Array.from(downloadSessions)) { + if (session.senderId === senderId) { + void closeDownloadSession(transferId, true) + } + } + } + + return { + store, + commitMessageAgentEnv, + activeTextSearches, + downloadSessions, + listFilesCancellations, + gitStatusCancellations, + closeDownloadSession, + cleanupDownloadSessionsForSender + } +} diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts new file mode 100644 index 00000000000..d1a86f1f748 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -0,0 +1,170 @@ +import { ipcMain } from 'electron' +import { readdir, readFile, stat } from 'node:fs/promises' +import { extname } from 'node:path' +import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../../shared/file-name-sort' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' +import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store' +import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + BINARY_PROBE_BYTES, + isBinaryBuffer, + isBinaryFilePrefix, + isDirectoryEntry, + MAX_PREVIEWABLE_BINARY_SIZE, + MAX_TEXT_FILE_SIZE, + PREVIEWABLE_BINARY_MIME_TYPES, + readLocalLogSnapshot +} from './filesystem-file-helpers' + +export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:readDir', + async (_event, args: { dirPath: string; connectionId?: string }): Promise => { + // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. + let throwSite: ReadDirThrowSite = 'authorize' + try { + if (args.connectionId) { + throwSite = 'ssh-provider' + const provider = requireSshFilesystemProvider(args.connectionId) + // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) + } + const dirPath = await resolveAuthorizedPath(args.dirPath, store) + throwSite = 'readdir' + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries.map((entry) => ({ + name: entry.name, + isDirectory: isDirectoryEntry(entry), + isSymlink: entry.isSymbolicLink() + })) + return sortDirEntries(mapped) + } catch (error: unknown) { + recordCrashBreadcrumb( + 'fs_readdir_error', + buildReadDirErrorBreadcrumb({ + dirPath: args.dirPath, + connectionId: args.connectionId, + throwSite, + error + }) + ) + throw error + } + } + ) + + ipcMain.handle( + 'fs:readFile', + async ( + _event, + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.readFile(args.filePath) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } + const stats = await stat(filePath) + const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` + ) + } + + if (mimeType) { + const buffer = await readFile(filePath) + return { + content: buffer.toString('base64'), + isBinary: true, + // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. + isImage: true, + mimeType + } + } + + // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { + return { content: '', isBinary: true } + } + + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + ) + + ipcMain.handle( + 'fs:listMarkdownDocuments', + async ( + _event, + args: { rootPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const relativePaths = await provider.listFiles(args.rootPath) + return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) + } + const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) + return listMarkdownDocuments(rootPath) + } + ) + + ipcMain.handle( + 'fs:stat', + async ( + _event, + args: { filePath: string; connectionId?: string } + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const result = await provider.stat(args.filePath) + return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } + ) + + ipcMain.handle( + 'fs:pathExists', + async (_event, args: { filePath: string; connectionId?: string }): Promise => { + try { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + await provider.stat(args.filePath) + return true + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + await stat(filePath) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts new file mode 100644 index 00000000000..c2ec1531ead --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -0,0 +1,234 @@ +import { ipcMain } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { SearchOptions, SearchResult } from '../../../shared/code-search-types' +import { + buildRgArgs, + createAccumulator, + DEFAULT_SEARCH_MAX_RESULTS, + finalize, + ingestRgJsonLine, + SEARCH_TIMEOUT_MS +} from '../../../shared/text-search' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../../shared/ripgrep-process-availability' +import { toWindowsWslPath, parseWslPath } from '../../wsl' +import { wslAwareSpawn } from '../../git/runner' +import { + getSshFilesystemProvider, + requireSshFilesystemProvider +} from '../../providers/ssh-filesystem-dispatch' +import { checkRgAvailable } from '../rg-availability' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { listQuickOpenFiles } from '../filesystem-list-files' +import { searchWithGitGrep } from '../filesystem-search-git' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT } from './filesystem-file-helpers' + +export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { + const { store, activeTextSearches } = context + + ipcMain.handle( + 'fs:search', + async (event, args: SearchOptions & { connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.search(args) + } + const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.rootPath, + rootPath + ) + const maxResults = Math.max( + 1, + Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro + + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { + return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) + } + + return new Promise((resolvePromise) => { + const rgArgs = buildRgArgs(args.query, rootPath, args) + // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let child: ChildProcess | null = null + let killTimeout: ReturnType + + const transformAbsPath = wslDistroForOutput + ? (path: string): string => + path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + : undefined + + const finish = (result: SearchResult | PromiseLike): void => { + if (resolved) { + return + } + resolved = true + if (activeTextSearches.get(searchKey) === child) { + activeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. + child?.stdout?.off('data', handleStdoutData) + child?.stderr?.off('data', handleStderrData) + child?.off('error', handleError) + child?.off('close', handleClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) + } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + activeTextSearches.set(searchKey, nextChild) + + const handleStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + } + const handleStderrData = (): void => { + // Drain stderr so rg cannot block on a full pipe. + } + const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + } + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', handleStdoutData) + nextChild.stderr!.on('data', handleStderrData) + nextChild.once('error', handleError) + nextChild.once('close', handleClose) + + // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. + killTimeout = setTimeout(() => { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + }, SEARCH_TIMEOUT_MS) + }) + } + ) + + const { listFilesCancellations } = context + ipcMain.handle( + 'fs:listFiles', + async ( + event, + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + } + ): Promise => { + const controller = listFilesCancellations.begin(event, args.requestToken) + try { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. + if (!provider) { + return [] + } + // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. + if ( + args.searchQuery !== undefined && + provider.supportsQuickOpenSearch && + !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) + ) { + const legacyFiles = await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, + signal: controller?.signal + }) + const ranker = new QuickOpenPathRanker( + args.searchQuery, + args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT + ) + for (const file of legacyFiles) { + ranker.consider(file) + } + return ranker.result().paths + } + return await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), + ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), + signal: controller?.signal + }) + } + return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) + } finally { + listFilesCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) + }) +} diff --git a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts b/src/main/ipc/filesystem/filesystem-worktree-helpers.ts new file mode 100644 index 00000000000..18dd5c4f1f6 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-worktree-helpers.ts @@ -0,0 +1,185 @@ +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence' +import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' +import { resolve } from 'node:path' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { listRepoWorktrees } from '../../repo-worktrees' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { splitWorktreeId } from '../../../shared/worktree/id' + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getCandidateLocalWorktreePaths( + worktreePath: string, + resolvedWorktreePath: string +): Set { + return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) +} + +function hasRegisteredWorktreeMetaForRepo( + store: Store, + repoId: string, + candidatePaths: Set +): boolean { + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + return true + } + } + return false +} + +function comparableRemotePath(value: string): string { + return value.replace(/[/\\]+$/g, '') +} + +function hasRegisteredRemoteWorktreeMetaForRepo( + store: Store, + repoId: string, + worktreePath: string +): boolean { + const comparableWorktreePath = comparableRemotePath(worktreePath) + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if ( + parsed?.repoId === repoId && + comparableRemotePath(parsed.worktreePath) === comparableWorktreePath + ) { + return true + } + } + return false +} + +async function localRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string +): Promise { + let resolvedWorktreePath: string + try { + resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + } catch { + return false + } + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + if (candidatePaths.has(comparableLocalPath(repo.path))) { + return true + } + if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { + return true + } + try { + const worktrees = await listRepoWorktrees(repo) + return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) + } catch { + return false + } +} + +async function remoteRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string, + connectionId: string +): Promise { + const comparableWorktreePath = comparableRemotePath(worktreePath) + if (comparableRemotePath(repo.path) === comparableWorktreePath) { + return true + } + const provider = getSshGitProvider(connectionId) + if (!provider) { + return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) + } + try { + const worktrees = await provider.listWorktrees(repo.path) + return worktrees.some( + (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath + ) + } catch { + return false + } +} + +export async function getRepoForSourceControlAi( + store: Store, + args: { repoId?: string; worktreePath: string; connectionId?: string } +): Promise { + if (!args.repoId) { + return null + } + const repo = store.getRepo(args.repoId) + if (!repo) { + return null + } + if (args.connectionId) { + if (repo.connectionId !== args.connectionId) { + return null + } + // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. + return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) + ? repo + : null + } + if (repo.connectionId) { + return null + } + // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. + return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null +} + +export function getLocalAgentRuntimeTarget( + gitOptions: LocalProjectWorktreeGitOptions +): CommitMessageAgentRuntimeTarget { + return gitOptions.wslDistro + ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } + : { runtime: 'host' } +} + +export async function resolveModelDiscoveryLocalPath( + store: Store, + requestedPath: string +): Promise { + try { + return await resolveRegisteredWorktreePath(requestedPath, store) + } catch (error) { + const folderWorkspaces = + typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] + const isFolderWorkspaceRoot = folderWorkspaces.some( + (workspace) => + comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) + ) + if (!isFolderWorkspaceRoot) { + throw error + } + return resolveAuthorizedPath(requestedPath, store) + } +} + +export function getLocalTextGenerationTarget( + worktreePath: string, + gitOptions: LocalProjectWorktreeGitOptions, + env?: NodeJS.ProcessEnv +): Extract { + return { + kind: 'local', + cwd: worktreePath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), + ...(env ? { env } : {}) + } +} + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts new file mode 100644 index 00000000000..07d4de5b56a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -0,0 +1,91 @@ +import { ipcMain, shell } from 'electron' +import { lstat, writeFile } from 'node:fs/promises' +import type { SshMutationExpectation } from '../../../shared/ssh-types' +import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { tryDeleteWslUncPath } from '../../wsl-unc-delete' +import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:writeFile', + async ( + _event, + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.writeFile(args.filePath, args.content) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, args.content, 'utf-8') + } + ) + + ipcMain.handle( + 'fs:deletePath', + async ( + _event, + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.deletePath(args.targetPath, args.recursive) + } + // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). + const targetPath = await resolveAuthorizedPath(args.targetPath, store, { + preserveSymlink: true + }) + // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). + if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { + return + } + // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). + try { + await shell.trashItem(targetPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + } + ) + + registerFilesystemMutationHandlers(store) + + ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { + authorizeExternalPath(args.targetPath) + }) +} diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts index c30aed82d10..b1535b50408 100644 --- a/src/main/macos-press-and-hold-default.test.ts +++ b/src/main/macos-press-and-hold-default.test.ts @@ -267,20 +267,26 @@ describe('readBundleIdentifierFromExecutablePath', () => { }) describe('startup wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { const callIndex = source.indexOf( 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' ) const initDataPathIndex = source.indexOf('initDataPath()') - const readyIndex = source.indexOf('app.whenReady().then(') + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') expect(callIndex).toBeGreaterThanOrEqual(0) expect(readyIndex).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData // path is only captured there. expect(callIndex).toBeGreaterThan(initDataPathIndex) - expect(callIndex).toBeLessThan(readyIndex) + expect(preflightCall).toBeLessThan(readyIndex) }) }) diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts index cc2c7d7a26e..315eba73aa0 100644 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ b/src/main/quit-teardown-agent-browser-daemons.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and * every open tab's daemon outlived the app (#16367). */ -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') function teardownBarrierMembers(): string { const start = source.indexOf('settleTeardownWithinDeadline([') @@ -25,7 +25,7 @@ describe('quit teardown of agent-browser daemons', () => { it('captures the destroyAllSessions promise instead of firing and forgetting', () => { expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ + /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ ) // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 8ce0b167a4a..5d12b979bc3 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1,2182 +1,10 @@ -/* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */ -import type { BrowserWindow } from 'electron' -import type { - CodexRateLimitResetResult, - RateLimitState, - ProviderRateLimits, - InactiveAccountUsage, - RateLimitRuntimeTarget -} from '../../shared/rate-limit-types' -import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' -import type { InactiveClaudeAccountInfo } from './claude-fetcher' -import { mapClaudeUsageWindow } from './claude-usage-window' -import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' -import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import { - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget, - type NormalizedClaudeAccountSelectionTarget -} from '../claude-accounts/runtime-selection' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' -import { fetchKimiRateLimits } from './kimi-fetcher' -import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' -import { fetchGrokRateLimits } from './grok-fetcher' -import { readGrokAuthSession } from './grok-auth' -import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' -import { fetchMiniMaxRateLimits } from './minimax-fetcher' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' -import { - normalizeCodexAccountSelectionTarget, - type CodexAccountSelectionTarget, - type NormalizedCodexAccountSelectionTarget -} from '../codex-accounts/runtime-selection' -import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service' +import { RateLimitServiceConfiguration } from './service/service-configuration' -export type InactiveCodexAccountInfo = { - id: string - resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } -} +export type { InactiveCodexAccountInfo } from './service/service-types' -type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution -type KimiHomeResolver = () => Promise -type ClaudeAuthPreparationResolver = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -type OpenCodeGoRateLimitConfig = { - sessionCookie: string - workspaceIdOverride: string -} - -type MiniMaxRateLimitConfig = { - sessionCookie: string - groupId: string - models: string -} - -type MiniMaxResolvedConfig = { - config: MiniMaxRateLimitConfig - error: string | null -} - -type GeminiCliOAuthEnabledResolver = () => boolean -type ActiveRateLimitProvider = ProviderRateLimits['provider'] -type ActiveProviderState = { - provider: ActiveRateLimitProvider - limits: ProviderRateLimits | null -} -type ActiveWindowRefreshPlan = - | { kind: 'none' } - | { kind: 'full' } - | { kind: 'providers'; providers: ActiveRateLimitProvider[] } - -// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. -const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes -const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. -const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. -const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts -const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS -// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. -const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS -const MAX_ACTIVE_FAILURE_STREAK = 8 -// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. -const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'grok' -]) -const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped -// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". -const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 -// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. -const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 -const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open -// Why: each inactive Codex probe spawns a real codex process inside that -// account's live credential home; pace them out instead of bursting every -// account the moment the switcher opens. -const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 -const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 - -// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). -type InternalRateLimitState = { - claude: ProviderRateLimits | null - codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null - opencodeGo: ProviderRateLimits | null - kimi: ProviderRateLimits | null - antigravity: ProviderRateLimits | null - minimax: ProviderRateLimits | null - grok: ProviderRateLimits | null -} - -function normalizePollingInterval(ms: number): number { - if (!Number.isFinite(ms)) { - return DEFAULT_POLL_MS - } - return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) -} - -function isSystemDefaultClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. - if (!authPreparation) { - return true - } - const provenance = authPreparation?.provenance - return provenance === 'system' || Boolean(provenance?.endsWith(':system')) -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { - // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. - const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') - return trimmed || null -} - -function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - const onAbort = (): void => { - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, ms) - signal.addEventListener('abort', onAbort, { once: true }) - }) -} - -function isSameUsageWindow( - a: ProviderRateLimits['session'], - b: ProviderRateLimits['session'] -): boolean { - if (!a || !b) { - return a === b - } - return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt -} - -export class RateLimitService { - private state: InternalRateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null - } - private grokAuthConfigured = readGrokAuthSession().status === 'ok' - private pollInterval: number = DEFAULT_POLL_MS - private timer: ReturnType | null = null - private deferredStartupRefreshTimer: ReturnType | null = null - // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. - private lastActiveFailureRetryAtByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. - private activeFailureStreakByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - private mainWindow: BrowserWindow | null = null - private detachWindowListeners: (() => void) | null = null - private isFetching = false - private fullFetchQueued = false - private codexOnlyFetchQueued = false - private claudeOnlyFetchQueued = false - private grokOnlyFetchQueued = false - private activeFetchAbortControllers = new Set() - private fetchIdleResolvers: (() => void)[] = [] - private codexFetchGeneration = 0 - private claudeFetchGeneration = 0 - // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. - private lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null - private opencodeFetchGeneration = 0 - private minimaxFetchGeneration = 0 - private lastOpencodeConfigHash = '' - private lastMiniMaxConfigHash = '' - private codexHomePathResolver: CodexHomePathResolver | null = null - private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - // Why: resolved per cycle — the local-account runtime policy can flip between fetches. - private kimiHomeResolver: KimiHomeResolver | null = null - private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null - private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null - private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null - private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null - private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null - private networkProxySettingsResolver: (() => NetworkProxySettings) | null = null - private inactiveClaudeCache = new Map() - private inactiveCodexCache = new Map() - private inactiveClaudeFetching = new Set() - private inactiveCodexFetching = new Set() - private inactiveCodexFetchInFlight = false - private lastInactiveClaudeFetchAt = 0 - private inactiveClaudeAccountsGeneration = 0 - private lastInactiveCodexFetchAt = 0 - private inactiveCodexAccountsGeneration = 0 - private stateListeners = new Set<(state: RateLimitState) => void>() - - constructor() {} - - onStateChange(listener: (state: RateLimitState) => void): () => void { - this.stateListeners.add(listener) - return () => { - this.stateListeners.delete(listener) - } - } - - setCodexHomePathResolver(resolver: CodexHomePathResolver): void { - this.codexHomePathResolver = resolver - } - - // Why: `skip` and a `ready` null are different answers — null still means the - // system-default lane, so it must never stand in for "don't fetch" (#STA-4422). - private resolveCodexHome(target?: CodexAccountSelectionTarget): { - skip: boolean - homePath: string | null - } { - const resolution = this.codexHomePathResolver?.(target) - if (!resolution) { - return { skip: false, homePath: null } - } - return resolution.kind === 'skip' - ? { skip: true, homePath: null } - : { skip: false, homePath: resolution.codexHomePath } - } - - setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { - this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) - } - - setKimiHomeResolver(resolver: KimiHomeResolver): void { - this.kimiHomeResolver = resolver - } - - // Why: resolving a WSL home probes wsl.exe, so it must not run before the other - // providers' fetches are started; chaining keeps the no-resolver path immediate. - private fetchKimiWithResolvedHome(): Promise { - const pendingHome = this.kimiHomeResolver?.() - return pendingHome - ? pendingHome.then((home) => fetchKimiRateLimits({ home })) - : fetchKimiRateLimits({ home: undefined }) - } - - setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { - this.claudeAuthPreparationResolver = resolver - } - - setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { - this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) - } - - setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { - this.openCodeGoConfigResolver = resolver - } - - setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { - this.miniMaxConfigResolver = resolver - } - - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver - } - - setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { - this.networkProxySettingsResolver = resolver - } - - setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { - this.inactiveClaudeAccountsResolver = resolver - this.inactiveClaudeAccountsGeneration += 1 - } - - setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { - this.inactiveCodexAccountsResolver = resolver - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - } - - attach(mainWindow: BrowserWindow): void { - this.detachWindowListeners?.() - this.mainWindow = mainWindow - const refreshOnResume = (): void => { - void this.refreshIfWindowActive() - } - // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. - const detachWindowListeners = (): void => { - mainWindow.removeListener('focus', refreshOnResume) - mainWindow.removeListener('show', refreshOnResume) - mainWindow.removeListener('restore', refreshOnResume) - mainWindow.removeListener('closed', onClosed) - } - const onClosed = (): void => { - detachWindowListeners() - if (this.detachWindowListeners === detachWindowListeners) { - this.detachWindowListeners = null - } - if (this.mainWindow === mainWindow) { - this.mainWindow = null - } - } - mainWindow.on('focus', refreshOnResume) - mainWindow.on('show', refreshOnResume) - mainWindow.on('restore', refreshOnResume) - mainWindow.on('closed', onClosed) - this.detachWindowListeners = detachWindowListeners - } - - start(options: { fetchImmediately?: boolean } = {}): void { - if (options.fetchImmediately !== false) { - void this.fetchAll() - } else { - this.scheduleDeferredStartupRefresh() - } - this.startTimer() - } - - stop(): void { - this.abortActiveFetchCycle() - this.clearQueuedFetches() - this.inactiveClaudeFetching.clear() - this.inactiveCodexFetching.clear() - this.resolveAndClearFetchIdleWaiters() - this.stopTimer() - this.clearDeferredStartupRefresh() - this.detachWindowListeners?.() - this.detachWindowListeners = null - this.mainWindow = null - } - - getState(): RateLimitState { - this.pruneInactiveClaudeState() - this.pruneInactiveCodexState() - return { - ...this.state, - // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. - minimaxCookieConfigured: hasMiniMaxSessionCookie(), - grokAuthConfigured: this.grokAuthConfigured, - claudeTarget: this.claudeFetchTarget, - codexTarget: this.codexFetchTarget, - inactiveClaudeAccounts: this.buildInactiveArray( - this.inactiveClaudeCache, - this.inactiveClaudeFetching - ), - inactiveCodexAccounts: this.buildInactiveArray( - this.inactiveCodexCache, - this.inactiveCodexFetching - ) - } - } - - async refresh(): Promise { - // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. - await this.fetchAll({ force: true }) - return this.getState() - } - - async refreshIfStale(): Promise { - // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - return this.getState() - } - - async refreshGrok(): Promise { - await this.fetchGrokOnly({ force: true }) - return this.getState() - } - - invalidateMiniMaxCredentialState(): void { - this.minimaxFetchGeneration += 1 - // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. - this.updateState({ - ...this.state, - minimax: this.withFetchingStatus(null, 'minimax') - }) - } - - async refreshForCodexAccountChange( - outgoingAccountId?: string | null, - target?: CodexAccountSelectionTarget - ): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - // Why: weekly-only plans report no session window, so gating on session alone - // dropped their snapshot and left the switcher's inline bars empty. - if ( - outgoingAccountId && - (this.state.codex?.session || this.state.codex?.weekly) && - this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - ) { - this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) - } - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.codex = 0 - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - // Why: the switch must NOT reset the inactive-fetch debounce — re-probing - // every inactive account per switch spawns codex in each credential home - // and endangers rotating refresh tokens; the switcher shows the cached - // snapshot (seeded above for the outgoing account) until the debounce ends. - // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(null, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - this.activeFailureStreakByProvider.codex = 0 - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async consumeCodexRateLimitResetCredit(options: { - idempotencyKey: string - target: RateLimitRuntimeTarget - codexHomePath: string | null - }): Promise { - const codexTarget = normalizeCodexAccountSelectionTarget(options.target) - const codexHomePath = options.codexHomePath - const scopedStateBeforeReset = this.getState() - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - if (missingWslCodexHome) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') - } - try { - const outcome = await consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: options.idempotencyKey - }) - const state = await this.fetchCodexResetResultState( - codexTarget, - codexHomePath, - scopedStateBeforeReset - ) - return { outcome, state } - } catch (error) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw error - } - } - - async refreshForClaudeAccountChange( - outgoingAccountId?: string | null, - target?: ClaudeAccountSelectionTarget - ): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. - if ( - outgoingAccountId && - this.state.claude?.session && - this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - ) { - this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) - } - this.claudeFetchTarget = nextTarget - this.inactiveClaudeAccountsGeneration += 1 - this.pruneInactiveClaudeState() - this.claudeFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.claude = 0 - // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. - this.lastClaudeAuthSnapshot = null - this.lastInactiveClaudeFetchAt = 0 - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(null, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - this.claudeFetchTarget = nextTarget - this.claudeFetchGeneration += 1 - this.activeFailureStreakByProvider.claude = 0 - if (targetChanged) { - // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. - this.lastClaudeAuthSnapshot = null - } - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } - - async fetchInactiveClaudeAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveClaudeState() - if (this.inactiveClaudeFetching.size > 0) { - return - } - const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - const fetchGeneration = this.inactiveClaudeAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - - for (const account of accounts) { - this.inactiveClaudeFetching.add(account.id) - } - this.pushToRenderer() - - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveClaudeCache.get(account.id) ?? null - this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeCache.delete(account.id) - } - } - this.inactiveClaudeFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { - this.lastInactiveClaudeFetchAt = Date.now() - } - } finally { - this.finishFetchCycle(controller) - } - } - - async fetchInactiveCodexAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveCodexState() - if (this.inactiveCodexFetchInFlight) { - return - } - const accounts = this.inactiveCodexAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. - const fetchGeneration = this.inactiveCodexAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - this.inactiveCodexFetchInFlight = true - - let staggerNextProbe = false - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - if (staggerNextProbe) { - await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) - // Why: the account set can change while the stagger delay runs. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - } - const home = account.resolveHome() - if (home.kind === 'skip') { - continue - } - staggerNextProbe = true - this.inactiveCodexFetching.add(account.id) - this.pushToRenderer() - try { - // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. - // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. - const fresh = await fetchCodexRateLimits({ - codexHomePath: home.managedHomePath, - allowPtyFallback: false, - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveCodexCache.get(account.id) ?? null - this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one failure from aborting the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexCache.delete(account.id) - } - } - this.inactiveCodexFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { - this.lastInactiveCodexFetchAt = Date.now() - } - } finally { - this.inactiveCodexFetchInFlight = false - this.finishFetchCycle(controller) - } - } - - evictInactiveClaudeCache(accountId: string): void { - this.inactiveClaudeAccountsGeneration += 1 - this.inactiveClaudeCache.delete(accountId) - this.inactiveClaudeFetching.delete(accountId) - this.pushToRenderer() - } - - private isCurrentInactiveClaudeAccount(accountId: string): boolean { - return (this.inactiveClaudeAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private isCurrentInactiveCodexAccount(accountId: string): boolean { - return (this.inactiveCodexAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private pruneInactiveClaudeState(): void { - const currentIds = new Set( - (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveClaudeCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeCache.delete(accountId) - } - } - for (const accountId of this.inactiveClaudeFetching) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeFetching.delete(accountId) - } - } - } - - private pruneInactiveCodexState(): void { - const currentIds = new Set( - (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveCodexCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveCodexCache.delete(accountId) - } - } - for (const accountId of this.inactiveCodexFetching) { - if (!currentIds.has(accountId)) { - this.inactiveCodexFetching.delete(accountId) - } - } - } - - evictInactiveCodexCache(accountId: string): void { - // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. - this.inactiveCodexCache.delete(accountId) - this.inactiveCodexFetching.delete(accountId) - this.pushToRenderer() - } - - setPollingInterval(ms: number): void { - this.pollInterval = normalizePollingInterval(ms) - if (this.timer) { - this.stopTimer() - this.startTimer() - } - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private startTimer(): void { - this.stopTimer() - this.timer = setInterval(() => { - if (!this.shouldBackgroundPoll()) { - return - } - void this.fetchAll() - }, this.pollInterval) - } - - private stopTimer(): void { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - } - - private scheduleDeferredStartupRefresh(): void { - this.clearDeferredStartupRefresh() - this.deferredStartupRefreshTimer = setTimeout(() => { - this.deferredStartupRefreshTimer = null - void this.refreshIfWindowActive() - }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) - } - - private clearDeferredStartupRefresh(): void { - if (this.deferredStartupRefreshTimer) { - clearTimeout(this.deferredStartupRefreshTimer) - this.deferredStartupRefreshTimer = null - } - } - - private shouldBackgroundPoll(): boolean { - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return false - } - // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). - if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { - return false - } - return this.mainWindow.isFocused() - } - - private getActiveProviderState(): ActiveProviderState[] { - // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. - const byProvider: Record = { - claude: this.state.claude, - codex: this.state.codex, - gemini: this.state.gemini, - 'opencode-go': this.state.opencodeGo, - kimi: this.state.kimi, - minimax: this.state.minimax, - grok: this.state.grok, - antigravity: this.state.antigravity - } - return Object.entries(byProvider).map(([provider, limits]) => ({ - provider: provider as ActiveRateLimitProvider, - limits - })) - } - - private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { - const retryableFailures: ActiveRateLimitProvider[] = [] - for (const { provider, limits } of this.getActiveProviderState()) { - if (!limits || limits.status === 'idle' || limits.status === 'fetching') { - return { kind: 'full' } - } - if (limits.status === 'ok' || limits.status === 'unavailable') { - if (now - limits.updatedAt >= MIN_REFETCH_MS) { - return { kind: 'full' } - } - continue - } - // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. - if (limits.status === 'error') { - // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. - if (this.isRetryAfterActive(limits)) { - continue - } - const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] - const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ? Math.min( - ACTIVE_FAILURE_REFETCH_MS * - 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), - MAX_ACTIVE_FAILURE_REFETCH_MS - ) - : MIN_REFETCH_MS - if (now - lastRetryAt >= throttleMs) { - retryableFailures.push(provider) - } - } - } - - if (retryableFailures.length === 0) { - return { kind: 'none' } - } - return { kind: 'providers', providers: retryableFailures } - } - - private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { - if (plan.kind === 'none') { - return - } - if (plan.kind === 'full') { - // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. - // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. - if (!this.isFetching) { - const now = Date.now() - for (const { provider, limits } of this.getActiveProviderState()) { - if (limits?.status === 'error') { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - } - } - await this.fetchAll() - return - } - - // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. - if (this.isFetching) { - return - } - - const now = Date.now() - for (const provider of plan.providers) { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - - const canRefreshIndividually = plan.providers.every((provider) => - INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ) - if (!canRefreshIndividually) { - await this.fetchAll() - return - } - - // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. - if (plan.providers.includes('claude')) { - await this.fetchClaudeOnly() - } - if (plan.providers.includes('codex')) { - await this.fetchCodexOnly() - } - if (plan.providers.includes('grok')) { - await this.fetchGrokOnly() - } - } - - private async refreshIfWindowActive(): Promise { - if (!this.shouldBackgroundPoll()) { - return - } - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - } - - private async fetchAll(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.fullFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - shouldContinue = true - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchCodexOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.codexOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - shouldContinue = true - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchClaudeOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.claudeOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchGrokOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.grokOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private waitForFetchIdle(): Promise { - if ( - !this.isFetching && - !this.fullFetchQueued && - !this.codexOnlyFetchQueued && - !this.claudeOnlyFetchQueued && - !this.grokOnlyFetchQueued - ) { - return Promise.resolve() - } - // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. - return new Promise((resolve) => { - this.fetchIdleResolvers.push(resolve) - }) - } - - private resolveFetchIdleWaiters(): void { - if ( - this.isFetching || - this.fullFetchQueued || - this.codexOnlyFetchQueued || - this.claudeOnlyFetchQueued || - this.grokOnlyFetchQueued - ) { - return - } - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private beginFetchCycle(): AbortController { - const controller = new AbortController() - this.activeFetchAbortControllers.add(controller) - return controller - } - - private finishFetchCycle(controller: AbortController): void { - this.activeFetchAbortControllers.delete(controller) - } - - private async runWithFetchAbortSignal( - fn: (signal: AbortSignal) => Promise - ): Promise { - const controller = this.beginFetchCycle() - try { - await fn(controller.signal) - return controller.signal - } finally { - this.finishFetchCycle(controller) - } - } - - private abortActiveFetchCycle(): void { - for (const controller of this.activeFetchAbortControllers) { - controller.abort() - } - this.activeFetchAbortControllers.clear() - } - - private clearQueuedFetches(): void { - this.fullFetchQueued = false - this.codexOnlyFetchQueued = false - this.claudeOnlyFetchQueued = false - this.grokOnlyFetchQueued = false - } - - private resolveAndClearFetchIdleWaiters(): void { - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private isSameCodexTarget( - left: NormalizedCodexAccountSelectionTarget, - right: NormalizedCodexAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private isSameClaudeTarget( - left: NormalizedClaudeAccountSelectionTarget, - right: NormalizedClaudeAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private getCodexProvenance( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null - ): string { - const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' - return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` - } - - private getMissingWslCodexHomeResult( - target: NormalizedCodexAccountSelectionTarget - ): ProviderRateLimits | null { - if (target.runtime !== 'wsl') { - return null - } - return { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, - status: 'error' - } - } - - private async fetchCodexResetResultState( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null, - stateBeforeReset: RateLimitState - ): Promise { - const controller = this.beginFetchCycle() - let fresh: ProviderRateLimits - try { - fresh = await fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal: controller.signal - }) - } catch (error) { - fresh = { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: toErrorMessage(error), - status: 'error' - } - } finally { - this.finishFetchCycle(controller) - } - - const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentCodexHome = this.resolveCodexHome(target) - // Why: a skip has no provenance to compare, so treat it as no longer active - // rather than publishing this result against the system-default lane. - const stillActive = - !currentCodexHome.skip && - this.isSameCodexTarget(this.codexFetchTarget, target) && - this.getCodexProvenance(target, currentCodexHome.homePath) === - this.getCodexProvenance(target, codexHomePath) - if (stillActive) { - // Why: this post-redemption read is newer than every Codex fetch that - // started before it, so invalidate those results before publishing it. - this.codexFetchGeneration += 1 - this.trackActiveFailureStreak('codex', fresh) - this.updateState({ - ...this.state, - codex: this.applyStalePolicy(fresh, this.state.codex) - }) - } - - // Why: the caller must receive the redeemed target even if the global UI - // switched targets while the provider mutation was in flight. - return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } - } - - private shouldAllowCodexPtyFallback(): boolean { - // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. - return process.platform !== 'win32' - } - - private shouldAllowClaudePtyFallback( - authPreparation: ClaudeRuntimeAuthPreparation | undefined - ): boolean { - // Why: Windows hidden PTY support is less reliable than host/WSL shells. - if (process.platform === 'win32') { - return false - } - // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. - return !isSystemDefaultClaudeAuth(authPreparation) - } - - private shouldAllowClaudeUsagePanelSupplement(): boolean { - // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. - return process.platform !== 'win32' - } - - private resolveMiniMaxConfig(): MiniMaxResolvedConfig { - try { - return { - config: this.miniMaxConfigResolver?.() ?? { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: null - } - } catch (error) { - // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. - return { - config: { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: toErrorMessage(error) - } - } - } - - private getMiniMaxCredentialError(message: string): ProviderRateLimits { - return { - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: message, - status: 'error', - usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } - } - } - - // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. - private isRetryAfterActive(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'error' && - limits.usageMetadata?.retryAtMs && - limits.usageMetadata.retryAtMs > Date.now() - ) - } - - // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. - private isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'ok' && - limits.usageMetadata?.source === 'live-session' && - Date.now() - limits.updatedAt < MIN_REFETCH_MS - ) - } - - private shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { - return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) - } - - private resolveClaudeFetchApply( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not - // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. - const current = this.state.claude - if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { - return current - } - return this.applyStalePolicy(fresh, previous) - } - - private rememberClaudeAuthSnapshot( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - claudeGeneration: number, - claudeTarget: NormalizedClaudeAccountSelectionTarget - ): void { - // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. - if ( - claudeGeneration !== this.claudeFetchGeneration || - !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - ) { - return - } - this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - provenance: authPreparation?.provenance ?? 'system' - } - } - - /** Live usage windows forwarded from a Claude session's statusLine command. */ - ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { - // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. - const snapshot = this.lastClaudeAuthSnapshot - if (!snapshot) { - // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. - console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { - eventConfigDir: event.configDir - }) - return - } - // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. - if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { - console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { - eventConfigDir: event.configDir, - snapshotConfigDir: snapshot.configDir - }) - return - } - const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) - const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) - if (!freshSession && !freshWeekly) { - return - } - const previous = this.state.claude - // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. - const session = freshSession ?? previous?.session ?? null - const weekly = freshWeekly ?? previous?.weekly ?? null - if ( - previous?.status === 'ok' && - previous.usageMetadata?.source === 'live-session' && - Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && - isSameUsageWindow(previous.session, session) && - isSameUsageWindow(previous.weekly, weekly) - ) { - return - } - this.activeFailureStreakByProvider.claude = 0 - this.updateState({ - ...this.state, - claude: { - provider: 'claude', - session, - weekly, - // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. - // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. - fableWeekly: previous?.fableWeekly ?? null, - updatedAt: Date.now(), - error: null, - status: 'ok', - usageMetadata: { - source: 'live-session', - lastSuccessfulSource: 'live-session', - credentialSource: previous?.usageMetadata?.credentialSource, - authProvenance: snapshot.provenance - } - } - }) - } - - private trackActiveFailureStreak( - provider: ActiveRateLimitProvider, - fresh: ProviderRateLimits - ): void { - if (fresh.status === 'error') { - this.activeFailureStreakByProvider[provider] = Math.min( - this.activeFailureStreakByProvider[provider] + 1, - MAX_ACTIVE_FAILURE_STREAK - ) - return - } - if (fresh.status === 'ok' || fresh.status === 'unavailable') { - this.activeFailureStreakByProvider[provider] = 0 - } - } - - private withFetchingStatus( - current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' - ): ProviderRateLimits { - if (!current) { - return { - provider, - session: null, - weekly: null, - updatedAt: 0, - error: null, - status: 'fetching' - } - } - // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. - if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { - return current - } - return { ...current, status: 'fetching' } - } - - private async runFetchAllCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const codexTarget = this.codexFetchTarget - const previousState = this.state - // Why: a skipped Codex poll must not stop the other providers' cycle, so gate - // only the Codex slot instead of returning early (#STA-4422). - const codexHome = this.resolveCodexHome(codexTarget) - const codexFetchGated = codexHome.skip - const codexHomePath = codexHome.homePath - const codexStateBeforeFetch = - previousState.codex?.status === 'fetching' ? null : previousState.codex - const codexProvenance = codexFetchGated - ? null - : this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration - const openCodeGoConfig = this.openCodeGoConfigResolver?.() - const cookie = openCodeGoConfig?.sessionCookie ?? '' - const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const miniMaxConfigResult = this.resolveMiniMaxConfig() - const miniMaxCookie = miniMaxConfigResult.config.sessionCookie - const miniMaxGroupId = miniMaxConfigResult.config.groupId - const miniMaxModels = miniMaxConfigResult.config.models - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false - // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - // Discard stale data on config change — it belongs to a different session/workspace. - const currentConfigHash = `${cookie}|${workspaceIdOverride}` - const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash - if (opencodeConfigChanged) { - this.lastOpencodeConfigHash = currentConfigHash - this.opencodeFetchGeneration += 1 - } - const opencodeGeneration = this.opencodeFetchGeneration - - const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` - const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash - if (miniMaxConfigChanged) { - this.lastMiniMaxConfigHash = currentMiniMaxConfigHash - this.minimaxFetchGeneration += 1 - } - const miniMaxGeneration = this.minimaxFetchGeneration - - // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude'), - // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. - codex: codexFetchGated - ? codexStateBeforeFetch - : this.withFetchingStatus(previousState.codex, 'codex'), - gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), - opencodeGo: opencodeConfigChanged - ? this.withFetchingStatus(null, 'opencode-go') - : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), - kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), - minimax: miniMaxConfigChanged - ? this.withFetchingStatus(null, 'minimax') - : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const missingWslCodexHome = - codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) - const grokResultPromise = fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).then( - (value) => ({ status: 'fulfilled', value }) as const, - (reason) => ({ status: 'rejected', reason }) as const - ) - - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - const claudeFetchGated = - !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) - - if (signal.aborted) { - return - } - - const claude = - claudeResult.status === 'fulfilled' - ? claudeResult.value - : ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const codex = - codexResult.status === 'fulfilled' - ? codexResult.value - : ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value - : ({ - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) - - const opencodeGo = - opencodeGoResult.status === 'fulfilled' - ? opencodeGoResult.value - : ({ - provider: 'opencode-go', - session: null, - weekly: null, - monthly: null, - updatedAt: Date.now(), - error: - opencodeGoResult.reason instanceof Error - ? opencodeGoResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const kimi = - kimiResult.status === 'fulfilled' - ? kimiResult.value - : ({ - provider: 'kimi', - session: null, - weekly: null, - updatedAt: Date.now(), - error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const miniMax = - miniMaxResult.status === 'fulfilled' - ? miniMaxResult.value - : ({ - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - miniMaxResult.reason instanceof Error - ? miniMaxResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const latestCodexHome = this.resolveCodexHome(codexTarget) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - // Why: a finishing skip has no provenance, so an in-flight result must never be - // applied as though the target had become the system default (#STA-4422). - const shouldApplyCodex = - !codexFetchGated && - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - const codexBecameUnavailable = - !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration - // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. - const shouldApplyClaude = - !claudeFetchGated && - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration - const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) - if (shouldApplyOpencode) { - this.trackActiveFailureStreak('opencode-go', opencodeGo) - } - this.trackActiveFailureStreak('kimi', kimi) - if (shouldApplyMiniMax) { - this.trackActiveFailureStreak('minimax', miniMax) - } - - // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude, - codex: shouldApplyCodex - ? this.applyStalePolicy(codex, previousState.codex) - : codexBecameUnavailable - ? codexStateBeforeFetch - : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), - opencodeGo: shouldApplyOpencode - ? opencodeConfigChanged - ? opencodeGo - : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) - : this.state.opencodeGo, - kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), - minimax: shouldApplyMiniMax - ? miniMaxConfigChanged - ? miniMax - : this.applyStalePolicy(miniMax, previousState.minimax) - : this.state.minimax - }) - - const grokResult = await grokResultPromise - if (signal.aborted) { - return - } - const grok = - grokResult.status === 'fulfilled' - ? grokResult.value - : ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const codexTarget = this.codexFetchTarget - const codexGeneration = this.codexFetchGeneration - const codexHome = this.resolveCodexHome(codexTarget) - // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). - if (codexHome.skip) { - if ( - codexGeneration === this.codexFetchGeneration && - this.state.codex?.status === 'fetching' - ) { - this.updateState({ ...this.state, codex: null }) - } - return - } - const codexHomePath = codexHome.homePath - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) - const previousState = this.state - - this.updateState({ - ...previousState, - codex: this.withFetchingStatus(previousState.codex, 'codex') - }) - - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - const codex = await ( - missingWslCodexHome - ? Promise.resolve(missingWslCodexHome) - : fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - }) - ).catch((err): ProviderRateLimits => ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestCodexHome = this.resolveCodexHome(codexTarget) - if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { - this.updateState({ - ...this.state, - codex: previousState.codex?.status === 'fetching' ? null : previousState.codex - }) - return - } - const shouldApplyCodex = - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.updateState({ - ...this.state, - codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex - }) - } - - private async runFetchClaudeOnlyCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const previousState = this.state - - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude') - }) - - const claude = await fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }).catch((err): ProviderRateLimits => ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude - }) - } - - private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const previousState = this.state - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - this.updateState({ - ...previousState, - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const grok = await fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).catch((err): ProviderRateLimits => ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private applyStalePolicy( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Fresh data is fine — use it - if (fresh.status === 'ok') { - return { - ...fresh, - usageMetadata: { - ...fresh.usageMetadata, - lastSuccessfulSource: - fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource - } - } - } - - // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. - if (fresh.status === 'unavailable') { - return fresh - } - - const previousHasData = Boolean( - previous?.session || - previous?.weekly || - previous?.fableWeekly || - previous?.monthly || - (previous?.buckets && previous.buckets.length > 0) - ) - - // No previous data to fall back on - if (!previous || !previousHasData) { - return fresh - } - - // Previous data is too old — don't show stale data - const staleThresholdMs = - fresh.usageMetadata?.failureKind === 'rate-limited' - ? RATE_LIMITED_STALE_THRESHOLD_MS - : STALE_THRESHOLD_MS - if (Date.now() - previous.updatedAt > staleThresholdMs) { - return fresh - } - - // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. - return { - ...previous, - error: fresh.error, - status: 'error', - usageMetadata: { - ...previous.usageMetadata, - ...fresh.usageMetadata, - lastSuccessfulSource: - previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source - } - } - } - - private buildInactiveArray( - cache: Map, - fetching: Set - ): InactiveAccountUsage[] { - const result: InactiveAccountUsage[] = [] - for (const [accountId, limits] of cache) { - result.push({ - accountId, - rateLimits: limits, - updatedAt: limits.updatedAt, - isFetching: fetching.has(accountId) - }) - } - // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. - for (const accountId of fetching) { - if (!cache.has(accountId)) { - result.push({ - accountId, - rateLimits: null, - updatedAt: 0, - isFetching: true - }) - } - } - return result - } - - private updateState(next: InternalRateLimitState): void { - this.state = next - this.pushToRenderer() - } - - private pushToRenderer(): void { - const state = this.getState() - for (const listener of this.stateListeners) { - try { - listener(state) - } catch { - // ignore — one bad listener must not break the others - } - } - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return - } - this.mainWindow.webContents.send('rateLimits:update', state) - } -} +/** + * Coordinates provider quota polling and publishes a stable rate-limit snapshot. + * The implementation is layered by lifecycle, account selection, and fetch policy + * so each module stays small while this path remains the public integration seam. + */ +export class RateLimitService extends RateLimitServiceConfiguration {} diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts new file mode 100644 index 00000000000..9e758a98518 --- /dev/null +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -0,0 +1,182 @@ +import { consumeCodexRateLimitResetCredit } from '../codex-fetcher' +import { RateLimitServiceInactiveAccounts } from './service-inactive-accounts' +import { + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type CodexAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type RateLimitRuntimeTarget, + type RateLimitState, + type CodexRateLimitResetResult +} from './service-types' + +export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceInactiveAccounts { + async refresh(): Promise { + // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. + await this.fetchAll({ force: true }) + return this.getState() + } + + async refreshIfStale(): Promise { + // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + return this.getState() + } + + async refreshGrok(): Promise { + await this.fetchGrokOnly({ force: true }) + return this.getState() + } + + invalidateMiniMaxCredentialState(): void { + this.minimaxFetchGeneration += 1 + // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. + this.updateState({ + ...this.state, + minimax: this.withFetchingStatus(null, 'minimax') + }) + } + + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + // Why: weekly-only plans report no session window, so gating on session alone + // dropped their snapshot and left the switcher's inline bars empty. + if ( + outgoingAccountId && + (this.state.codex?.session || this.state.codex?.weekly) && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { + this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) + } + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.codex = 0 + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + // Why: the switch must NOT reset the inactive-fetch debounce — re-probing + // every inactive account per switch spawns codex in each credential home + // and endangers rotating refresh tokens; the switcher shows the cached + // snapshot (seeded above for the outgoing account) until the debounce ends. + // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(null, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.activeFailureStreakByProvider.codex = 0 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async consumeCodexRateLimitResetCredit(options: { + idempotencyKey: string + target: RateLimitRuntimeTarget + codexHomePath: string | null + }): Promise { + const codexTarget = normalizeCodexAccountSelectionTarget(options.target) + const codexHomePath = options.codexHomePath + const scopedStateBeforeReset = this.getState() + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + if (missingWslCodexHome) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') + } + try { + const outcome = await consumeCodexRateLimitResetCredit({ + codexHomePath, + idempotencyKey: options.idempotencyKey + }) + const state = await this.fetchCodexResetResultState( + codexTarget, + codexHomePath, + scopedStateBeforeReset + ) + return { outcome, state } + } catch (error) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw error + } + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { + this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) + } + this.claudeFetchTarget = nextTarget + this.inactiveClaudeAccountsGeneration += 1 + this.pruneInactiveClaudeState() + this.claudeFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.claude = 0 + // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. + this.lastClaudeAuthSnapshot = null + this.lastInactiveClaudeFetchAt = 0 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(null, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.activeFailureStreakByProvider.claude = 0 + if (targetChanged) { + // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. + this.lastClaudeAuthSnapshot = null + } + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshAfterClaudeLivePtysDrained(): Promise { + // Why: "Waiting for Claude session" can only recover once no live claude + // owns the credentials. Refetch on the last PTY exit instead of leaving + // the stale terminal error up until the failure backoff elapses. + if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { + return + } + this.activeFailureStreakByProvider.claude = 0 + await this.fetchClaudeOnly({ force: true }) + } +} diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts new file mode 100644 index 00000000000..52aa02ccddd --- /dev/null +++ b/src/main/rate-limits/service/service-configuration.ts @@ -0,0 +1,139 @@ +import type { BrowserWindow } from 'electron' +import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store' +import { RateLimitServiceAccountRefresh } from './service-account-refresh' +import { + type CodexAccountSelectionTarget, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAccountSelectionTarget, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type InactiveCodexAccountInfo, + type InactiveClaudeAccountInfo, + type RateLimitState, + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type NetworkProxySettings +} from './service-types' + +export abstract class RateLimitServiceConfiguration extends RateLimitServiceAccountRefresh { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { + this.codexHomePathResolver = resolver + } + + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setKimiHomeResolver(resolver: KimiHomeResolver): void { + this.kimiHomeResolver = resolver + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { + this.claudeAuthPreparationResolver = resolver + } + + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + + setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { + this.openCodeGoConfigResolver = resolver + } + + setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { + this.miniMaxConfigResolver = resolver + } + + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { + this.geminiCliOAuthEnabledResolver = resolver + } + + setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { + this.networkProxySettingsResolver = resolver + } + + setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { + this.inactiveClaudeAccountsResolver = resolver + this.inactiveClaudeAccountsGeneration += 1 + } + + setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { + this.inactiveCodexAccountsResolver = resolver + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + } + attach(mainWindow: BrowserWindow): void { + this.detachWindowListeners?.() + this.mainWindow = mainWindow + const refreshOnResume = (): void => { + void this.refreshIfWindowActive() + } + // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. + const detachWindowListeners = (): void => { + mainWindow.removeListener('focus', refreshOnResume) + mainWindow.removeListener('show', refreshOnResume) + mainWindow.removeListener('restore', refreshOnResume) + mainWindow.removeListener('closed', onClosed) + } + const onClosed = (): void => { + detachWindowListeners() + if (this.detachWindowListeners === detachWindowListeners) { + this.detachWindowListeners = null + } + if (this.mainWindow === mainWindow) { + this.mainWindow = null + } + } + mainWindow.on('focus', refreshOnResume) + mainWindow.on('show', refreshOnResume) + mainWindow.on('restore', refreshOnResume) + mainWindow.on('closed', onClosed) + this.detachWindowListeners = detachWindowListeners + } + + start(options: { fetchImmediately?: boolean } = {}): void { + if (options.fetchImmediately !== false) { + void this.fetchAll() + } else { + this.scheduleDeferredStartupRefresh() + } + this.startTimer() + } + + stop(): void { + this.abortActiveFetchCycle() + this.clearQueuedFetches() + this.inactiveClaudeFetching.clear() + this.inactiveCodexFetching.clear() + this.resolveAndClearFetchIdleWaiters() + this.stopTimer() + this.clearDeferredStartupRefresh() + this.detachWindowListeners?.() + this.detachWindowListeners = null + this.mainWindow = null + } + + getState(): RateLimitState { + this.pruneInactiveClaudeState() + this.pruneInactiveCodexState() + return { + ...this.state, + // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. + minimaxCookieConfigured: hasMiniMaxSessionCookie(), + grokAuthConfigured: this.grokAuthConfigured, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, + inactiveClaudeAccounts: this.buildInactiveArray( + this.inactiveClaudeCache, + this.inactiveClaudeFetching + ), + inactiveCodexAccounts: this.buildInactiveArray( + this.inactiveCodexCache, + this.inactiveCodexFetching + ) + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-control.ts b/src/main/rate-limits/service/service-fetch-control.ts new file mode 100644 index 00000000000..cab01551def --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-control.ts @@ -0,0 +1,80 @@ +import { RateLimitServiceState } from './service-state' + +export abstract class RateLimitServiceFetchControl extends RateLimitServiceState { + protected waitForFetchIdle(): Promise { + if ( + !this.isFetching && + !this.fullFetchQueued && + !this.codexOnlyFetchQueued && + !this.claudeOnlyFetchQueued && + !this.grokOnlyFetchQueued + ) { + return Promise.resolve() + } + // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. + return new Promise((resolve) => { + this.fetchIdleResolvers.push(resolve) + }) + } + + protected resolveFetchIdleWaiters(): void { + if ( + this.isFetching || + this.fullFetchQueued || + this.codexOnlyFetchQueued || + this.claudeOnlyFetchQueued || + this.grokOnlyFetchQueued + ) { + return + } + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } + + protected beginFetchCycle(): AbortController { + const controller = new AbortController() + this.activeFetchAbortControllers.add(controller) + return controller + } + + protected finishFetchCycle(controller: AbortController): void { + this.activeFetchAbortControllers.delete(controller) + } + + protected async runWithFetchAbortSignal( + fn: (signal: AbortSignal) => Promise + ): Promise { + const controller = this.beginFetchCycle() + try { + await fn(controller.signal) + return controller.signal + } finally { + this.finishFetchCycle(controller) + } + } + + protected abortActiveFetchCycle(): void { + for (const controller of this.activeFetchAbortControllers) { + controller.abort() + } + this.activeFetchAbortControllers.clear() + } + + protected clearQueuedFetches(): void { + this.fullFetchQueued = false + this.codexOnlyFetchQueued = false + this.claudeOnlyFetchQueued = false + this.grokOnlyFetchQueued = false + } + + protected resolveAndClearFetchIdleWaiters(): void { + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts new file mode 100644 index 00000000000..2c28c519a14 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -0,0 +1,139 @@ +import { RateLimitServiceFetchTargets } from './service-fetch-targets' +import { + LIVE_CLAUDE_INGEST_DEDUPE_MS, + MIN_REFETCH_MS, + isSameUsageWindow, + normalizeClaudeConfigDir, + type ClaudeRuntimeAuthPreparation, + type ClaudeStatusLineRateLimits, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits +} from './service-types' +import { mapClaudeUsageWindow } from '../claude-usage-window' + +export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { + protected getMiniMaxCredentialError(message: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. + protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'error' && + limits.usageMetadata?.retryAtMs && + limits.usageMetadata.retryAtMs > Date.now() + ) + } + + // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. + protected isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'ok' && + limits.usageMetadata?.source === 'live-session' && + Date.now() - limits.updatedAt < MIN_REFETCH_MS + ) + } + + protected shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { + return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) + } + + protected resolveClaudeFetchApply( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not + // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. + const current = this.state.claude + if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { + return current + } + return this.applyStalePolicy(fresh, previous) + } + + protected rememberClaudeAuthSnapshot( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + claudeGeneration: number, + claudeTarget: NormalizedClaudeAccountSelectionTarget + ): void { + // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. + if ( + claudeGeneration !== this.claudeFetchGeneration || + !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + ) { + return + } + this.lastClaudeAuthSnapshot = { + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + provenance: authPreparation?.provenance ?? 'system' + } + } + + /** Live usage windows forwarded from a Claude session's statusLine command. */ + ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { + // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. + const snapshot = this.lastClaudeAuthSnapshot + if (!snapshot) { + // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. + console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { + eventConfigDir: event.configDir + }) + return + } + // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. + if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { + console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { + eventConfigDir: event.configDir, + snapshotConfigDir: snapshot.configDir + }) + return + } + const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) + const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) + if (!freshSession && !freshWeekly) { + return + } + const previous = this.state.claude + // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. + const session = freshSession ?? previous?.session ?? null + const weekly = freshWeekly ?? previous?.weekly ?? null + if ( + previous?.status === 'ok' && + previous.usageMetadata?.source === 'live-session' && + Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && + isSameUsageWindow(previous.session, session) && + isSameUsageWindow(previous.weekly, weekly) + ) { + return + } + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ + ...this.state, + claude: { + provider: 'claude', + session, + weekly, + // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. + // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. + fableWeekly: previous?.fableWeekly ?? null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + credentialSource: previous?.usageMetadata?.credentialSource, + authProvenance: snapshot.provenance + } + } + }) + } +} diff --git a/src/main/rate-limits/service/service-fetch-queue.ts b/src/main/rate-limits/service/service-fetch-queue.ts new file mode 100644 index 00000000000..49531ee3fb1 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-queue.ts @@ -0,0 +1,245 @@ +import { RateLimitServiceProviderCycles } from './service-provider-cycles' + +export abstract class RateLimitServiceFetchQueue extends RateLimitServiceProviderCycles { + protected async fetchAll(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.fullFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + shouldContinue = true + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchCodexOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.codexOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + shouldContinue = true + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchClaudeOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.claudeOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchGrokOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.grokOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts new file mode 100644 index 00000000000..2d316294dbc --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -0,0 +1,171 @@ +import { RateLimitServiceResultPolicy } from './service-result-policy' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchKimiRateLimits } from '../kimi-fetcher' +import { + isSystemDefaultClaudeAuth, + type ClaudeRuntimeAuthPreparation, + type CodexAccountSelectionTarget, + type MiniMaxResolvedConfig, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits, + type RateLimitState, + toErrorMessage +} from './service-types' + +export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResultPolicy { + protected resolveCodexHome(target?: CodexAccountSelectionTarget): { + skip: boolean + homePath: string | null + } { + const resolution = this.codexHomePathResolver?.(target) + if (!resolution) { + return { skip: false, homePath: null } + } + return resolution.kind === 'skip' + ? { skip: true, homePath: null } + : { skip: false, homePath: resolution.codexHomePath } + } + + // Why: resolving a WSL home probes wsl.exe, so it must not run before the other + // providers' fetches are started; chaining keeps the no-resolver path immediate. + protected fetchKimiWithResolvedHome(): Promise { + const pendingHome = this.kimiHomeResolver?.() + return pendingHome + ? pendingHome.then((home) => fetchKimiRateLimits({ home })) + : fetchKimiRateLimits({ home: undefined }) + } + + protected isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + protected getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + protected async fetchCodexResetResultState( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null, + stateBeforeReset: RateLimitState + ): Promise { + const controller = this.beginFetchCycle() + let fresh: ProviderRateLimits + try { + fresh = await fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal: controller.signal + }) + } catch (error) { + fresh = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: toErrorMessage(error), + status: 'error' + } + } finally { + this.finishFetchCycle(controller) + } + + const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) + const currentCodexHome = this.resolveCodexHome(target) + // Why: a skip has no provenance to compare, so treat it as no longer active + // rather than publishing this result against the system-default lane. + const stillActive = + !currentCodexHome.skip && + this.isSameCodexTarget(this.codexFetchTarget, target) && + this.getCodexProvenance(target, currentCodexHome.homePath) === + this.getCodexProvenance(target, codexHomePath) + if (stillActive) { + // Why: this post-redemption read is newer than every Codex fetch that + // started before it, so invalidate those results before publishing it. + this.codexFetchGeneration += 1 + this.trackActiveFailureStreak('codex', fresh) + this.updateState({ + ...this.state, + codex: this.applyStalePolicy(fresh, this.state.codex) + }) + } + + // Why: the caller must receive the redeemed target even if the global UI + // switched targets while the provider mutation was in flight. + return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } + } + + protected shouldAllowCodexPtyFallback(): boolean { + // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. + return process.platform !== 'win32' + } + + protected shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { + // Why: Windows hidden PTY support is less reliable than host/WSL shells. + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. + return !isSystemDefaultClaudeAuth(authPreparation) + } + + protected shouldAllowClaudeUsagePanelSupplement(): boolean { + // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. + return process.platform !== 'win32' + } + + protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { + try { + return { + config: this.miniMaxConfigResolver?.() ?? { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: null + } + } catch (error) { + // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. + return { + config: { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: toErrorMessage(error) + } + } + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts new file mode 100644 index 00000000000..7104face38f --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -0,0 +1,215 @@ +import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' +import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { + protected async runFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + const prepared = await this.prepareFetchAllCycle(signal, options) + if (!prepared) { + return + } + const { + claudeTarget, + claudeGeneration, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } = prepared + if (signal.aborted) { + return + } + + const claude = + claudeResult.status === 'fulfilled' + ? claudeResult.value + : ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const codex = + codexResult.status === 'fulfilled' + ? codexResult.value + : ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const gemini = + geminiResult.status === 'fulfilled' + ? geminiResult.value + : ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + const antigravity = deriveAntigravityRateLimits(gemini) + + const opencodeGo = + opencodeGoResult.status === 'fulfilled' + ? opencodeGoResult.value + : ({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: + opencodeGoResult.reason instanceof Error + ? opencodeGoResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const kimi = + kimiResult.status === 'fulfilled' + ? kimiResult.value + : ({ + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const miniMax = + miniMaxResult.status === 'fulfilled' + ? miniMaxResult.value + : ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + miniMaxResult.reason instanceof Error + ? miniMaxResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const latestCodexHome = this.resolveCodexHome(codexTarget) + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + // Why: a finishing skip has no provenance, so an in-flight result must never be + // applied as though the target had become the system default (#STA-4422). + const shouldApplyCodex = + !codexFetchGated && + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + const codexBecameUnavailable = + !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration + // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. + const shouldApplyClaude = + !claudeFetchGated && + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration + const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.trackActiveFailureStreak('gemini', gemini) + this.trackActiveFailureStreak('antigravity', antigravity) + if (shouldApplyOpencode) { + this.trackActiveFailureStreak('opencode-go', opencodeGo) + } + this.trackActiveFailureStreak('kimi', kimi) + if (shouldApplyMiniMax) { + this.trackActiveFailureStreak('minimax', miniMax) + } + + // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude, + codex: shouldApplyCodex + ? this.applyStalePolicy(codex, previousState.codex) + : codexBecameUnavailable + ? codexStateBeforeFetch + : this.state.codex, + gemini: this.applyStalePolicy(gemini, previousState.gemini), + opencodeGo: shouldApplyOpencode + ? opencodeConfigChanged + ? opencodeGo + : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) + : this.state.opencodeGo, + kimi: this.applyStalePolicy(kimi, previousState.kimi), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), + minimax: shouldApplyMiniMax + ? miniMaxConfigChanged + ? miniMax + : this.applyStalePolicy(miniMax, previousState.minimax) + : this.state.minimax + }) + + const grokResult = await grokResultPromise + if (signal.aborted) { + return + } + const grok = + grokResult.status === 'fulfilled' + ? grokResult.value + : ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts new file mode 100644 index 00000000000..1bbf3bf497d --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -0,0 +1,204 @@ +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import { fetchMiniMaxRateLimits } from '../minimax-fetcher' +import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' +import { RateLimitServiceFetchPolicy } from './service-fetch-policy' +import type { + ClaudeRuntimeAuthPreparation, + InternalRateLimitState, + NormalizedClaudeAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget, + ProviderRateLimits +} from './service-types' + +export type FetchAllCyclePrepared = { + claudeTarget: NormalizedClaudeAccountSelectionTarget + claudeGeneration: number + claudeAuthPreparation: ClaudeRuntimeAuthPreparation | undefined + claudeProvenance: string + codexTarget: NormalizedCodexAccountSelectionTarget + previousState: InternalRateLimitState + codexFetchGated: boolean + codexStateBeforeFetch: ProviderRateLimits | null + codexProvenance: string | null + codexGeneration: number + opencodeConfigChanged: boolean + opencodeGeneration: number + miniMaxConfigChanged: boolean + miniMaxGeneration: number + claudeFetchGated: boolean + results: [ + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult + ] + grokResultPromise: Promise< + { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } + > +} + +export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { + protected async prepareFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return null + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return null + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const codexTarget = this.codexFetchTarget + const previousState = this.state + // Why: a skipped Codex poll must not stop the other providers' cycle, so gate + // only the Codex slot instead of returning early (#STA-4422). + const codexHome = this.resolveCodexHome(codexTarget) + const codexFetchGated = codexHome.skip + const codexHomePath = codexHome.homePath + const codexStateBeforeFetch = + previousState.codex?.status === 'fetching' ? null : previousState.codex + const codexProvenance = codexFetchGated + ? null + : this.getCodexProvenance(codexTarget, codexHomePath) + const codexGeneration = this.codexFetchGeneration + const openCodeGoConfig = this.openCodeGoConfigResolver?.() + const cookie = openCodeGoConfig?.sessionCookie ?? '' + const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const miniMaxConfigResult = this.resolveMiniMaxConfig() + const miniMaxCookie = miniMaxConfigResult.config.sessionCookie + const miniMaxGroupId = miniMaxConfigResult.config.groupId + const miniMaxModels = miniMaxConfigResult.config.models + const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false + // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + // Discard stale data on config change — it belongs to a different session/workspace. + const currentConfigHash = `${cookie}|${workspaceIdOverride}` + const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash + if (opencodeConfigChanged) { + this.lastOpencodeConfigHash = currentConfigHash + this.opencodeFetchGeneration += 1 + } + const opencodeGeneration = this.opencodeFetchGeneration + + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` + const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash + if (miniMaxConfigChanged) { + this.lastMiniMaxConfigHash = currentMiniMaxConfigHash + this.minimaxFetchGeneration += 1 + } + const miniMaxGeneration = this.minimaxFetchGeneration + + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude'), + // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. + codex: codexFetchGated + ? codexStateBeforeFetch + : this.withFetchingStatus(previousState.codex, 'codex'), + gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), + opencodeGo: opencodeConfigChanged + ? this.withFetchingStatus(null, 'opencode-go') + : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), + kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), + minimax: miniMaxConfigChanged + ? this.withFetchingStatus(null, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax'), + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const missingWslCodexHome = + codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) + const grokResultPromise = fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + const claudeFetchGated = + !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = + await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) + ]) + + if (signal.aborted) { + return null + } + return { + claudeTarget, + claudeGeneration, + claudeAuthPreparation, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } + } +} diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts new file mode 100644 index 00000000000..5d977991af4 --- /dev/null +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -0,0 +1,246 @@ +import { fetchManagedAccountUsage } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { RateLimitServicePolling } from './service-polling' +import { + INACTIVE_CODEX_PROBE_STAGGER_MS, + INACTIVE_FETCH_DEBOUNCE_MS, + delayUnlessAborted +} from './service-types' + +export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling { + async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveClaudeState() + if (this.inactiveClaudeFetching.size > 0) { + return + } + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + const fetchGeneration = this.inactiveClaudeAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + + for (const account of accounts) { + this.inactiveClaudeFetching.add(account.id) + } + this.pushToRenderer() + + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + try { + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } + } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) + } + } + + async fetchInactiveCodexAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveCodexState() + if (this.inactiveCodexFetchInFlight) { + return + } + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. + const fetchGeneration = this.inactiveCodexAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + this.inactiveCodexFetchInFlight = true + + let staggerNextProbe = false + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + if (staggerNextProbe) { + await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) + // Why: the account set can change while the stagger delay runs. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + } + const home = account.resolveHome() + if (home.kind === 'skip') { + continue + } + staggerNextProbe = true + this.inactiveCodexFetching.add(account.id) + this.pushToRenderer() + try { + // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. + // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. + const fresh = await fetchCodexRateLimits({ + codexHomePath: home.managedHomePath, + allowPtyFallback: false, + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } + } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } + } finally { + this.inactiveCodexFetchInFlight = false + this.finishFetchCycle(controller) + } + } + + evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeAccountsGeneration += 1 + this.inactiveClaudeCache.delete(accountId) + this.inactiveClaudeFetching.delete(accountId) + this.pushToRenderer() + } + + protected isCurrentInactiveClaudeAccount(accountId: string): boolean { + return (this.inactiveClaudeAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected isCurrentInactiveCodexAccount(accountId: string): boolean { + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected pruneInactiveClaudeState(): void { + const currentIds = new Set( + (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveClaudeCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeCache.delete(accountId) + } + } + for (const accountId of this.inactiveClaudeFetching) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeFetching.delete(accountId) + } + } + } + + protected pruneInactiveCodexState(): void { + const currentIds = new Set( + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveCodexCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveCodexCache.delete(accountId) + } + } + for (const accountId of this.inactiveCodexFetching) { + if (!currentIds.has(accountId)) { + this.inactiveCodexFetching.delete(accountId) + } + } + } + + evictInactiveCodexCache(accountId: string): void { + // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. + this.inactiveCodexCache.delete(accountId) + this.inactiveCodexFetching.delete(accountId) + this.pushToRenderer() + } +} diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts new file mode 100644 index 00000000000..c861726cc52 --- /dev/null +++ b/src/main/rate-limits/service/service-polling.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFetchQueue } from './service-fetch-queue' +import { + ACTIVE_FAILURE_REFETCH_MS, + DEFERRED_STARTUP_ACTIVE_REFRESH_MS, + INDIVIDUALLY_REFRESHABLE_PROVIDERS, + MAX_ACTIVE_FAILURE_REFETCH_MS, + MIN_REFETCH_MS, + normalizePollingInterval, + type ActiveProviderState, + type ActiveRateLimitProvider, + type ActiveWindowRefreshPlan, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue { + setPollingInterval(ms: number): void { + this.pollInterval = normalizePollingInterval(ms) + if (this.timer) { + this.stopTimer() + this.startTimer() + } + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + protected startTimer(): void { + this.stopTimer() + this.timer = setInterval(() => { + if (!this.shouldBackgroundPoll()) { + return + } + void this.fetchAll() + }, this.pollInterval) + } + + protected stopTimer(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + protected scheduleDeferredStartupRefresh(): void { + this.clearDeferredStartupRefresh() + this.deferredStartupRefreshTimer = setTimeout(() => { + this.deferredStartupRefreshTimer = null + void this.refreshIfWindowActive() + }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) + } + + protected clearDeferredStartupRefresh(): void { + if (this.deferredStartupRefreshTimer) { + clearTimeout(this.deferredStartupRefreshTimer) + this.deferredStartupRefreshTimer = null + } + } + + protected shouldBackgroundPoll(): boolean { + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return false + } + // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). + if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { + return false + } + return this.mainWindow.isFocused() + } + + protected getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + protected getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. + if (limits.status === 'error') { + // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. + if (this.isRetryAfterActive(limits)) { + continue + } + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? Math.min( + ACTIVE_FAILURE_REFETCH_MS * + 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), + MAX_ACTIVE_FAILURE_REFETCH_MS + ) + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + protected async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. + // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. + if (!this.isFetching) { + const now = Date.now() + for (const { provider, limits } of this.getActiveProviderState()) { + if (limits?.status === 'error') { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + } + } + await this.fetchAll() + return + } + + // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + + protected async refreshIfWindowActive(): Promise { + if (!this.shouldBackgroundPoll()) { + return + } + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + } +} diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts new file mode 100644 index 00000000000..75c56d96e67 --- /dev/null +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFullCycleApplication } from './service-full-cycle-application' +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFullCycleApplication { + protected async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const codexTarget = this.codexFetchTarget + const codexGeneration = this.codexFetchGeneration + const codexHome = this.resolveCodexHome(codexTarget) + // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). + if (codexHome.skip) { + if ( + codexGeneration === this.codexFetchGeneration && + this.state.codex?.status === 'fetching' + ) { + this.updateState({ ...this.state, codex: null }) + } + return + } + const codexHomePath = codexHome.homePath + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) + const previousState = this.state + + this.updateState({ + ...previousState, + codex: this.withFetchingStatus(previousState.codex, 'codex') + }) + + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + }) + ).catch((err): ProviderRateLimits => ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestCodexHome = this.resolveCodexHome(codexTarget) + if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { + this.updateState({ + ...this.state, + codex: previousState.codex?.status === 'fetching' ? null : previousState.codex + }) + return + } + const shouldApplyCodex = + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.updateState({ + ...this.state, + codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex + }) + } + + protected async runFetchClaudeOnlyCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return + } + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + return + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const previousState = this.state + + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude') + }) + + const claude = await fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }).catch((err): ProviderRateLimits => ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + const shouldApplyClaude = + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude + }) + } + + protected async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const previousState = this.state + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + this.updateState({ + ...previousState, + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const grok = await fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).catch((err): ProviderRateLimits => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts new file mode 100644 index 00000000000..db00053fdcf --- /dev/null +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -0,0 +1,112 @@ +import { RateLimitServiceFetchControl } from './service-fetch-control' +import { + MAX_ACTIVE_FAILURE_STREAK, + RATE_LIMITED_STALE_THRESHOLD_MS, + STALE_THRESHOLD_MS, + type ActiveRateLimitProvider, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetchControl { + protected applyStalePolicy( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Fresh data is fine — use it + if (fresh.status === 'ok') { + return { + ...fresh, + usageMetadata: { + ...fresh.usageMetadata, + lastSuccessfulSource: + fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource + } + } + } + + // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. + if (fresh.status === 'unavailable') { + return fresh + } + + const previousHasData = Boolean( + previous?.session || + previous?.weekly || + previous?.fableWeekly || + previous?.monthly || + (previous?.buckets && previous.buckets.length > 0) + ) + + // No previous data to fall back on + if (!previous || !previousHasData) { + return fresh + } + + // Previous data is too old — don't show stale data + const staleThresholdMs = + fresh.usageMetadata?.failureKind === 'rate-limited' + ? RATE_LIMITED_STALE_THRESHOLD_MS + : STALE_THRESHOLD_MS + if (Date.now() - previous.updatedAt > staleThresholdMs) { + return fresh + } + + // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. + return { + ...previous, + error: fresh.error, + status: 'error', + usageMetadata: { + ...previous.usageMetadata, + ...fresh.usageMetadata, + lastSuccessfulSource: + previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source + } + } + } + + protected trackActiveFailureStreak( + provider: ActiveRateLimitProvider, + fresh: ProviderRateLimits + ): void { + if (fresh.status === 'error') { + this.activeFailureStreakByProvider[provider] = Math.min( + this.activeFailureStreakByProvider[provider] + 1, + MAX_ACTIVE_FAILURE_STREAK + ) + return + } + if (fresh.status === 'ok' || fresh.status === 'unavailable') { + this.activeFailureStreakByProvider[provider] = 0 + } + } + + protected withFetchingStatus( + current: ProviderRateLimits | null, + provider: + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' + ): ProviderRateLimits { + if (!current) { + return { + provider, + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. + if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { + return current + } + return { ...current, status: 'fetching' } + } +} diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts new file mode 100644 index 00000000000..c7e9aa4751a --- /dev/null +++ b/src/main/rate-limits/service/service-state.ts @@ -0,0 +1,165 @@ +import type { BrowserWindow } from 'electron' +import type { + InactiveAccountUsage, + ProviderRateLimits, + RateLimitState +} from '../../../shared/rate-limit-types' +import { + type ActiveRateLimitProvider, + type InactiveCodexAccountInfo, + type InternalRateLimitState, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type InactiveClaudeAccountInfo, + type NetworkProxySettings, + DEFAULT_POLL_MS +} from './service-types' +import { readGrokAuthSession } from '../grok-auth' + +export abstract class RateLimitServiceState { + protected state: InternalRateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null + } + protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected pollInterval: number = DEFAULT_POLL_MS + protected timer: ReturnType | null = null + protected deferredStartupRefreshTimer: ReturnType | null = null + // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. + protected lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. + protected activeFailureStreakByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + protected mainWindow: BrowserWindow | null = null + protected detachWindowListeners: (() => void) | null = null + protected isFetching = false + protected fullFetchQueued = false + protected codexOnlyFetchQueued = false + protected claudeOnlyFetchQueued = false + protected grokOnlyFetchQueued = false + protected activeFetchAbortControllers = new Set() + protected fetchIdleResolvers: (() => void)[] = [] + protected codexFetchGeneration = 0 + protected claudeFetchGeneration = 0 + // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. + protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null + protected opencodeFetchGeneration = 0 + protected minimaxFetchGeneration = 0 + protected lastOpencodeConfigHash = '' + protected lastMiniMaxConfigHash = '' + protected codexHomePathResolver: CodexHomePathResolver | null = null + protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + // Why: resolved per cycle — the local-account runtime policy can flip between fetches. + protected kimiHomeResolver: KimiHomeResolver | null = null + protected claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + protected claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null + protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null + protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null + protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null + protected inactiveClaudeCache = new Map() + protected inactiveCodexCache = new Map() + protected inactiveClaudeFetching = new Set() + protected inactiveCodexFetching = new Set() + protected inactiveCodexFetchInFlight = false + protected lastInactiveClaudeFetchAt = 0 + protected inactiveClaudeAccountsGeneration = 0 + protected lastInactiveCodexFetchAt = 0 + protected inactiveCodexAccountsGeneration = 0 + protected stateListeners = new Set<(state: RateLimitState) => void>() + + constructor() {} + + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + + protected abstract getState(): RateLimitState + + protected buildInactiveArray( + cache: Map, + fetching: Set + ): InactiveAccountUsage[] { + const result: InactiveAccountUsage[] = [] + for (const [accountId, limits] of cache) { + result.push({ + accountId, + rateLimits: limits, + updatedAt: limits.updatedAt, + isFetching: fetching.has(accountId) + }) + } + // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. + for (const accountId of fetching) { + if (!cache.has(accountId)) { + result.push({ + accountId, + rateLimits: null, + updatedAt: 0, + isFetching: true + }) + } + } + return result + } + + protected updateState(next: InternalRateLimitState): void { + this.state = next + this.pushToRenderer() + } + + protected pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return + } + this.mainWindow.webContents.send('rateLimits:update', state) + } +} diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts new file mode 100644 index 00000000000..0b38bd39433 --- /dev/null +++ b/src/main/rate-limits/service/service-types.ts @@ -0,0 +1,163 @@ +import type { ProviderRateLimits } from '../../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home' +import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +import type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type { + CodexRateLimitResetResult, + RateLimitState, + ProviderRateLimits, + InactiveAccountUsage, + RateLimitRuntimeTarget +} from '../../../shared/rate-limit-types' +export type { InactiveClaudeAccountInfo } from '../claude-fetcher' +export type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +export type { NetworkProxySettings } from '../../../shared/network-proxy' +export type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +export type { + ClaudeAccountSelectionTarget, + NormalizedClaudeAccountSelectionTarget +} from '../../claude-accounts/runtime-selection' +export { normalizeClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +export type { + CodexAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget +} from '../../codex-accounts/runtime-selection' +export { normalizeCodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +export type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type InactiveCodexAccountInfo = { + id: string + resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } +} + +export type CodexHomePathResolver = ( + target?: CodexAccountSelectionTarget +) => CodexRateLimitHomeResolution +export type KimiHomeResolver = () => Promise +export type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +export type OpenCodeGoRateLimitConfig = { + sessionCookie: string + workspaceIdOverride: string +} + +export type MiniMaxRateLimitConfig = { + sessionCookie: string + groupId: string + models: string +} + +export type MiniMaxResolvedConfig = { + config: MiniMaxRateLimitConfig + error: string | null +} + +export type GeminiCliOAuthEnabledResolver = () => boolean +export type ActiveRateLimitProvider = ProviderRateLimits['provider'] +export type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +export type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } + +// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. +export const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +export const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. +export const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. +export const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +export const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. +export const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS +export const MAX_ACTIVE_FAILURE_STREAK = 8 +// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. +export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) +export const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped +// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". +export const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 +// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. +export const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 +export const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open +// Why: each inactive Codex probe spawns a real codex process inside that +// account's live credential home; pace them out instead of bursting every +// account the moment the switcher opens. +export const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 +export const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 + +// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). +export type InternalRateLimitState = { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null + kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null + minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null +} + +export function normalizePollingInterval(ms: number): number { + if (!Number.isFinite(ms)) { + return DEFAULT_POLL_MS + } + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) +} + +export function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { + // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. + const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +export function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +export function isSameUsageWindow( + a: ProviderRateLimits['session'], + b: ProviderRateLimits['session'] +): boolean { + if (!a || !b) { + return a === b + } + return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt +} diff --git a/src/main/runtime/rpc/methods/orchestration-ask-methods.ts b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts new file mode 100644 index 00000000000..fb5194df87d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts @@ -0,0 +1,168 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import { isGroupAddress } from '../../orchestration/groups' +import { AskParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' +import { askRemoteRunHome } from './orchestration-ask-remote' + +export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.ask', + params: AskParams, + handler: async ( + params, + { runtime, signal, orchestrationCapability, recordMutationReceipt } + ) => { + // Why: group addresses have no unambiguous first-answer authority. + if (params.to && isGroupAddress(params.to)) { + throw new Error( + 'ask does not support group addresses; use send for non-blocking fan-out questions' + ) + } + + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. + const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) + const paneKey = runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if (remoteAttachment) { + rejectFederatedExplicitTarget(params) + return askRemoteRunHome({ + params: { ...params, timeoutMs }, + runtime, + signal, + orchestrationCapability, + recordMutationReceipt, + from, + paneKey: paneKey as string, + dispatchId: remoteAttachment.dispatch_id, + taskId: remoteAttachment.task_id + }) + } + const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) + if (!activeDispatch) { + throw new OrchestrationError( + 'dispatch_inactive', + 'ask requires an active supervised Dispatch.' + ) + } + if (activeDispatch.capability_hash) { + const authority = db.verifyDispatchCapability({ + dispatchId: activeDispatch.id, + capability: orchestrationCapability, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined + }) + if (!authority.valid) { + throw new OrchestrationError('dispatch_capability_invalid', authority.reason) + } + } + const options = + params.options + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean) ?? [] + let question = params.resume ? db.getQuestion(params.resume) : undefined + if (params.resume) { + if (!question || question.dispatch_id !== activeDispatch.id) { + throw new OrchestrationError( + 'question_not_found', + `Question ${params.resume} does not belong to this active Dispatch.` + ) + } + } else { + const run = db.getRun(activeDispatch.run_id) + if (!run || run.legacy === 1) { + throw new OrchestrationError( + 'run_not_found', + `Run ${activeDispatch.run_id} was not found.` + ) + } + if (params.run && params.run !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` + ) + } + if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` + ) + } + const created = db.createQuestion({ + runId: run.id, + dispatchId: activeDispatch.id, + askerHandle: from, + question: params.question as string, + options + }) + question = created.question + runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) + } + + const questionId = question.message_id + recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const current = db.getQuestion(questionId) + if (!current || current.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its Dispatch is inactive.` + ) + } + if (current.status === 'answered') { + return { + answer: current.answer_body, + messageId: questionId, + answerMessageId: current.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { + timeoutMs: remainingMs, + signal + }) + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-ask-remote.ts b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts new file mode 100644 index 00000000000..4b76e626e87 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts @@ -0,0 +1,129 @@ +import type { z } from 'zod' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import type { AskParams } from './orchestration-schemas' + +export async function askRemoteRunHome(args: { + params: z.infer + runtime: OrcaRuntimeService + signal?: AbortSignal + orchestrationCapability?: string + recordMutationReceipt?: (receipt: unknown) => void + from: string + paneKey: string + dispatchId: string + taskId: string +}): Promise { + const db = args.runtime.getOrchestrationDb() + const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + capability: args.orchestrationCapability, + paneKey: args.paneKey, + processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + const options = + args.params.options + ?.split(',') + .map((option) => option.trim()) + .filter(Boolean) ?? [] + let questionId = args.params.resume + if (questionId) { + const existing = db.getRemoteQuestion(questionId) + if (!existing || existing.dispatch_id !== args.dispatchId) { + throw new OrchestrationError( + 'question_not_found', + `Question ${questionId} does not belong to this remote Dispatch.` + ) + } + } else { + const relay = db.enqueueFederationRelay({ + dispatchId: args.dispatchId, + direction: 'to_home', + kind: 'question', + payload: JSON.stringify({ + from: args.from, + subject: 'Question', + body: args.params.question as string, + type: 'question', + priority: 'normal', + threadId: null, + payload: JSON.stringify({ + taskId: args.taskId, + dispatchId: args.dispatchId, + question: args.params.question, + options + }) + }), + remoteQuestion: true + }) + questionId = relay.message_id + } + args.recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const question = db.getRemoteQuestion(questionId) + if (!question || question.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its remote Dispatch is inactive.` + ) + } + if (question.status === 'answered') { + return { + answer: question.answer_body, + messageId: questionId, + answerMessageId: question.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (args.signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { + timeoutMs: remainingMs, + signal: args.signal + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-direct.ts b/src/main/runtime/rpc/methods/orchestration-check-direct.ts new file mode 100644 index 00000000000..1ac5ddeb3f3 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-direct.ts @@ -0,0 +1,79 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkDirectMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined +}): Promise { + const { params, runtime, db, handle, typeFilter, signal } = args + // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const consumeUnread = !showAll && params.peek !== true + const readAndReturn = () => { + const messages = showAll + ? db.getAllMessagesForHandle(handle, undefined, typeFilter) + : db.getUnreadMessages(handle, typeFilter) + if ( + consumeUnread && + messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', + { effectsApplied: false } + ) + } + let visibleMessages = messages + if (consumeUnread && messages.length > 0) { + // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) + db.markAsRead(messages.map((message) => message.id)) + } + if (params.format || params.inject) { + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } + } + return { messages: visibleMessages, count: visibleMessages.length } + } + + if (signal?.aborted) { + return { messages: [], count: 0 } + } + const result = readAndReturn() + if (result.count > 0 || !params.wait) { + return result + } + // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). + const waitResult = await runtime.waitForMessage(handle, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + if (signal?.aborted) { + return { messages: [], count: 0 } + } + if (waitResult === 'cancelled') { + throw new OrchestrationError( + 'consumer_fenced', + 'This direct mailbox became owned by a Run while the check was waiting.' + ) + } + return readAndReturn() +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-methods.ts b/src/main/runtime/rpc/methods/orchestration-check-methods.ts new file mode 100644 index 00000000000..d07be04d129 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-methods.ts @@ -0,0 +1,79 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { CheckParams } from './orchestration-schemas' +import { parseMessageTypes } from './orchestration-routing' +import { checkRunMailbox } from './orchestration-check-run' +import { checkWorkerMailbox } from './orchestration-check-worker' +import { checkDirectMailbox } from './orchestration-check-direct' + +export const ORCHESTRATION_CHECK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.check', + params: CheckParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } + ) => { + const db = runtime.getOrchestrationDb() + const handle = params.terminal ?? 'unknown' + const typeFilter = parseMessageTypes(params.types) + + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (params.run || boundRun) { + return checkRunMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + }) + } + + const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey) + const remoteAttachment = + !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if ( + remoteAttachment && + !db.isRemoteAttachmentProcessCurrent({ + dispatchId: remoteAttachment.dispatch_id, + paneKey: paneKey ?? null, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` + ) + } + if (activeDispatch || remoteAttachment) { + return checkWorkerMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + }) + } + return checkDirectMailbox({ params, runtime, db, handle, typeFilter, signal }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-check-run.ts b/src/main/runtime/rpc/methods/orchestration-check-run.ts new file mode 100644 index 00000000000..140b58d1a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-run.ts @@ -0,0 +1,251 @@ +import type { MessageRow, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { interruptedAcknowledgedCheck } from './orchestration-routing' +import { routeAllMailboxPages } from './orchestration-schemas' +import { resolveRunScope } from './orchestration-run-scope' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkRunMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + orchestrationCompatibilityEvidence: RpcContext['orchestrationCompatibilityEvidence'] + recordMutationReceipt: ((receipt: unknown) => void) | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + } = args + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const generation = run.consumer_generation + const address = `run:${run.id}` + runtime.ensureOrchestrationFederationRelay(run.id) + await routeDirectSnapshot(run.id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) + ) + const coordinatorHandle = run.coordinator_handle + if (coordinatorHandle && coordinatorHandle !== handle) { + await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) + ) + } + revalidateLegacyCoordinator?.() + const currentRun = resolveRunScope(runtime, { + runId: run.id, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (currentRun.consumer_generation !== generation) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while routing pending mail.' + ) + } + + const acknowledged = params.ack + ? db.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: generation, + deliveryId: params.ack + }) + : undefined + if (acknowledged) { + recordMutationReceipt?.( + interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') + ) + } + if (params.all || (params.unread === false && !params.peek)) { + const messages = db.getRunMailboxHistory(run.id, 100, typeFilter) + const result = { + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null + } + if (params.format || params.inject) { + return { + ...result, + formatted: messages.map(formatMessageBanner).join('\n\n'), + runId: run.id + } + } + return { ...result, runId: run.id } + } + + const peekResult = (messages: MessageRow[]) => ({ + runId: run.id, + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + }) + const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) + const readDelivery = (wakeTypes?: MessageType[]) => + db.getOrCreateRunDelivery({ runId: run.id, consumerGeneration: generation, wakeTypes }) + let peeked = params.peek ? readPeek() : [] + if (params.peek && peeked.length > 0) { + return peekResult(peeked) + } + let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) + if (current) { + return { + runId: run.id, + deliveryId: current.delivery.id, + messages: current.messages, + count: current.messages.length, + replayed: current.replayed, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format || params.inject + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + if (!params.wait) { + if (params.peek) { + return peekResult([]) + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false + } + } + + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal, + exclusive: true + }) + try { + revalidateLegacyCoordinator?.() + } catch (error) { + if (!acknowledged) { + throw error + } + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + const latestRun = db.getRun(run.id) + if (!latestRun || latestRun.consumer_generation !== generation) { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while waiting.' + ) + } + if (waitResult === 'waiter_exists') { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') + } + throw new OrchestrationError( + 'waiter_exists', + `Run ${run.id} already has an active actionable waiter.` + ) + } + if (waitResult === 'timed_out') { + if (params.peek) { + return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: true, + cancelled: false, + connectionLost: false + } + } + if (waitResult === 'cancelled') { + if (params.peek) { + return { + ...peekResult([]), + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + if (params.peek) { + peeked = readPeek() + return { ...peekResult(peeked), timedOut: false, cancelled: false, connectionLost: false } + } + current = readDelivery(typeFilter) + return { + runId: run.id, + deliveryId: current?.delivery.id ?? null, + messages: current?.messages ?? [], + count: current?.messages.length ?? 0, + replayed: current?.replayed ?? false, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format && current + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-worker.ts b/src/main/runtime/rpc/methods/orchestration-check-worker.ts new file mode 100644 index 00000000000..3d5e68dec75 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-worker.ts @@ -0,0 +1,192 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { routeAllMailboxPages } from './orchestration-schemas' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer +type ActiveDispatch = NonNullable> +type RemoteAttachment = NonNullable< + ReturnType +> + +export async function checkWorkerMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + activeDispatch: ActiveDispatch | undefined + remoteAttachment: RemoteAttachment | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + } = args + const workerMailbox = activeDispatch + ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } + : remoteAttachment + ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } + : undefined + if (!workerMailbox) { + return undefined + } + const address = `dispatch:${workerMailbox.dispatchId}` + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const revalidateWorkerMailbox = async (): Promise => { + if (activeDispatch) { + const current = db.getActiveDispatchForIdentity(handle, paneKey) + if (current?.id === activeDispatch.id) { + return + } + } else if (remoteAttachment && paneKey) { + const current = db.findActiveRemoteAttachmentForPane(paneKey) + if ( + current?.dispatch_id === remoteAttachment.dispatch_id && + db.isRemoteAttachmentProcessCurrent({ + dispatchId: current.dispatch_id, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + return + } + } + const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) + const owningRunId = latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId + if ( + owningRunId && + (!latestDispatch || + (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) + ) { + const throughSequence = db.getLatestUnreadMessageSequence(address) + if (throughSequence !== undefined) { + const routedTypes = new Set() + const routePage = (): { routedCount: number; hasMore: boolean } => { + const routed = db.routeUnreadDispatchMailboxToRunMailbox( + workerMailbox.dispatchId, + owningRunId, + throughSequence + ) + for (const routedType of routed.types) { + routedTypes.add(routedType) + } + return routed + } + const notifyRoutedTypes = (): void => { + for (const routedType of routedTypes) { + runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) + } + routedTypes.clear() + } + try { + await routeAllMailboxPages(routePage, signal) + } catch (error) { + notifyRoutedTypes() + if (error instanceof OrchestrationError && error.code === 'request_aborted') { + setImmediate(() => { + void routeAllMailboxPages(routePage) + .catch(() => undefined) + .finally(notifyRoutedTypes) + }) + } + throw error + } + notifyRoutedTypes() + } + } + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` + ) + } + + if (activeDispatch) { + await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + handle, + throughSequence + ) + ) + const assigneeHandle = activeDispatch.assignee_handle + if (assigneeHandle && assigneeHandle !== handle) { + await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + assigneeHandle, + throughSequence + ) + ) + } + } + await revalidateWorkerMailbox() + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const messages = showAll + ? db.getAllMessagesForHandle(address, 100, typeFilter) + : db.getUnreadMessages(address, typeFilter) + if (!showAll && params.peek !== true && messages.length > 0) { + db.markAsRead(messages.map((message) => message.id)) + } + if (messages.length > 0 || !params.wait) { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages, + count: messages.length, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + await revalidateWorkerMailbox() + if (waitResult === 'timed_out' || waitResult === 'cancelled') { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: [], + count: 0, + timedOut: waitResult === 'timed_out', + cancelled: waitResult === 'cancelled', + connectionLost: waitResult === 'cancelled' && signal?.aborted === true + } + } + const arrived = db.getUnreadMessages(address, typeFilter) + db.markAsRead(arrived.map((message) => message.id)) + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: arrived, + count: arrived.length, + ...(params.format || params.inject + ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts new file mode 100644 index 00000000000..ae21a4e5a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { resolveRunScope } from './orchestration-run-scope' +import { DispatchParams, DispatchShowParams } from './orchestration-schemas' + +export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.dispatch', + params: DispatchParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + legacyCoordinatorRunId, + revalidateLegacyCoordinator + } + ) => { + const db = runtime.getOrchestrationDb() + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (task.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${task.id} was not found in Run ${run.id}.` + ) + } + + // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. + if (params.dryRun) { + const maxDepth = runtime.getNestedWorkerMaxDepth() + const previewDepth = db.resolveChildDispatchDepth( + resolveDispatchCreator(runtime, params.from), + maxDepth + ) + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: 'ctx_dryrun', + canDispatchSubWorkers: previewDepth < maxDepth, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: params.to ?? 'worker', + devMode: params.devMode, + ...(params.to + ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } + : {}) + }) + return { dispatch: null, injected: false, dryRun: true, preamble } + } + + if (!params.to) { + throw new Error('Missing --to') + } + const to = params.to + + if (task.status !== 'ready') { + throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + } + + // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(to) + if (!hasAgent) { + throw new Error(buildInjectRejectionMessage(to)) + } + } + + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) + const assigneePaneKey = + dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined + const processIncarnation = + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined + if (params.inject && (!assigneePaneKey || !processIncarnation)) { + throw new OrchestrationError( + 'stable_pane_required', + `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` + ) + } + + revalidateLegacyCoordinator?.() + const ctx = db.createDispatchContext({ + taskId: params.task, + assigneeHandle: to, + assigneePaneKey, + launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation, + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth() + }) + const dispatchCapability = params.inject + ? db.mintDispatchCapability({ + dispatchId: ctx.id, + paneKey: assigneePaneKey as string, + processIncarnation: processIncarnation as string + }) + : undefined + + // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: ctx.id, + canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: to, + dispatchCapability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(to) + }) + + let injected = false + if (params.inject) { + try { + await runtime.sendTerminalAgentPrompt(to, preamble) + injected = true + } catch (err) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + throw err + } + } + + // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. + if (params.returnPreamble) { + return { dispatch: ctx, injected, preamble } + } + return { dispatch: ctx, injected } + } + }), + + defineMethod({ + name: 'orchestration.dispatchShow', + params: DispatchShowParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!params.task) { + throw new Error('Missing --task') + } + const ctx = db.getDispatchContext(params.task) + + // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. + if (params.preamble) { + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const workerHandle = ctx?.assignee_handle ?? 'worker' + const preamble = buildDispatchPreamble({ + taskId: task.id, + // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. + dispatchId: ctx?.id ?? 'ctx_preview', + canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle, + devMode: params.devMode, + ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) + }) + return { dispatch: ctx ?? null, preamble } + } + + return { dispatch: ctx ?? null } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-message-methods.ts b/src/main/runtime/rpc/methods/orchestration-message-methods.ts new file mode 100644 index 00000000000..faf89669247 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-message-methods.ts @@ -0,0 +1,219 @@ +import { defineMethod, type RpcMethod } from '../core' +import type { TaskStatus } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' +import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' +import { resolveRunScope } from './orchestration-run-scope' +import { + ReplyParams, + InboxParams, + TaskCreateParams, + TaskListParams, + TaskUpdateParams +} from './orchestration-schemas' + +export const ORCHESTRATION_MESSAGE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reply', + params: ReplyParams, + handler: async ( + params, + { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } + ) => { + const db = runtime.getOrchestrationDb() + const original = db.getMessageById(params.id) + if (!original) { + throw new Error(`Message not found: ${params.id}`) + } + if ( + legacyCoordinatorRunId && + (original.run_id !== legacyCoordinatorRunId || + (params.run !== undefined && params.run !== legacyCoordinatorRunId)) + ) { + throw new OrchestrationError( + 'request_mismatch', + `Message ${params.id} does not belong to this adopted Run.`, + { effectsApplied: false } + ) + } + if ( + original.run_id === ORCHESTRATION_LEGACY_RUN_ID || + original.delivery_contract === 'legacy_direct' || + original.delivery_contract === 'audit_only' + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; no reply was applied.', + { effectsApplied: false } + ) + } + + const question = db.getQuestion(params.id) + if (question) { + const run = resolveRunScope(runtime, { + runId: params.run ?? question.run_id, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const answered = db.answerQuestion({ + messageId: question.message_id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: params.body + }) + const federated = db.getFederatedDispatch(question.dispatch_id) + if (federated) { + db.enqueueFederationRelay({ + dispatchId: question.dispatch_id, + direction: 'to_worker', + kind: 'reply', + payload: JSON.stringify({ + questionId: question.message_id, + answerMessageId: answered.message.id, + body: params.body + }) + }) + runtime.ensureOrchestrationFederationRelay(run.id) + } else { + runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') + } + return { + message: answered.message, + question: answered.question, + duplicate: answered.duplicate + } + } + + db.markAsRead([original.id]) + + const reply = db.insertMessage({ + from: params.from ?? original.to_handle, + to: original.from_handle, + subject: `Re: ${original.subject}`, + body: params.body, + threadId: original.thread_id ?? original.id, + runId: original.run_id + }) + + runtime.notifyMessageArrived(reply.to_handle, reply.type) + return { message: reply } + } + }), + + defineMethod({ + name: 'orchestration.inbox', + params: InboxParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). + const messages = params.terminal + ? db.getAllMessagesForHandle(params.terminal, params.limit) + : db.getInbox(params.limit) + return { messages, count: messages.length } + } + }), + + defineMethod({ + name: 'orchestration.taskCreate', + params: TaskCreateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const creatorAuthority = params.callerTerminalHandle + ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + : null + const task = db.createTask({ + spec: params.spec, + taskTitle: params.taskTitle, + displayName: params.displayName, + deps, + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle, + ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation + ? { + createdByPaneKey: creatorAuthority.paneKey, + createdByProcessIncarnation: creatorAuthority.processIncarnation, + createdByRunGeneration: run.consumer_generation + } + : {}), + runId: run.id + }) + return { task } + } + }), + + defineMethod({ + name: 'orchestration.taskList', + params: TaskListParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const explicitRun = params.run ? db.getRun(params.run) : undefined + const run = + explicitRun?.legacy === 1 + ? explicitRun + : resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: params.run === undefined, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. + const joined = db.listTasksWithDispatch({ + status: params.status as TaskStatus, + ready: params.ready, + runId: run.id + }) + const tasks = joined.map((row) => { + const { assignee_handle, dispatch_id, ...base } = row + if (base.status === 'dispatched') { + return { ...base, assignee_handle, dispatch_id } + } + return base + }) + return { + runId: run.id, + legacyReadOnly: run.legacy === 1, + tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, + count: tasks.length + } + } + }), + + defineMethod({ + name: 'orchestration.taskUpdate', + params: TaskUpdateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const existing = db.getTask(params.id) + if (!existing || existing.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${params.id} was not found in Run ${run.id}.` + ) + } + const task = db.updateTaskStatus(params.id, params.status, params.result) + if (!task) { + throw new Error(`Task not found: ${params.id}`) + } + return { task } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-reset-methods.ts b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts new file mode 100644 index 00000000000..d98fc4719b9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts @@ -0,0 +1,27 @@ +import { defineMethod, type RpcMethod } from '../core' +import { ResetParams } from './orchestration-schemas' + +export const ORCHESTRATION_RESET_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reset', + params: ResetParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (params.all) { + runtime.stopOrchestrationFederationRelay() + db.resetAll() + return { reset: 'all' } + } + if (params.tasks) { + runtime.stopOrchestrationFederationRelay() + db.resetTasks() + return { reset: 'tasks' } + } + if (params.messages) { + db.resetMessages() + return { reset: 'messages' } + } + throw new Error('Invalid reset scope') + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-routing.ts b/src/main/runtime/rpc/methods/orchestration-routing.ts new file mode 100644 index 00000000000..0722f19b44e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-routing.ts @@ -0,0 +1,135 @@ +import type { MessageType } from '../../orchestration/db' +import type { RunRow } from '../../orchestration/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { LEGACY_CONTRACT_VERSION } from '../../orchestration/db' + +export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { + const types = rawTypes + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) as MessageType[] | undefined + const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) + if (invalidTypes && invalidTypes.length > 0) { + throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) + } + return types && types.length > 0 ? types : undefined +} + +export function resolveMessageRun( + runtime: OrcaRuntimeService, + params: { + from?: string + senderPaneKey?: string + to?: string + runId?: string + payload?: string + } +): { run: RunRow | undefined; dispatchId: string | undefined } { + const db = runtime.getOrchestrationDb() + let dispatchId: string | undefined + if (params.payload) { + try { + const payload: unknown = JSON.parse(params.payload) + if ( + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' + ) { + dispatchId = (payload as { dispatchId: string }).dispatchId + } + } catch { + // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. + } + } + if (!dispatchId && params.to?.startsWith('dispatch:')) { + dispatchId = params.to.slice('dispatch:'.length) + } + + const dispatch = dispatchId + ? db.getDispatchContextById(dispatchId) + : params.from + ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) + : undefined + if (params.to?.startsWith('dispatch:') && !dispatch) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${dispatchId ?? ''} was not found.` + ) + } + const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined + const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id + let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined + + if (!run && params.from) { + const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) + run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + } + if (resolvedRunId && (!run || run.legacy === 1)) { + throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) + } + if (run && targetRunId && targetRunId !== run.id) { + throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) + } + if (run && dispatch && dispatch.run_id !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` + ) + } + return { run, dispatchId: dispatch?.id ?? dispatchId } +} + +export function legacyWorkerDeliveryContract( + runtime: OrcaRuntimeService, + runId: string | undefined, + recipient: string +): 'legacy_direct' | undefined { + if (!runId) { + return undefined + } + if (!recipient.startsWith('dispatch:')) { + return runtime + .getOrchestrationDb() + .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) + ? 'legacy_direct' + : undefined + } + const dispatch = runtime + .getOrchestrationDb() + .getDispatchContextById(recipient.slice('dispatch:'.length)) + return dispatch?.run_id === runId && + dispatch.contract_version === LEGACY_CONTRACT_VERSION && + (dispatch.status === 'pending' || dispatch.status === 'dispatched') + ? 'legacy_direct' + : undefined +} + +export function interruptedAcknowledgedCheck( + runId: string, + acknowledged: string, + reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' +): Record { + return { + runId, + deliveryId: null, + messages: [], + count: 0, + acknowledged, + timedOut: false, + cancelled: false, + connectionLost: false, + waitInterrupted: reason + } +} + +export function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { + if (params.to || params.run) { + throw new OrchestrationError( + 'invalid_argument', + 'Federated Dispatch messages route to their Run home; omit --to and --run.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-schemas.ts b/src/main/runtime/rpc/methods/orchestration-schemas.ts new file mode 100644 index 00000000000..d1023827fee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-schemas.ts @@ -0,0 +1,272 @@ +import { z } from 'zod' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' +import type { TaskStatus } from '../../orchestration/db' +import { isGroupAddress } from '../../orchestration/groups' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export const TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] + +export async function routeAllMailboxPages( + routePage: () => { routedCount: number; hasMore: boolean }, + signal?: AbortSignal +): Promise { + while (true) { + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + const page = routePage() + if (!page.hasMore) { + return + } + await yieldToEventLoop() + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + } +} + +const SEND_MESSAGE_TYPE_ERROR = [ + `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, + 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' +].join(' ') + +export type DispatchMutationMessageType = + | 'worker_done' + | 'heartbeat' + | 'escalation' + | 'decision_gate' + +export function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +export function parseRemoteWorkerPayload(payload: string | undefined): Record { + if (!payload) { + return {} + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') + } +} + +export function parseMessageTaskId(payload: string | undefined): string | undefined { + if (!payload) { + return undefined + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? typeof (parsed as { taskId?: unknown }).taskId === 'string' + ? (parsed as { taskId: string }).taskId + : undefined + : undefined + } catch { + return undefined + } +} + +export function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { + return value === 'succeeded' || value === 'failed' +} + +export const SendParams = z + .object({ + to: OptionalString, + subject: requiredString('Missing --subject'), + from: OptionalString, + body: OptionalString, + type: z + .enum(MESSAGE_TYPES, { + error: SEND_MESSAGE_TYPE_ERROR + }) + .optional(), + priority: z.enum(['normal', 'high', 'urgent']).optional(), + threadId: OptionalString, + payload: OptionalString, + // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. + senderPaneKey: OptionalString, + run: OptionalString, + waitForLifecycleSettlement: OptionalBoolean, + devMode: OptionalBoolean + }) + .superRefine((params, ctx) => { + if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { + return + } + // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: getLifecycleGroupRecipientError(params.type), + path: ['to'] + }) + }) + +export const CheckParams = z + .object({ + terminal: OptionalString, + terminalPaneKey: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). + all: OptionalBoolean, + types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. + inject: OptionalBoolean, + ack: OptionalString, + compatibilityAck: OptionalString, + compatibilityQuestionAck: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + run: OptionalString, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) + +export const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString, + run: OptionalString +}) + +export const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). + terminal: OptionalString +}) + +export const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + taskTitle: OptionalString, + displayName: OptionalString, + deps: OptionalString, + parent: OptionalString, + callerTerminalHandle: OptionalString, + run: OptionalString +}) + +export const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean, + // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const TaskUpdateParams = z.object({ + id: requiredString('Missing --id'), + status: z + .unknown() + .transform((v) => { + if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { + return v as TaskStatus + } + return '' + }) + .pipe( + z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { + message: 'Missing --status' + }) + ), + result: OptionalString, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean, + run: OptionalString +}) + +export const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +export const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) + +export const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) diff --git a/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts new file mode 100644 index 00000000000..40f8abf08ee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts @@ -0,0 +1,83 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' +import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { SendParams } from './orchestration-schemas' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +/** Delivers coordinator control mail to a federated worker when `to` names its exact Dispatch. */ +export function sendFederatedControlMail(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + messageRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + } = args + const dispatchId = to.startsWith('dispatch:') ? to.slice('dispatch:'.length) : undefined + const federatedTarget = + dispatchId && to === `dispatch:${dispatchId}` ? db.getFederatedDispatch(dispatchId) : undefined + if (!federatedTarget || !dispatchId) { + return undefined + } + if (federatedTarget.protocol_version < ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION) { + throw new OrchestrationError( + 'capability_unsupported', + `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` + ) + } + if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Federated Dispatch ${dispatchId} is not active.` + ) + } + if (params.type === 'worker_done' || params.type === 'heartbeat') { + throw new OrchestrationError( + 'invalid_argument', + 'Coordinator-to-worker control mail cannot report worker lifecycle.' + ) + } + revalidateLegacyCoordinator?.() + const relay = db.enqueueFederationRelay({ + dispatchId, + direction: 'to_worker', + kind: 'control_message', + payload: encodeFederatedControlMessage({ + from, + subject: params.subject, + body: params.body ?? '', + type: (params.type ?? 'status') as MessageType, + priority: (params.priority ?? 'normal') as MessagePriority, + threadId: params.threadId ?? null, + payload: params.payload ?? null + }) + }) + runtime.ensureOrchestrationFederationRelay(messageRunId) + return withSendWarnings({ + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'worker', + accepted: true + } + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-group.ts b/src/main/runtime/rpc/methods/orchestration-send-group.ts new file mode 100644 index 00000000000..aa3c8d47788 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-group.ts @@ -0,0 +1,131 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { resolveGroupAddress } from '../../orchestration/groups' +import { resolveBareOrchestrationRecipient } from './orchestration-recipient-routing' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { SendParams } from './orchestration-schemas' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export async function sendGroupMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + groupAddress: string + senderPaneKey: string | undefined + senderRunId: string | undefined + explicitRunId: string | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + recordMutationReceipt: ((receipt: unknown) => void) | undefined + withSendWarnings: SendReceipt +}): Promise { + const { + params, + runtime, + db, + from, + groupAddress, + senderPaneKey, + senderRunId, + explicitRunId, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } = args + // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). + const { terminals } = await runtime.listTerminals(undefined, undefined, { + includeVisualLayouts: false + }) + const handles = resolveGroupAddress(groupAddress, from, terminals, (handle: string) => + runtime.getAgentStatusForHandle(handle) + ) + if (handles.length === 0) { + throw new Error(`No recipients resolved for group address: ${groupAddress}`) + } + + const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() + const resolvedRecipients = handles.map((handle) => ({ + handle, + resolution: resolveBareOrchestrationRecipient({ + runtime, + db, + handle, + senderRunId, + explicitRunId, + legacyAdoptedMailboxOwner + }) + })) + const deliverableRecipients = resolvedRecipients.filter( + ( + recipient + ): recipient is typeof recipient & { + resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } + } => recipient.resolution.ok + ) + const senderRecipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: from, + senderRunId, + legacyAdoptedMailboxOwner + }) + const senderMailboxKey = senderRecipient.ok + ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` + : undefined + const seenMailboxes = new Set() + const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { + const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` + if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { + return false + } + seenMailboxes.add(mailboxKey) + return true + }) + if (uniqueRecipients.length === 0) { + throw new OrchestrationError( + 'terminal_not_found', + `No recipient of ${groupAddress} resolved to a live terminal or durable Run/Dispatch mailbox.` + ) + } + + revalidateLegacyCoordinator?.() + const threadId = params.threadId ?? `thread_${Date.now()}` + const messages = db.insertMessages( + uniqueRecipients.map(({ resolution }) => ({ + from, + to: resolution.to, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId, + payload: params.payload, + senderPaneKey, + runId: resolution.runId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + resolution.runId ?? legacyCoordinatorRunId, + resolution.to + ) + })) + ) + const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => + resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + ) + const receipt = { + messages, + recipients: messages.length, + ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) + } + recordMutationReceipt?.(receipt) + for (const message of messages) { + runtime.notifyMessageArrived(message.to_handle, message.type) + } + return receipt +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-methods.ts b/src/main/runtime/rpc/methods/orchestration-send-methods.ts new file mode 100644 index 00000000000..d780d6a5a5e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-methods.ts @@ -0,0 +1,188 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { isGroupAddress } from '../../orchestration/groups' +import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' +import { + SendParams, + isWorkerReportOutcome, + parseRemoteWorkerPayload +} from './orchestration-schemas' +import { resolveMessageRun } from './orchestration-routing' +import { + assertDispatchMailboxDeliverable, + resolveBareOrchestrationRecipient, + type SendRecipientWarning +} from './orchestration-recipient-routing' +import { sendRemoteMessage } from './orchestration-send-remote' +import { sendPointToPointMessage } from './orchestration-send-point-to-point' +import { sendGroupMessage } from './orchestration-send-group' +import { sendFederatedControlMail } from './orchestration-send-control-mail' + +export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.send', + params: SendParams, + handler: async ( + params, + { + runtime, + orchestrationCapability, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityCallerAuthority, + recordMutationReceipt, + signal + } + ) => { + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + const attestedCaller = + orchestrationCompatibilityCallerAuthority?.terminalHandle === from + ? orchestrationCompatibilityCallerAuthority + : undefined + // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. + const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = senderPaneKey + ? db.findActiveRemoteAttachmentForPane(senderPaneKey) + : undefined + if (remoteAttachment && senderPaneKey) { + return sendRemoteMessage({ + params, + runtime, + db, + from, + senderPaneKey, + remoteAttachment, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + orchestrationCapability, + signal + }) + } + + const routing = resolveMessageRun(runtime, { + from, + senderPaneKey, + to: params.to, + runId: params.run, + payload: params.payload + }) + if ( + params.type === 'worker_done' && + !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'worker_done requires outcome=succeeded|failed for a current Dispatch.' + ) + } + if (params.to?.startsWith('task:')) { + throw new OrchestrationError( + 'invalid_argument', + 'Task recipients are intentionally unsupported; use run: or dispatch:.' + ) + } + + let to = params.to + if ( + routing.run && + (!to || + ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) + ) { + to = `run:${routing.run.id}` + } + if (!to) { + throw new OrchestrationError( + 'run_required', + 'No recipient or active Dispatch Run could be resolved. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + + const sendWarnings: SendRecipientWarning[] = [] + let messageRunId = routing.run?.id + if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + const recipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: to, + senderRunId: routing.run?.id, + explicitRunId: params.run + }) + if (!recipient.ok) { + throw new OrchestrationError(recipient.code, recipient.message) + } + to = recipient.to + messageRunId = recipient.runId + if (recipient.warning) { + sendWarnings.push(recipient.warning) + } + } + const withSendWarnings = ( + receipt: T + ): T & { warnings?: SendRecipientWarning[] } => + sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt + + if (!isGroupAddress(to)) { + const addressedDispatchId = to.startsWith('dispatch:') + ? to.slice('dispatch:'.length) + : undefined + const federatedTarget = + addressedDispatchId && to === `dispatch:${addressedDispatchId}` + ? db.getFederatedDispatch(addressedDispatchId) + : undefined + // Federated targets perform their own liveness check before relaying. + if (addressedDispatchId && !federatedTarget) { + assertDispatchMailboxDeliverable(db, addressedDispatchId) + } + const federatedControl = sendFederatedControlMail({ + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + }) + if (federatedControl !== undefined) { + return federatedControl + } + return sendPointToPointMessage({ + params, + runtime, + db, + from, + to, + dispatchId: routing.dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + revalidateLegacyCoordinator, + withSendWarnings + }) + } + return sendGroupMessage({ + params, + runtime, + db, + from, + groupAddress: to, + senderPaneKey, + senderRunId: routing.run?.id, + explicitRunId: params.run, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt, + withSendWarnings + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts new file mode 100644 index 00000000000..3988dc90af8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts @@ -0,0 +1,187 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { isDispatchMutationMessageType, parseMessageTaskId } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export function sendPointToPointMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + dispatchId: string | undefined + messageRunId: string | undefined + senderPaneKey: string | undefined + legacyCoordinatorRunId: string | undefined + orchestrationCapability: string | undefined + processIncarnation: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + processIncarnation, + revalidateLegacyCoordinator, + withSendWarnings + } = args + // Point-to-point — existing single-recipient behavior + revalidateLegacyCoordinator?.() + const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) : undefined + const messageType = (params.type ?? 'status') as MessageType + const msg = db.insertMessage({ + from, + to, + subject: params.subject, + body: params.body, + type: messageType, + priority: params.priority as MessagePriority, + threadId: params.threadId, + payload: dispatch + ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) + : params.payload, + senderPaneKey, + runId: messageRunId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + messageRunId ?? legacyCoordinatorRunId, + to + ) + }) + if (isDispatchMutationMessageType(msg.type)) { + const taskId = parseMessageTaskId(params.payload) + const capabilityBacked = Boolean(dispatch?.capability_hash) + const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' + const authority = resolveLifecycleAuthority({ + db, + dispatch, + from, + paneKey: senderPaneKey, + processIncarnation, + capability: orchestrationCapability, + taskId, + capabilityBacked, + coordinatorMutation + }) + if (!authority.valid) { + const rejection = + db.convertLifecycleMessageToRejection(msg.id, authority.code, authority.reason) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ + message: rejection, + lifecycle: { action: 'rejected', code: authority.code, reason: authority.reason } + }) + } + } + + // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. + if (msg.type === 'worker_done' || msg.type === 'heartbeat') { + const reconciled = reconcileLifecycleMessage(db, msg) + // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. + if (reconciled.action === 'suppressed') { + return withSendWarnings({ message: msg }) + } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ message: rejection, lifecycle: reconciled }) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings( + msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } + ) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings({ message: msg }) +} + +type LifecycleAuthority = { + valid: boolean + code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' + reason: string +} + +function resolveLifecycleAuthority(args: { + db: OrchestrationDb + dispatch: ReturnType + from: string + paneKey: string | undefined + processIncarnation: string | undefined + capability: string | undefined + taskId: string | undefined + capabilityBacked: boolean + coordinatorMutation: boolean +}): LifecycleAuthority { + const { + db, + dispatch, + from, + paneKey, + processIncarnation, + capability, + taskId, + capabilityBacked, + coordinatorMutation + } = args + if (!dispatch) { + return { + valid: !coordinatorMutation, + code: 'sender_not_assignee', + reason: 'No active Dispatch belongs to this message sender.' + } + } + if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { + return { + valid: false, + code: 'task_dispatch_mismatch', + reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` + } + } + if (capabilityBacked) { + const authority = db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey, + processIncarnation + }) + return { + valid: authority.valid, + code: 'dispatch_capability_invalid', + reason: authority.valid ? '' : authority.reason + } + } + if (dispatch.process_incarnation) { + return { + valid: db.isDispatchProcessCurrent({ + dispatchId: dispatch.id, + paneKey: paneKey ?? null, + processIncarnation: processIncarnation ?? null + }), + code: 'sender_not_assignee', + reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + } + } + return { + valid: + !coordinatorMutation || + db.isDispatchMessageSender({ dispatchId: dispatch.id, handle: from, paneKey }), + code: 'sender_not_assignee', + reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-remote.ts b/src/main/runtime/rpc/methods/orchestration-send-remote.ts new file mode 100644 index 00000000000..9243b977d2e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-remote.ts @@ -0,0 +1,114 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { z } from 'zod' +import { parseRemoteWorkerPayload } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' + +type SendParamsInput = z.infer + +type RemoteAttachment = { + dispatch_id: string + protocol_version: number +} + +export async function sendRemoteMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + senderPaneKey: string + remoteAttachment: RemoteAttachment + processIncarnation?: string | null + orchestrationCapability?: string + signal?: AbortSignal +}): Promise { + const { params, runtime, db, from, senderPaneKey, remoteAttachment } = args + rejectFederatedExplicitTarget(params) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: remoteAttachment.dispatch_id, + capability: args.orchestrationCapability, + paneKey: senderPaneKey, + processIncarnation: args.processIncarnation ?? null + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + + const type = (params.type ?? 'status') as MessageType + const payload = parseRemoteWorkerPayload(params.payload) + if ( + typeof payload.dispatchId === 'string' && + payload.dispatchId !== remoteAttachment.dispatch_id + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` + ) + } + const outcome = + type === 'worker_done' && (payload.outcome === 'succeeded' || payload.outcome === 'failed') + ? payload.outcome + : undefined + if (type === 'worker_done' && !outcome) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker_done requires outcome=succeeded|failed.' + ) + } + + const supportsLifecycleSettlement = + remoteAttachment.protocol_version >= + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION + const relay = db.enqueueFederationRelay({ + dispatchId: remoteAttachment.dispatch_id, + direction: 'to_home', + kind: type, + payload: JSON.stringify({ + from, + subject: params.subject, + body: params.body ?? '', + type, + priority: params.priority ?? 'normal', + threadId: params.threadId ?? null, + payload: bindCoordinatorMutationPayload(type, params.payload, remoteAttachment.dispatch_id) + }), + ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) + }) + const lifecycle = + outcome && supportsLifecycleSettlement + ? await waitForFederatedLifecycleSettlement(runtime, relay.dispatch_id, relay.sequence, { + timeoutMs: 30_000, + signal: args.signal + }) + : outcome + ? { + action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), + authority: 'worker_server_legacy' as const + } + : undefined + if (outcome && supportsLifecycleSettlement && !lifecycle) { + throw new OrchestrationError( + 'operation_unknown', + 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' + ) + } + return { + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'run_home', + accepted: true + }, + ...(lifecycle ? { lifecycle } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 2d5e7b6a796..fab5feba812 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,2057 +1,26 @@ -/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */ -import { z } from 'zod' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { defineMethod, type RpcMethod } from '../core' -import { resolveDispatchCreator } from './orchestration-dispatch-creator' -import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' -import { - LEGACY_CONTRACT_VERSION, - type MessageRow, - type MessageType, - type MessagePriority, - type TaskStatus -} from '../../orchestration/db' -import { MESSAGE_TYPES } from '../../orchestration/types' -import { buildDispatchPreamble } from '../../orchestration/preamble' -import { formatMessageBanner } from '../../orchestration/formatter' -import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' -import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' -import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' -import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' -import { - ORCHESTRATION_LEGACY_RUN_ID, - orchestrationSkillRecoveryData -} from '../../../../shared/orchestration-rpc-contract' -import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' -import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' -import { - assertDispatchMailboxDeliverable, - resolveBareOrchestrationRecipient, - type SendRecipientWarning -} from './orchestration-recipient-routing' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' -import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' -import { resolveRunScope } from './orchestration-run-scope' +import type { RpcMethod } from '../core' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration-federation-methods' import { ORCHESTRATION_MUTATION_REQUEST_METHODS } from './orchestration-mutation-request-show' -import { OrchestrationError } from '../../orchestration/orchestration-error' -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RunRow } from '../../orchestration/types' -import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' -import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' -import { - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION -} from '../../../../shared/protocol-version' - -const TASK_STATUSES: TaskStatus[] = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] - -async function routeAllMailboxPages( - routePage: () => { routedCount: number; hasMore: boolean }, - signal?: AbortSignal -): Promise { - while (true) { - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - const page = routePage() - if (!page.hasMore) { - return - } - await yieldToEventLoop() - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - } -} - -type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' - -const SEND_MESSAGE_TYPE_ERROR = [ - `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, - 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' -].join(' ') - -function isDispatchMutationMessageType( - type: string | undefined -): type is DispatchMutationMessageType { - return ( - type === 'worker_done' || - type === 'heartbeat' || - type === 'escalation' || - type === 'decision_gate' - ) -} - -function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -function parseRemoteWorkerPayload(payload: string | undefined): Record { - if (!payload) { - return {} - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') - } -} - -function parseMessageTaskId(payload: string | undefined): string | undefined { - if (!payload) { - return undefined - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? typeof (parsed as { taskId?: unknown }).taskId === 'string' - ? (parsed as { taskId: string }).taskId - : undefined - : undefined - } catch { - return undefined - } -} - -function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { - return value === 'succeeded' || value === 'failed' -} - -const SendParams = z - .object({ - to: OptionalString, - subject: requiredString('Missing --subject'), - from: OptionalString, - body: OptionalString, - type: z - .enum(MESSAGE_TYPES, { - error: SEND_MESSAGE_TYPE_ERROR - }) - .optional(), - priority: z.enum(['normal', 'high', 'urgent']).optional(), - threadId: OptionalString, - payload: OptionalString, - // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. - senderPaneKey: OptionalString, - run: OptionalString, - waitForLifecycleSettlement: OptionalBoolean, - devMode: OptionalBoolean - }) - .superRefine((params, ctx) => { - if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { - return - } - // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: getLifecycleGroupRecipientError(params.type), - path: ['to'] - }) - }) - -const CheckParams = z - .object({ - terminal: OptionalString, - terminalPaneKey: OptionalString, - unread: OptionalBoolean, - peek: OptionalBoolean, - // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). - all: OptionalBoolean, - types: OptionalString, - format: OptionalBoolean, - // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. - inject: OptionalBoolean, - ack: OptionalString, - compatibilityAck: OptionalString, - compatibilityQuestionAck: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - run: OptionalString, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber - }) - .superRefine((params, ctx) => { - // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. - const modes = [ - params.unread === true, - params.peek === true, - params.all === true || (params.unread === false && params.peek !== true) - ].filter(Boolean) - if (modes.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose at most one message read mode: --unread, --peek, or --all.' - }) - } - }) - -const ReplyParams = z.object({ - id: requiredString('Missing --id'), - body: requiredString('Missing --body'), - from: OptionalString, - run: OptionalString -}) - -const InboxParams = z.object({ - limit: OptionalFiniteNumber, - // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). - terminal: OptionalString -}) - -const TaskCreateParams = z.object({ - spec: requiredString('Missing --spec'), - taskTitle: OptionalString, - displayName: OptionalString, - deps: OptionalString, - parent: OptionalString, - callerTerminalHandle: OptionalString, - run: OptionalString -}) - -const TaskListParams = z.object({ - status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean, - // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const TaskUpdateParams = z.object({ - id: requiredString('Missing --id'), - status: z - .unknown() - .transform((v) => { - if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { - return v as TaskStatus - } - return '' - }) - .pipe( - z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { - message: 'Missing --status' - }) - ), - result: OptionalString, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const DispatchParams = z.object({ - task: requiredString('Missing --task'), - // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. - to: OptionalString, - from: OptionalString, - inject: OptionalBoolean, - dryRun: OptionalBoolean, - returnPreamble: OptionalBoolean, - devMode: OptionalBoolean, - run: OptionalString -}) - -const DispatchShowParams = z.object({ - task: OptionalString, - preamble: OptionalBoolean, - from: OptionalString, - devMode: OptionalBoolean -}) - -const AskParams = z - .object({ - to: OptionalString, - question: OptionalString, - resume: OptionalString, - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString, - run: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() - }) - .superRefine((params, ctx) => { - if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one of --question or --resume.' - }) - } - }) - -const ResetParams = z - .object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean - }) - .superRefine((params, ctx) => { - const selectedScopeCount = [params.all, params.tasks, params.messages].filter( - (scope) => scope === true - ).length - if (selectedScopeCount !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' - }) - } - }) - -function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { - const types = rawTypes - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) as MessageType[] | undefined - const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) - if (invalidTypes && invalidTypes.length > 0) { - throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) - } - return types && types.length > 0 ? types : undefined -} - -function resolveMessageRun( - runtime: OrcaRuntimeService, - params: { - from?: string - senderPaneKey?: string - to?: string - runId?: string - payload?: string - } -): { run: RunRow | undefined; dispatchId: string | undefined } { - const db = runtime.getOrchestrationDb() - let dispatchId: string | undefined - if (params.payload) { - try { - const payload: unknown = JSON.parse(params.payload) - if ( - payload && - typeof payload === 'object' && - !Array.isArray(payload) && - typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' - ) { - dispatchId = (payload as { dispatchId: string }).dispatchId - } - } catch { - // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. - } - } - if (!dispatchId && params.to?.startsWith('dispatch:')) { - dispatchId = params.to.slice('dispatch:'.length) - } - - const dispatch = dispatchId - ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined - if (params.to?.startsWith('dispatch:') && !dispatch) { - throw new OrchestrationError( - 'dispatch_not_found', - `Dispatch ${dispatchId ?? ''} was not found.` - ) - } - const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined - const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id - let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - } - if (resolvedRunId && (!run || run.legacy === 1)) { - throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) - } - if (run && targetRunId && targetRunId !== run.id) { - throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) - } - if (run && dispatch && dispatch.run_id !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` - ) - } - return { run, dispatchId: dispatch?.id ?? dispatchId } -} - -function legacyWorkerDeliveryContract( - runtime: OrcaRuntimeService, - runId: string | undefined, - recipient: string -): 'legacy_direct' | undefined { - if (!runId) { - return undefined - } - if (!recipient.startsWith('dispatch:')) { - return runtime - .getOrchestrationDb() - .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) - ? 'legacy_direct' - : undefined - } - const dispatch = runtime - .getOrchestrationDb() - .getDispatchContextById(recipient.slice('dispatch:'.length)) - return dispatch?.run_id === runId && - dispatch.contract_version === LEGACY_CONTRACT_VERSION && - (dispatch.status === 'pending' || dispatch.status === 'dispatched') - ? 'legacy_direct' - : undefined -} - -function interruptedAcknowledgedCheck( - runId: string, - acknowledged: string, - reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' -): Record { - return { - runId, - deliveryId: null, - messages: [], - count: 0, - acknowledged, - timedOut: false, - cancelled: false, - connectionLost: false, - waitInterrupted: reason - } -} - -function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { - if (params.to || params.run) { - throw new OrchestrationError( - 'invalid_argument', - 'Federated Dispatch messages route to their Run home; omit --to and --run.' - ) - } -} +import { ORCHESTRATION_SEND_METHODS } from './orchestration-send-methods' +import { ORCHESTRATION_CHECK_METHODS } from './orchestration-check-methods' +import { ORCHESTRATION_MESSAGE_METHODS } from './orchestration-message-methods' +import { ORCHESTRATION_DISPATCH_METHODS } from './orchestration-dispatch-methods' +import { ORCHESTRATION_ASK_METHODS } from './orchestration-ask-methods' +import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' +import { ORCHESTRATION_RESET_METHODS } from './orchestration-reset-methods' export const ORCHESTRATION_METHODS: RpcMethod[] = [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, ...ORCHESTRATION_MUTATION_REQUEST_METHODS, - defineMethod({ - name: 'orchestration.send', - params: SendParams, - handler: async ( - params, - { - runtime, - orchestrationCapability, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - orchestrationCompatibilityCallerAuthority, - recordMutationReceipt, - signal - } - ) => { - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - const attestedCaller = - orchestrationCompatibilityCallerAuthority?.terminalHandle === from - ? orchestrationCompatibilityCallerAuthority - : undefined - // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = senderPaneKey - ? db.findActiveRemoteAttachmentForPane(senderPaneKey) - : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - const processIncarnation = - attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: remoteAttachment.dispatch_id, - capability: orchestrationCapability, - paneKey: senderPaneKey ?? null, - processIncarnation - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const type = (params.type ?? 'status') as MessageType - const payload = parseRemoteWorkerPayload(params.payload) - if ( - typeof payload.dispatchId === 'string' && - payload.dispatchId !== remoteAttachment.dispatch_id - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` - ) - } - const outcome = - type === 'worker_done' && - (payload.outcome === 'succeeded' || payload.outcome === 'failed') - ? payload.outcome - : undefined - if (type === 'worker_done' && !outcome) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote worker_done requires outcome=succeeded|failed.' - ) - } - const supportsLifecycleSettlement = - remoteAttachment.protocol_version >= - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION - const relay = db.enqueueFederationRelay({ - dispatchId: remoteAttachment.dispatch_id, - direction: 'to_home', - kind: type, - payload: JSON.stringify({ - from, - subject: params.subject, - body: params.body ?? '', - type, - priority: params.priority ?? 'normal', - threadId: params.threadId ?? null, - payload: bindCoordinatorMutationPayload( - type, - params.payload, - remoteAttachment.dispatch_id - ) - }), - ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) - }) - const lifecycle = - outcome && supportsLifecycleSettlement - ? await waitForFederatedLifecycleSettlement( - runtime, - relay.dispatch_id, - relay.sequence, - { - timeoutMs: 30_000, - signal - } - ) - : outcome - ? { - action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), - authority: 'worker_server_legacy' as const - } - : undefined - if (outcome && supportsLifecycleSettlement && !lifecycle) { - throw new OrchestrationError( - 'operation_unknown', - 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' - ) - } - return { - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'run_home', - accepted: true - }, - ...(lifecycle ? { lifecycle } : {}) - } - } - const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, - to: params.to, - runId: params.run, - payload: params.payload - }) - if ( - params.type === 'worker_done' && - !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'worker_done requires outcome=succeeded|failed for a current Dispatch.' - ) - } - if (params.to?.startsWith('task:')) { - throw new OrchestrationError( - 'invalid_argument', - 'Task recipients are intentionally unsupported; use run: or dispatch:.' - ) - } - let to = params.to - if ( - routing.run && - (!to || - ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) - ) { - to = `run:${routing.run.id}` - } - if (!to) { - throw new OrchestrationError( - 'run_required', - 'No recipient or active Dispatch Run could be resolved. No effects were applied.', - orchestrationSkillRecoveryData() - ) - } - - const sendWarnings: SendRecipientWarning[] = [] - let messageRunId = routing.run?.id - if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { - const recipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: to, - senderRunId: routing.run?.id, - explicitRunId: params.run - }) - if (!recipient.ok) { - throw new OrchestrationError(recipient.code, recipient.message) - } - to = recipient.to - messageRunId = recipient.runId - if (recipient.warning) { - sendWarnings.push(recipient.warning) - } - } - const withSendWarnings = ( - receipt: T - ): T & { - warnings?: SendRecipientWarning[] - } => (sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt) - - if (!isGroupAddress(to)) { - const addressedDispatchId = to.startsWith('dispatch:') - ? to.slice('dispatch:'.length) - : undefined - const federatedTarget = - addressedDispatchId && to === `dispatch:${addressedDispatchId}` - ? db.getFederatedDispatch(addressedDispatchId) - : undefined - if (addressedDispatchId && !federatedTarget) { - assertDispatchMailboxDeliverable(db, addressedDispatchId) - } - if (federatedTarget && addressedDispatchId) { - const dispatchId = addressedDispatchId - if ( - federatedTarget.protocol_version < - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION - ) { - throw new OrchestrationError( - 'capability_unsupported', - `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` - ) - } - if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { - throw new OrchestrationError( - 'dispatch_inactive', - `Federated Dispatch ${dispatchId} is not active.` - ) - } - if (params.type === 'worker_done' || params.type === 'heartbeat') { - throw new OrchestrationError( - 'invalid_argument', - 'Coordinator-to-worker control mail cannot report worker lifecycle.' - ) - } - revalidateLegacyCoordinator?.() - const relay = db.enqueueFederationRelay({ - dispatchId, - direction: 'to_worker', - kind: 'control_message', - payload: encodeFederatedControlMessage({ - from, - subject: params.subject, - body: params.body ?? '', - type: (params.type ?? 'status') as MessageType, - priority: (params.priority ?? 'normal') as MessagePriority, - threadId: params.threadId ?? null, - payload: params.payload ?? null - }) - }) - runtime.ensureOrchestrationFederationRelay(messageRunId) - return withSendWarnings({ - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'worker', - accepted: true - } - }) - } - // Point-to-point — existing single-recipient behavior - revalidateLegacyCoordinator?.() - const dispatch = routing.dispatchId - ? db.getDispatchContextById(routing.dispatchId) - : undefined - const messageType = (params.type ?? 'status') as MessageType - const msg = db.insertMessage({ - from, - to, - subject: params.subject, - body: params.body, - type: messageType, - priority: params.priority as MessagePriority, - threadId: params.threadId, - payload: dispatch - ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) - : params.payload, - senderPaneKey, - runId: messageRunId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - messageRunId ?? legacyCoordinatorRunId, - to - ) - }) - const dispatchMutationMessage = isDispatchMutationMessageType(msg.type) - if (dispatchMutationMessage) { - const processIncarnation = - attestedCaller?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(from) ?? - undefined - const taskId = parseMessageTaskId(params.payload) - const capabilityBacked = Boolean(dispatch?.capability_hash) - const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' - let authority: { - valid: boolean - code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' - reason: string - } - if (!dispatch) { - authority = { - valid: !coordinatorMutation, - code: 'sender_not_assignee', - reason: 'No active Dispatch belongs to this message sender.' - } - } else if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { - authority = { - valid: false, - code: 'task_dispatch_mismatch', - reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` - } - } else if (capabilityBacked) { - const capabilityAuthority = db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability: orchestrationCapability, - paneKey: senderPaneKey, - processIncarnation - }) - authority = { - valid: capabilityAuthority.valid, - code: 'dispatch_capability_invalid', - reason: capabilityAuthority.valid ? '' : capabilityAuthority.reason - } - } else if (dispatch.process_incarnation) { - authority = { - valid: db.isDispatchProcessCurrent({ - dispatchId: dispatch.id, - paneKey: senderPaneKey ?? null, - processIncarnation: processIncarnation ?? null - }), - code: 'sender_not_assignee', - reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` - } - } else { - authority = { - valid: - !coordinatorMutation || - db.isDispatchMessageSender({ - dispatchId: dispatch.id, - handle: from, - paneKey: senderPaneKey - }), - code: 'sender_not_assignee', - reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` - } - } - if (!authority.valid) { - const code = authority.code - const rejection = - db.convertLifecycleMessageToRejection(msg.id, code, authority.reason) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ - message: rejection, - lifecycle: { - action: 'rejected', - code, - reason: authority.reason - } - }) - } - } - // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. - if (msg.type === 'worker_done' || msg.type === 'heartbeat') { - const reconciled = reconcileLifecycleMessage(db, msg) - // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. - if (reconciled.action === 'suppressed') { - return withSendWarnings({ message: msg }) - } - if (reconciled.action === 'rejected') { - const rejection = db.getMessageById(msg.id) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ message: rejection, lifecycle: reconciled }) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings( - msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } - ) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings({ message: msg }) - } - - // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). - const { terminals } = await runtime.listTerminals(undefined, undefined, { - includeVisualLayouts: false - }) - const handles = resolveGroupAddress(to, from, terminals, (handle: string) => - runtime.getAgentStatusForHandle(handle) - ) - - if (handles.length === 0) { - throw new Error(`No recipients resolved for group address: ${to}`) - } - - const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() - const resolvedRecipients = handles.map((handle) => ({ - handle, - resolution: resolveBareOrchestrationRecipient({ - runtime, - db, - handle, - senderRunId: routing.run?.id, - explicitRunId: params.run, - legacyAdoptedMailboxOwner - }) - })) - const deliverableRecipients = resolvedRecipients.filter( - ( - recipient - ): recipient is typeof recipient & { - resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } - } => recipient.resolution.ok - ) - const senderRecipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: from, - senderRunId: routing.run?.id, - legacyAdoptedMailboxOwner - }) - const senderMailboxKey = senderRecipient.ok - ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` - : undefined - const seenMailboxes = new Set() - const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { - const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` - if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { - return false - } - seenMailboxes.add(mailboxKey) - return true - }) - if (uniqueRecipients.length === 0) { - throw new OrchestrationError( - 'terminal_not_found', - `No recipient of ${to} resolved to a live terminal or durable Run/Dispatch mailbox.` - ) - } - - revalidateLegacyCoordinator?.() - const threadId = params.threadId ?? `thread_${Date.now()}` - const messages = db.insertMessages( - uniqueRecipients.map(({ resolution }) => ({ - from, - to: resolution.to, - subject: params.subject, - body: params.body, - type: params.type as MessageType, - priority: params.priority as MessagePriority, - threadId, - payload: params.payload, - senderPaneKey, - runId: resolution.runId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - resolution.runId ?? legacyCoordinatorRunId, - resolution.to - ) - })) - ) - const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => - resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] - ) - const receipt = { - messages, - recipients: messages.length, - ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) - } - recordMutationReceipt?.(receipt) - for (const message of messages) { - runtime.notifyMessageArrived(message.to_handle, message.type) - } - return receipt - } - }), - - defineMethod({ - name: 'orchestration.check', - params: CheckParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - signal, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - recordMutationReceipt - } - ) => { - const db = runtime.getOrchestrationDb() - const handle = params.terminal ?? 'unknown' - const typeFilter = parseMessageTypes(params.types) - const routeDirectSnapshot = async ( - runId: string, - directHandle: string, - routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } - ): Promise => { - const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) - if (throughSequence !== undefined) { - await routeAllMailboxPages(() => routePage(throughSequence), signal) - } - } - - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - if (params.run || boundRun) { - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const generation = run.consumer_generation - const address = `run:${run.id}` - runtime.ensureOrchestrationFederationRelay(run.id) - await routeDirectSnapshot(run.id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) - ) - const coordinatorHandle = run.coordinator_handle - if (coordinatorHandle && coordinatorHandle !== handle) { - await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) - ) - } - revalidateLegacyCoordinator?.() - const currentRun = resolveRunScope(runtime, { - runId: run.id, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (currentRun.consumer_generation !== generation) { - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while routing pending mail.' - ) - } - - const acknowledged = params.ack - ? db.acknowledgeRunDelivery({ - runId: run.id, - consumerGeneration: generation, - deliveryId: params.ack - }) - : undefined - if (acknowledged) { - recordMutationReceipt?.( - interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') - ) - } - if (params.all || (params.unread === false && !params.peek)) { - const history = db.getRunMailboxHistory(run.id, 100, typeFilter) - const messages = history - const result = { - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null - } - if (params.format || params.inject) { - return { - ...result, - formatted: messages.map(formatMessageBanner).join('\n\n'), - runId: run.id - } - } - return { ...result, runId: run.id } - } - - const peekResult = (messages: MessageRow[]) => ({ - runId: run.id, - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - }) - const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) - const readDelivery = (wakeTypes?: MessageType[]) => - db.getOrCreateRunDelivery({ - runId: run.id, - consumerGeneration: generation, - wakeTypes - }) - let peeked = params.peek ? readPeek() : [] - if (params.peek && peeked.length > 0) { - return peekResult(peeked) - } - let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) - if (current) { - return { - runId: run.id, - deliveryId: current.delivery.id, - messages: current.messages, - count: current.messages.length, - replayed: current.replayed, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format || params.inject - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - if (!params.wait) { - if (params.peek) { - return peekResult([]) - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false - } - } - - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal, - exclusive: true - }) - try { - revalidateLegacyCoordinator?.() - } catch (error) { - if (!acknowledged) { - throw error - } - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - const latestRun = db.getRun(run.id) - if (!latestRun || latestRun.consumer_generation !== generation) { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while waiting.' - ) - } - if (waitResult === 'waiter_exists') { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') - } - throw new OrchestrationError( - 'waiter_exists', - `Run ${run.id} already has an active actionable waiter.` - ) - } - if (waitResult === 'timed_out') { - if (params.peek) { - return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: true, - cancelled: false, - connectionLost: false - } - } - if (waitResult === 'cancelled') { - if (params.peek) { - return { - ...peekResult([]), - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - - if (params.peek) { - peeked = readPeek() - return { - ...peekResult(peeked), - timedOut: false, - cancelled: false, - connectionLost: false - } - } - current = readDelivery(typeFilter) - return { - runId: run.id, - deliveryId: current?.delivery.id ?? null, - messages: current?.messages ?? [], - count: current?.messages.length ?? 0, - replayed: current?.replayed ?? false, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format && current - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - const remoteAttachment = - !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if ( - remoteAttachment && - !db.isRemoteAttachmentProcessCurrent({ - dispatchId: remoteAttachment.dispatch_id, - paneKey: paneKey ?? null, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` - ) - } - const workerMailbox = activeDispatch - ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } - : remoteAttachment - ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } - : undefined - if (workerMailbox) { - const address = `dispatch:${workerMailbox.dispatchId}` - const revalidateWorkerMailbox = async (): Promise => { - if (activeDispatch) { - const current = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - if (current?.id === activeDispatch.id) { - return - } - } else if (remoteAttachment && paneKey) { - const current = db.findActiveRemoteAttachmentForPane(paneKey) - if ( - current?.dispatch_id === remoteAttachment.dispatch_id && - db.isRemoteAttachmentProcessCurrent({ - dispatchId: current.dispatch_id, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - return - } - } - const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) - const owningRunId = - latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId - if ( - owningRunId && - (!latestDispatch || - (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) - ) { - const throughSequence = db.getLatestUnreadMessageSequence(address) - if (throughSequence !== undefined) { - const routedTypes = new Set() - const routePage = (): { routedCount: number; hasMore: boolean } => { - const routed = db.routeUnreadDispatchMailboxToRunMailbox( - workerMailbox.dispatchId, - owningRunId, - throughSequence - ) - for (const routedType of routed.types) { - routedTypes.add(routedType) - } - return routed - } - const notifyRoutedTypes = (): void => { - for (const routedType of routedTypes) { - runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) - } - routedTypes.clear() - } - try { - await routeAllMailboxPages(routePage, signal) - } catch (error) { - notifyRoutedTypes() - if (error instanceof OrchestrationError && error.code === 'request_aborted') { - setImmediate(() => { - void routeAllMailboxPages(routePage) - .catch(() => undefined) - .finally(notifyRoutedTypes) - }) - } - throw error - } - notifyRoutedTypes() - } - } - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` - ) - } - if (activeDispatch) { - await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - handle, - throughSequence - ) - ) - const assigneeHandle = activeDispatch.assignee_handle - if (assigneeHandle && assigneeHandle !== handle) { - await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - assigneeHandle, - throughSequence - ) - ) - } - } - await revalidateWorkerMailbox() - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const messages = showAll - ? db.getAllMessagesForHandle(address, 100, typeFilter) - : db.getUnreadMessages(address, typeFilter) - if (!showAll && params.peek !== true && messages.length > 0) { - db.markAsRead(messages.map((message) => message.id)) - } - if (messages.length > 0 || !params.wait) { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages, - count: messages.length, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - await revalidateWorkerMailbox() - if (waitResult === 'timed_out' || waitResult === 'cancelled') { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: [], - count: 0, - timedOut: waitResult === 'timed_out', - cancelled: waitResult === 'cancelled', - connectionLost: waitResult === 'cancelled' && signal?.aborted === true - } - } - const arrived = db.getUnreadMessages(address, typeFilter) - db.markAsRead(arrived.map((message) => message.id)) - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: arrived, - count: arrived.length, - ...(params.format || params.inject - ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const consumeUnread = !showAll && params.peek !== true - - const readAndReturn = () => { - const messages = showAll - ? db.getAllMessagesForHandle(handle, undefined, typeFilter) - : db.getUnreadMessages(handle, typeFilter) - - if ( - consumeUnread && - messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', - { effectsApplied: false } - ) - } - - let visibleMessages = messages - if (consumeUnread && messages.length > 0) { - // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. - visibleMessages = messages.map((message) => { - const reconciled = reconcileLifecycleMessage(db, message) - return reconciled.action === 'rejected' - ? (db.getMessageById(message.id) ?? message) - : message - }) - db.markAsRead(messages.map((m) => m.id)) - } - - if (params.format || params.inject) { - const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') - return { messages: visibleMessages, formatted, count: visibleMessages.length } - } - - return { messages: visibleMessages, count: visibleMessages.length } - } - - if (signal?.aborted) { - return { messages: [], count: 0 } - } - const result = readAndReturn() - if (result.count > 0 || !params.wait) { - return result - } - - // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). - const waitResult = await runtime.waitForMessage(handle, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - if (signal?.aborted) { - return { messages: [], count: 0 } - } - if (waitResult === 'cancelled') { - throw new OrchestrationError( - 'consumer_fenced', - 'This direct mailbox became owned by a Run while the check was waiting.' - ) - } - return readAndReturn() - } - }), - - defineMethod({ - name: 'orchestration.reply', - params: ReplyParams, - handler: async ( - params, - { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } - ) => { - const db = runtime.getOrchestrationDb() - const original = db.getMessageById(params.id) - if (!original) { - throw new Error(`Message not found: ${params.id}`) - } - if ( - legacyCoordinatorRunId && - (original.run_id !== legacyCoordinatorRunId || - (params.run !== undefined && params.run !== legacyCoordinatorRunId)) - ) { - throw new OrchestrationError( - 'request_mismatch', - `Message ${params.id} does not belong to this adopted Run.`, - { effectsApplied: false } - ) - } - if ( - original.run_id === ORCHESTRATION_LEGACY_RUN_ID || - original.delivery_contract === 'legacy_direct' || - original.delivery_contract === 'audit_only' - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; no reply was applied.', - { effectsApplied: false } - ) - } - - const question = db.getQuestion(params.id) - if (question) { - const run = resolveRunScope(runtime, { - runId: params.run ?? question.run_id, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const answered = db.answerQuestion({ - messageId: question.message_id, - runId: run.id, - consumerGeneration: run.consumer_generation, - body: params.body - }) - const federated = db.getFederatedDispatch(question.dispatch_id) - if (federated) { - db.enqueueFederationRelay({ - dispatchId: question.dispatch_id, - direction: 'to_worker', - kind: 'reply', - payload: JSON.stringify({ - questionId: question.message_id, - answerMessageId: answered.message.id, - body: params.body - }) - }) - runtime.ensureOrchestrationFederationRelay(run.id) - } else { - runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') - } - return { - message: answered.message, - question: answered.question, - duplicate: answered.duplicate - } - } - - db.markAsRead([original.id]) - - const reply = db.insertMessage({ - from: params.from ?? original.to_handle, - to: original.from_handle, - subject: `Re: ${original.subject}`, - body: params.body, - threadId: original.thread_id ?? original.id, - runId: original.run_id - }) - - runtime.notifyMessageArrived(reply.to_handle, reply.type) - return { message: reply } - } - }), - - defineMethod({ - name: 'orchestration.inbox', - params: InboxParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). - const messages = params.terminal - ? db.getAllMessagesForHandle(params.terminal, params.limit) - : db.getInbox(params.limit) - return { messages, count: messages.length } - } - }), - - defineMethod({ - name: 'orchestration.taskCreate', - params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) - : null - const task = db.createTask({ - spec: params.spec, - taskTitle: params.taskTitle, - displayName: params.displayName, - deps, - parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, - ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation - ? { - createdByPaneKey: creatorAuthority.paneKey, - createdByProcessIncarnation: creatorAuthority.processIncarnation, - createdByRunGeneration: run.consumer_generation - } - : {}), - runId: run.id - }) - return { task } - } - }), - - defineMethod({ - name: 'orchestration.taskList', - params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const explicitRun = params.run ? db.getRun(params.run) : undefined - const run = - explicitRun?.legacy === 1 - ? explicitRun - : resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: params.run === undefined, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. - const joined = db.listTasksWithDispatch({ - status: params.status as TaskStatus, - ready: params.ready, - runId: run.id - }) - const tasks = joined.map((row) => { - const { assignee_handle, dispatch_id, ...base } = row - if (base.status === 'dispatched') { - return { ...base, assignee_handle, dispatch_id } - } - return base - }) - return { - runId: run.id, - legacyReadOnly: run.legacy === 1, - tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, - count: tasks.length - } - } - }), - - defineMethod({ - name: 'orchestration.taskUpdate', - params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const existing = db.getTask(params.id) - if (!existing || existing.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${params.id} was not found in Run ${run.id}.` - ) - } - const task = db.updateTaskStatus(params.id, params.status, params.result) - if (!task) { - throw new Error(`Task not found: ${params.id}`) - } - return { task } - } - }), - - defineMethod({ - name: 'orchestration.dispatch', - params: DispatchParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - legacyCoordinatorRunId, - revalidateLegacyCoordinator - } - ) => { - const db = runtime.getOrchestrationDb() - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (task.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${task.id} was not found in Run ${run.id}.` - ) - } - - // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. - if (params.dryRun) { - const maxDepth = runtime.getNestedWorkerMaxDepth() - const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), - maxDepth - ) - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: 'ctx_dryrun', - canDispatchSubWorkers: previewDepth < maxDepth, - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: params.to ?? 'worker', - devMode: params.devMode, - ...(params.to - ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } - : {}) - }) - return { dispatch: null, injected: false, dryRun: true, preamble } - } - - if (!params.to) { - throw new Error('Missing --to') - } - const to = params.to - - if (task.status !== 'ready') { - throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) - } - - // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { - const hasAgent = await runtime.isTerminalRunningAgent(to) - if (!hasAgent) { - throw new Error(buildInjectRejectionMessage(to)) - } - } - - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) - const assigneePaneKey = - dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined - const processIncarnation = - dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation - ? dispatchAuthority.processIncarnation - : undefined - if (params.inject && (!assigneePaneKey || !processIncarnation)) { - throw new OrchestrationError( - 'stable_pane_required', - `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` - ) - } - - revalidateLegacyCoordinator?.() - const ctx = db.createDispatchContext({ - taskId: params.task, - assigneeHandle: to, - assigneePaneKey, - launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, - processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), - maxDepth: runtime.getNestedWorkerMaxDepth() - }) - const dispatchCapability = params.inject - ? db.mintDispatchCapability({ - dispatchId: ctx.id, - paneKey: assigneePaneKey as string, - processIncarnation: processIncarnation as string - }) - : undefined - - // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: ctx.id, - canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: to, - dispatchCapability, - devMode: params.devMode, - cliCommand: runtime.getTerminalOrchestrationCliCommand(to) - }) - - let injected = false - if (params.inject) { - try { - await runtime.sendTerminalAgentPrompt(to, preamble) - injected = true - } catch (err) { - db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) - throw err - } - } - - // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. - if (params.returnPreamble) { - return { dispatch: ctx, injected, preamble } - } - return { dispatch: ctx, injected } - } - }), - - defineMethod({ - name: 'orchestration.dispatchShow', - params: DispatchShowParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (!params.task) { - throw new Error('Missing --task') - } - const ctx = db.getDispatchContext(params.task) - - // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. - if (params.preamble) { - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const workerHandle = ctx?.assignee_handle ?? 'worker' - const preamble = buildDispatchPreamble({ - taskId: task.id, - // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. - dispatchId: ctx?.id ?? 'ctx_preview', - canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle, - devMode: params.devMode, - ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) - }) - return { dispatch: ctx ?? null, preamble } - } - - return { dispatch: ctx ?? null } - } - }), - - defineMethod({ - name: 'orchestration.ask', - params: AskParams, - handler: async ( - params, - { runtime, signal, orchestrationCapability, recordMutationReceipt } - ) => { - // Why: group addresses have no unambiguous first-answer authority. - if (params.to && isGroupAddress(params.to)) { - throw new Error( - 'ask does not support group addresses; use send for non-blocking fan-out questions' - ) - } - - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. - const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) - const paneKey = runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - return askRemoteRunHome({ - params: { ...params, timeoutMs }, - runtime, - signal, - orchestrationCapability, - recordMutationReceipt, - from, - paneKey: paneKey as string, - dispatchId: remoteAttachment.dispatch_id, - taskId: remoteAttachment.task_id - }) - } - const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) - if (!activeDispatch) { - throw new OrchestrationError( - 'dispatch_inactive', - 'ask requires an active supervised Dispatch.' - ) - } - if (activeDispatch.capability_hash) { - const authority = db.verifyDispatchCapability({ - dispatchId: activeDispatch.id, - capability: orchestrationCapability, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined - }) - if (!authority.valid) { - throw new OrchestrationError('dispatch_capability_invalid', authority.reason) - } - } - const options = - params.options - ?.split(',') - .map((s) => s.trim()) - .filter(Boolean) ?? [] - let question = params.resume ? db.getQuestion(params.resume) : undefined - if (params.resume) { - if (!question || question.dispatch_id !== activeDispatch.id) { - throw new OrchestrationError( - 'question_not_found', - `Question ${params.resume} does not belong to this active Dispatch.` - ) - } - } else { - const run = db.getRun(activeDispatch.run_id) - if (!run || run.legacy === 1) { - throw new OrchestrationError( - 'run_not_found', - `Run ${activeDispatch.run_id} was not found.` - ) - } - if (params.run && params.run !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` - ) - } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` - ) - } - const created = db.createQuestion({ - runId: run.id, - dispatchId: activeDispatch.id, - askerHandle: from, - question: params.question as string, - options - }) - question = created.question - runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) - } - - const questionId = question.message_id - recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const current = db.getQuestion(questionId) - if (!current || current.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its Dispatch is inactive.` - ) - } - if (current.status === 'answered') { - return { - answer: current.answer_body, - messageId: questionId, - answerMessageId: current.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { - timeoutMs: remainingMs, - signal - }) - } - } - }), - + ...ORCHESTRATION_SEND_METHODS, + ...ORCHESTRATION_CHECK_METHODS, + ...ORCHESTRATION_MESSAGE_METHODS, + ...ORCHESTRATION_DISPATCH_METHODS, + ...ORCHESTRATION_ASK_METHODS, ...ORCHESTRATION_GATE_METHODS, - - defineMethod({ - name: 'orchestration.reset', - params: ResetParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (params.all) { - runtime.stopOrchestrationFederationRelay() - db.resetAll() - return { reset: 'all' } - } - if (params.tasks) { - runtime.stopOrchestrationFederationRelay() - db.resetTasks() - return { reset: 'tasks' } - } - if (params.messages) { - db.resetMessages() - return { reset: 'messages' } - } - throw new Error('Invalid reset scope') - } - }) + ...ORCHESTRATION_RESET_METHODS ] - -async function askRemoteRunHome(args: { - params: z.infer - runtime: OrcaRuntimeService - signal?: AbortSignal - orchestrationCapability?: string - recordMutationReceipt?: (receipt: unknown) => void - from: string - paneKey: string - dispatchId: string - taskId: string -}): Promise { - const db = args.runtime.getOrchestrationDb() - const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: args.dispatchId, - capability: args.orchestrationCapability, - paneKey: args.paneKey, - processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const options = - args.params.options - ?.split(',') - .map((option) => option.trim()) - .filter(Boolean) ?? [] - let questionId = args.params.resume - if (questionId) { - const existing = db.getRemoteQuestion(questionId) - if (!existing || existing.dispatch_id !== args.dispatchId) { - throw new OrchestrationError( - 'question_not_found', - `Question ${questionId} does not belong to this remote Dispatch.` - ) - } - } else { - const relay = db.enqueueFederationRelay({ - dispatchId: args.dispatchId, - direction: 'to_home', - kind: 'question', - payload: JSON.stringify({ - from: args.from, - subject: 'Question', - body: args.params.question as string, - type: 'question', - priority: 'normal', - threadId: null, - payload: JSON.stringify({ - taskId: args.taskId, - dispatchId: args.dispatchId, - question: args.params.question, - options - }) - }), - remoteQuestion: true - }) - questionId = relay.message_id - } - args.recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const question = db.getRemoteQuestion(questionId) - if (!question || question.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its remote Dispatch is inactive.` - ) - } - if (question.status === 'answered') { - return { - answer: question.answer_body, - messageId: questionId, - answerMessageId: question.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (args.signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { - timeoutMs: remainingMs, - signal: args.signal - }) - } -} diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts new file mode 100644 index 00000000000..ce33ab0b451 --- /dev/null +++ b/src/main/startup/branch-rename-hook.ts @@ -0,0 +1,128 @@ +import { existsSync } from 'node:fs' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' +import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' +import { moveWorktree } from '../git/worktree' +import { mainProcessState as state } from './main-process-state' + +const ENABLE_FIRST_WORK_FOLDER_RENAME = false + +export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined +}): void { + const store = state.store + const runtime = state.runtime + if (!store || !runtime) { + return + } + void maybeAutoRenameBranchOnFirstWork( + { + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + state: event.payload.state, + prompt: event.payload.prompt, + assistantMessage: event.payload.lastAssistantMessage, + isReplay: event.isReplay + }, + { + getSettings: () => store.getSettings(), + getRepo: (repoId) => store.getRepo(repoId), + getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name + : store.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === + true + : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + store.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + runtime.notifyFolderWorkspaceChanged() + return + } + store.setWorktreeMeta(worktreeId, { + displayName, + // The first-agent title is an intentional user-facing label; keep it stable after the + // generated branch is renamed and across subsequent catalog refreshes. + displayNameIsPinned: true, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + }, + renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME + ? (worktreeId, newLeaf) => + renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { + getRepo: (repoId) => store.getRepo(repoId), + getSettings: () => store.getSettings(), + migrateWorktreeIdentity: (oldId, newId) => + store.migrateWorktreeIdentity(oldId, newId), + notifyWorktreeRenamed: (repoId, oldId, newId) => + runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), + pathExists: async (candidate) => existsSync(candidate), + moveWorktree + }) + : undefined, + setRenameError: (worktreeId, error, failureOutput) => { + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = store.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + runtime.notifyFolderWorkspaceChanged() + return + } + const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) + }, + resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + runtime.notifyFolderWorkspaceChanged() + return + } + runtime.notifyBranchRenamed(repoIdOrWorktreeId) + } + } + ) +} diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts new file mode 100644 index 00000000000..3b94c00b11d --- /dev/null +++ b/src/main/startup/codex-launch-preparation.ts @@ -0,0 +1,95 @@ +import { app } from 'electron' +import type { CodexHomeLaunchContext } from '../ipc/pty' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { codexHookService } from '../codex/hook-service' +import { getDefaultWslDistro } from '../wsl' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexRuntimeHomeForLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +): Promise { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome) { + throw new Error('Codex runtime home service is not initialized') + } + if ( + target?.runtime !== 'wsl' && + launchContext?.launchAgent === 'codex' && + launchContext.workspacePath + ) { + try { + // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. + await markCodexProjectTrusted(launchContext.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + } + } + const ensureRealHomeHooksIfSelected = async (): Promise => { + if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { + return false + } + // Why (flag ON, system default): the hook entry must exist — appended last + // and trusted by codex's own app-server grant — in the real ~/.codex before + // the pane spawns. An incapable grant flips the lane gate so the launch + // below falls back to the managed home instead of a status-blind pane. + await ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()), + userDataPath: app.getPath('userData') + }) + return true + } + let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — + // the fallbacks below all key off `null`, which means "system default", so + // swallowing the refusal would launch the wrong account (#STA-4422). + let runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + if (runtimeHomePath === null && !realHomeHooksPrepared) { + // Why: launch prep can reject an untrusted managed home and clear its + // selection. Establish hook capability for that newly selected lane, then + // re-resolve if the capability gate rejects it. + realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + if (realHomeHooksPrepared) { + runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + } + } + if (runtimeHomePath === null && target?.runtime !== 'wsl') { + // Why: Codex runs on the user's real ~/.codex; the managed-home hook + // install below would target a home Codex never reads on this lane. + return null + } + const hookTarget = + target?.runtime === 'wsl' + ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } + : target + const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings()) + try { + // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. + const status = await codexHookService.prepareRuntimeHomeForLaunch( + runtimeHomePath, + hookTarget, + hooksEnabled + ) + if (status.state === 'error') { + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + status.detail + ) + } + } catch (error) { + // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + error + ) + } + return runtimeHomePath +} diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts new file mode 100644 index 00000000000..65a2c6f6781 --- /dev/null +++ b/src/main/startup/codex-session-resume-launch.ts @@ -0,0 +1,98 @@ +import { app } from 'electron' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' +import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' +import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { codexHookService } from '../codex/hook-service' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise { + const runtimeHome = state.codexRuntimeHome + const store = state.store + if (args.target.runtime === 'wsl' || !runtimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + const trustedHomes = [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] + const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + const preparation = await prepareCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes, + getSelectedAccountCodexHome: () => selectedAccountCodexHome, + systemCodexHomePath: systemHomePath, + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), + resolveVerifiedResumeHome: async (sessionSource) => { + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => runtimeHome.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + throw error + } + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + if (args.workspacePath) { + try { + await markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + await ensureRealHomeCodexHookState({ + hooksEnabled, + userDataPath: app.getPath('userData') + }) + } else if (hooksEnabled) { + await codexHookService.installForLaunchPrep(resumeHome) + } else { + await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) + } + } catch (error) { + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return resumeHome + } + }) + return preparation.outcome === 'resume' + ? { + ...preparation, + reconcileSharedRuntimeAuth: + normalizeRuntimePathForComparison(preparation.codexHomePath) === + normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) + } + : preparation +} diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 232626ca9f5..4747f797cf9 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -775,9 +775,9 @@ describe('safe graphics mode startup switches', () => { // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {') + it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') expect(gateStart).toBeGreaterThanOrEqual(0) const gateEnd = mainSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) @@ -789,14 +789,20 @@ describe('safe graphics mode startup switches', () => { // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready // top-level block and never move into the whenReady callback, where appendSwitch is a silent // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const readyStart = mainSource.indexOf('void app.whenReady()') + it('appends the throttling opt-out before app ready in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const preflightEnd = mainSource.indexOf('\n return true') + const readyStart = entrySource.indexOf('void app.whenReady()') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightEnd).toBeGreaterThan(0) expect(readyStart).toBeGreaterThan(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeLessThan(readyStart) const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(readyStart) + expect(callIndex).toBeLessThan(preflightEnd) }) // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 58339246287..e432ed383d2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -4,17 +4,35 @@ import { describe, expect, it } from 'vitest' describe('startup ordering', () => { it('passes the startup barrier into PTY handlers without blocking window creation', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachStart = source.indexOf('attachMainWindowServices(') - const attachEnd = source.indexOf('rateLimits.attach(window)', attachStart) - const attachBlock = source.slice(attachStart, attachEnd) + const attachSource = readFileSync( + join(process.cwd(), 'src/main/window/attach-main-window-services.ts'), + 'utf8' + ) + const startupSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const runtimeLaunchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const attachStart = attachSource.indexOf('export function attachMainWindowServices(') + const attachEnd = attachSource.indexOf(' registerSshHandlers(', attachStart) + const attachBlock = attachSource.slice(attachStart, attachEnd) // Why: anchor on the destructure head only — the settled-result variable's name is not the // contract, and pinning it turns a rename into a cryptic `expected -1` failure here. - const desktopStart = source.indexOf('const [win') + const desktopStart = runtimeLaunchSource.indexOf('async function launchDesktopMode(') // Why: anchor on code, not a comment — the previous comment anchor was silently reworded, so // this was -1 and sliced to EOF, letting the assertions below pass against never-run code. - const desktopEnd = source.indexOf("win.once('show'", desktopStart) - const desktopStartup = source.slice(desktopStart, desktopEnd) + const desktopEnd = runtimeLaunchSource.indexOf( + '\nexport async function initializeMainProcessRuntimeLaunch', + desktopStart + ) + const desktopStartup = runtimeLaunchSource.slice(desktopStart, desktopEnd) // Why: bound every anchor, not just the desktop pair — an unresolved one slices to EOF. expect(attachStart).toBeGreaterThanOrEqual(0) @@ -22,14 +40,20 @@ describe('startup ordering', () => { expect(desktopStart).toBeGreaterThanOrEqual(0) expect(desktopEnd).toBeGreaterThan(desktopStart) - expect(attachBlock).toContain('awaitLocalPtyStartup: () => localPtyStartupReady') - expect(attachBlock).toContain( - 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' + expect(coreSource).toContain('awaitLocalPtyStartup: () => state.localPtyStartupReady') + expect(coreSource).toContain( + 'awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady' ) - expect(source).toContain( + expect(attachBlock).toContain('awaitLocalPtyStartup: options?.awaitLocalPtyStartup') + expect(attachBlock).toContain( + 'awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup' + ) + expect(startupSource).toContain( 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' ) - expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)') + expect(startupSource).toContain( + 'localPtyStartupReady = services.then((value) => value.localPtyReady)' + ) const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') @@ -50,25 +74,43 @@ describe('startup ordering', () => { }) it('resolves the browser hosting identity with nothing awaited before it', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyIndex = source.indexOf('app.whenReady().then(') - const initIndex = source.indexOf('initializeBrowserClientHostId(') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const initReadyIndex = entrySource.indexOf('initializeMainProcessReady({') + const profileIndex = foundationSource.indexOf('const profile = ensureActiveOrcaProfile()') + const initIndex = foundationSource.indexOf( + 'initializeBrowserClientHostId(profile.profileDirectory)' + ) expect(readyIndex).toBeGreaterThanOrEqual(0) - expect(initIndex).toBeGreaterThan(readyIndex) + expect(initReadyIndex).toBeGreaterThan(readyIndex) + expect(profileIndex).toBeGreaterThanOrEqual(0) + expect(initIndex).toBeGreaterThan(profileIndex) // Why nothing may be awaited first: the identity is stamped into the renderer's argv when the // window is created, and a suspension here lets a window be created against a process-local // stand-in that the durable id then contradicts. The constraint is positional, so only a source // census can hold it — no behavioural test distinguishes "resolved" from "resolved in time". - expect(source.slice(readyIndex, initIndex)).not.toMatch(/\bawait\b/) + expect(foundationSource.slice(profileIndex, initIndex)).not.toMatch(/\bawait\b/) // Why the count: a second call site would leave the ordering claim above ambiguous. - expect(source.split('initializeBrowserClientHostId(')).toHaveLength(2) + expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) it('requires daemon authority before restored-subagent liveness runs', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const sweepStart = source.indexOf('function reapRestoredSubagentsWithoutLiveAgent()') - const sweepEnd = source.indexOf('function startTerminalRuntimeStartupServices()', sweepStart) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const sweepStart = source.indexOf( + 'export async function reapRestoredSubagentsWithoutLiveAgent()' + ) + const sweepEnd = source.indexOf( + 'export function startTerminalRuntimeStartupServices()', + sweepStart + ) const sweep = source.slice(sweepStart, sweepEnd) expect(sweepStart).toBeGreaterThanOrEqual(0) @@ -79,47 +121,53 @@ describe('startup ordering', () => { }) it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") - const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart) - const barrier = source.slice(barrierStart, barrierEnd) - const reconciliationStart = source.indexOf( - 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' ) - const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) - const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveEnd = source.indexOf('return', serveReady) - const desktopWindowStart = source.indexOf( - 'const desktopStartup = startWindowsDesktopBeforeShellPathReady(' + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' ) - const desktopWindowJoin = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveEnd + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' ) - const serveStartup = source.slice(serveStart, serveEnd) - const desktopStartup = source.slice(reconciliationStart, serveStart) + const barrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitFirstWindowStartupServices'" + ) + const barrierEnd = barrierSource.indexOf("'app:startupDiagnostic'", barrierStart) + const barrier = barrierSource.slice(barrierStart, barrierEnd) + const reconciliationStart = foundationSource.indexOf( + 'state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = runtimeSource.indexOf('async function launchServeMode(') + const serveEnd = runtimeSource.indexOf('\nasync function launchDesktopMode', serveStart) + const serveStartup = runtimeSource.slice(serveStart, serveEnd) + const desktopStart = runtimeSource.indexOf( + " if (process.platform === 'win32' && app.isPackaged && !serveOptions)" + ) + const desktopEnd = runtimeSource.indexOf(" app.on('activate'", desktopStart) + const desktopStartup = runtimeSource.slice(desktopStart, desktopEnd) expect(barrierStart).toBeGreaterThanOrEqual(0) expect(barrierEnd).toBeGreaterThan(barrierStart) expect(reconciliationStart).toBeGreaterThanOrEqual(0) - expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveEnd).toBeGreaterThan(serveStart) - // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call - // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. - expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) - expect(desktopWindowStart).toBeLessThan(serveStart) - expect(desktopWindowJoin).toBeGreaterThan(serveEnd) - expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') - expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') - expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + expect(serveStartup).toContain('await state.managedWslCliStartupBarrierReady') + expect(serveStartup).not.toContain('await state.managedWslCliReconciliationReady') + expect(serveStartup.indexOf('await state.managedWslCliStartupBarrierReady')).toBeLessThan( serveStartup.indexOf('await runtimeRpc.start()') ) - expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).not.toContain('await state.managedWslCliReconciliationReady') expect(desktopStartup).toContain( "process.platform === 'win32' && app.isPackaged && !serveOptions" ) expect(desktopStartup).toContain( - 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' + 'openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true })' ) expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices') expect(desktopStartup).toContain('shellPathReady,') @@ -129,31 +177,32 @@ describe('startup ordering', () => { expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({') expect(barrier).toContain('localPtyProviderStartupReady,') - expect(barrier).toContain('await runtime?.refreshRestoredOrchestrationAuthority()') + expect(barrier).toContain('await state.runtime?.refreshRestoredOrchestrationAuthority()') expect(barrier).toContain( - 'return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' + 'return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' ) }) it('reconciles retained Codex homes after authoritative daemon inventory', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const retainedPaneGateIndex = source.indexOf( + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const startupStart = source.indexOf('export function startTerminalRuntimeStartupServices()') + expect(startupStart).toBeGreaterThanOrEqual(0) + const startup = source.slice(startupStart) + const daemonInitIndex = startup.indexOf('await initDaemonPtyProvider(signal') + const retainedPaneGateIndex = startup.indexOf( 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) - const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) - const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' - const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) - const hookReconciliationIndex = source.indexOf( + const inventoryIndex = startup.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) + const reconciliation = 'state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' + const reconciliationIndex = startup.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = startup.indexOf( 'reconcileRetainedCodexHookHomes({', inventoryIndex ) - const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) - const desktopIndex = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveIndex - ) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) @@ -162,35 +211,51 @@ describe('startup ordering', () => { expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) - expect(serveIndex).toBeGreaterThan(reconciliationIndex) - expect(desktopIndex).toBeGreaterThan(serveIndex) - expect(source.split(reconciliation)).toHaveLength(2) + // The call is intentionally kept after the authoritative inventory; anchoring on the state + // receiver avoids matching any prose that mentions the same operation. + expect(startup).toContain(reconciliation) }) it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const serveSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) // Why: the barrier fails open, so the serve-ready payload must carry the // reconciliation state and the bounded wait must be traceable via a milestone. - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') - const readyEnd = source.indexOf('pairing: pairing.available', readyStart) - const readyPayload = source.slice(readyStart, readyEnd) + const readyStart = serveSource.indexOf('await state.serveReadinessPublisher.publish(') + const readyEnd = serveSource.indexOf('pairing: pairing.available', readyStart) + const readyPayload = serveSource.slice(readyStart, readyEnd) // Why: unbounded, a renamed pairing key slices to EOF and the status only has to survive // somewhere later in the file — not in the serve-ready payload this test is about. expect(readyStart).toBeGreaterThanOrEqual(0) expect(readyEnd).toBeGreaterThan(readyStart) - expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') + expect(readyPayload).toContain( + 'managedWslCliReconciliation: state.managedWslCliReconciliationStatus' + ) - expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") - expect(source).toContain("managedWslCliReconciliationStatus = 'settled'") - expect(source).toContain("managedWslCliReconciliationStatus = 'failed'") - expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'pending'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'settled'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'failed'") + expect(runtimeSource).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") }) it('notifies the serve supervisor only after publishing readiness', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const readyStart = source.indexOf('await state.serveReadinessPublisher.publish(') const supervisorReady = source.indexOf('notifyServeSupervisorReady(', readyStart) expect(readyStart).toBeGreaterThanOrEqual(0) @@ -198,7 +263,10 @@ describe('startup ordering', () => { }) it('does not run the rate-limit quota fetch before the first window can show results', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) const attachIndex = source.indexOf('rateLimits.attach(window)') const startIndex = source.indexOf('rateLimits.start({ fetchImmediately: false })') @@ -207,60 +275,67 @@ describe('startup ordering', () => { }) it('wires bounded teardown state to reporting but not recovery or close behavior', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const scopeStart = source.indexOf('function getExpectedTeardownScope(') - const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart) - const scope = source.slice(scopeStart, scopeEnd) - const windowStart = source.indexOf('const window = createMainWindow(store, {') - const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart) - const windowOptions = source.slice(windowStart, windowEnd) - const recorderStart = source.indexOf('function recordProcessGoneCrash(') - const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart) - const recorder = source.slice(recorderStart, recorderEnd) + const lifecycleSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-lifecycle-flags.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) - expect(scopeStart).toBeGreaterThanOrEqual(0) - expect(scopeEnd).toBeGreaterThan(scopeStart) - expect(scope).toContain('resolveExpectedTeardownScope({') - expect(scope).toContain('includeSystemSessionEnd') - expect(windowStart).toBeGreaterThanOrEqual(0) - expect(windowEnd).toBeGreaterThan(windowStart) - expect(windowOptions).toContain('getIsQuitting: () => isQuitting') - expect(windowOptions).toContain( + expect(lifecycleSource).toContain('export function getExpectedTeardownScope(') + expect(lifecycleSource).toContain('resolveExpectedTeardownScope({') + expect(lifecycleSource).toContain('includeSystemSessionEnd') + expect(windowSource).toContain('const window = createMainWindow(store, {') + expect(windowSource).toContain('getIsQuitting: () => state.isQuitting') + expect(windowSource).toContain( 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' ) - expect(recorderStart).toBeGreaterThanOrEqual(0) - expect(recorderEnd).toBeGreaterThan(recorderStart) - expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') + expect(lifecycleSource).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') }) it('attaches renderer services before starting the TCC prompt watcher', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachIndex = source.indexOf('attachMainWindowServices(') - const tccNoticeIndex = source.indexOf('initTccPromptNotice(window', attachIndex) - const quitAbortStart = source.indexOf('onQuitAborted:') - const quitAbortEnd = source.indexOf('onRendererProcessGone:', quitAbortStart) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) + const quitSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) + const attachIndex = coreSource.indexOf('attachMainWindowServices(') + const tccNoticeIndex = coreSource.indexOf('initTccPromptNotice(window', attachIndex) + const quitAbortStart = windowSource.indexOf('onQuitAborted:') + const quitAbortEnd = windowSource.indexOf('onRendererProcessGone:', quitAbortStart) expect(attachIndex).toBeGreaterThanOrEqual(0) expect(tccNoticeIndex).toBeGreaterThan(attachIndex) - expect(source.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( + expect(coreSource.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( 'deferWatchUntilReadyToShow: true' ) - expect(source.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') - expect(source).toContain("process.once('exit', stopTccPromptNotice)") - const willQuitStart = source.indexOf("app.on('will-quit'") - const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) - expect(source.slice(willQuitStart, windowAllClosedStart)).toContain('stopTccPromptNotice()') - expect(source.slice(0, willQuitStart)).not.toContain('stopTccPromptNoticeForQuit') + expect(windowSource.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') + expect(quitSource).toContain("process.once('exit', stopTccPromptNotice)") + const willQuitStart = quitSource.indexOf("app.on('will-quit'") + expect(quitSource.slice(willQuitStart)).toContain('stopTccPromptNotice()') + expect(quitSource).not.toContain('stopTccPromptNoticeForQuit') }) it('keeps the power bridge through vetoable before-quit and disposes after commit', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const beforeQuitStart = source.indexOf("app.on('before-quit'") const willQuitStart = source.indexOf("app.on('will-quit'", beforeQuitStart) const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const beforeQuit = source.slice(beforeQuitStart, willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) - const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(e)') + const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(event)') const disposeIndex = willQuit.indexOf('unsubscribeSystemResumeBroadcast?.()') expect(beforeQuitStart).toBeGreaterThanOrEqual(0) @@ -272,7 +347,10 @@ describe('startup ordering', () => { }) it('joins structured agent sessions to the committed quit barrier', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, willQuitEnd) @@ -286,7 +364,10 @@ describe('startup ordering', () => { }) it('joins agent-browser cleanup before the committed quit exits', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) @@ -309,8 +390,11 @@ describe('startup ordering', () => { }) it('registers repeatable serve signal handling before headless startup completes', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const signalHandlers = source.indexOf('registerServeSignalHandlers(process', serveStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) @@ -320,22 +404,34 @@ describe('startup ordering', () => { }) it('starts the automation scheduler before headless serve reports ready', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveReturn = source.indexOf('return', serveReady) + const serveEnd = source.indexOf('\nasync function launchDesktopMode', serveStart) const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart) - const automationStart = source.indexOf('automations.start()', serveStart) - const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)') - const desktopAutomationStart = source.indexOf('automations.start()', desktopSetWebContents + 1) + const automationStart = source.indexOf('state.automations?.start()', serveStart) + const desktopSetWebContents = windowSource.indexOf( + 'automations.setWebContents(window.webContents)' + ) + const desktopAutomationStart = windowSource.indexOf( + 'automations.start()', + desktopSetWebContents + 1 + ) expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveReady).toBeGreaterThan(serveStart) - expect(serveReturn).toBeGreaterThan(serveReady) + expect(serveEnd).toBeGreaterThan(serveReady) expect(runtimeRpcStart).toBeGreaterThan(serveStart) expect(automationStart).toBeGreaterThan(runtimeRpcStart) expect(automationStart).toBeLessThan(serveReady) - expect(automationStart).toBeLessThan(serveReturn) + expect(automationStart).toBeLessThan(serveEnd) expect(desktopSetWebContents).toBeGreaterThanOrEqual(0) expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents) }) @@ -345,28 +441,35 @@ describe('startup ordering', () => { // scope that accessor throws by design, so every `orca serve` process on macOS died at startup // before it could listen. serve-update-handoff.test.ts mocks the resolver, so only ordering // catches this; serve-update-handoff.app-environment.test.ts pins the throw it depends on. - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const install = 'installServeSupervisorDisconnectQuit(isServeMode)' + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const install = 'installServeSupervisorDisconnectQuit(state.isServeMode)' const appEnvironmentIndex = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') const dataPathIndex = source.indexOf('initDataPath()') const installIndex = source.indexOf(install) + // Why this anchor: preflight returns early when the lock is lost, so this gate is the split's + // equivalent of the old `if (hasSingleInstanceLock)` block head. + const lockGateIndex = source.indexOf('if (!hasLock) {') expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1) expect(appEnvironmentIndex).toBeGreaterThanOrEqual(0) expect(dataPathIndex).toBeGreaterThan(appEnvironmentIndex) expect(installIndex).toBeGreaterThan(dataPathIndex) + expect(lockGateIndex).toBeGreaterThanOrEqual(0) + expect(installIndex).toBeGreaterThan(lockGateIndex) - // Why also pin it synchronous: 'disconnect' cannot be delivered while this module is still - // evaluating, which is the whole reason deferring it is free. Parked behind an await — say + // Why also pin it synchronous: 'disconnect' cannot be delivered while preflight is still + // running, which is the whole reason deferring it is free. Parked behind an await — say // inside app.whenReady() — the ordering above still holds but a parent that dies in the gap // leaves the serve process orphaned on its port, which is the failure this handler prevents. - expect(installIndex).toBeLessThan(source.indexOf('void app.whenReady().then(')) - expect(installIndex).toBeGreaterThan(source.indexOf('if (hasSingleInstanceLock) {')) - // Why only statements at block indentation: the span now covers unrelated helper functions, - // and an `await` inside one of those bodies is not what this guards against — the risk is this - // call itself being parked behind one. + expect(source).toContain('export function runMainProcessPreflight(') + // Why only statements at block indentation: the span covers unrelated helper bodies, and an + // `await` inside one of those is not what this guards against — the risk is this call itself + // being parked behind one. const blockStatements = source - .slice(source.indexOf('if (hasSingleInstanceLock) {'), installIndex) + .slice(lockGateIndex, installIndex) .split('\n') .filter((line) => /^ {2}\S/.test(line) && !line.trim().startsWith('//')) .join('\n') diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts new file mode 100644 index 00000000000..45f825ba141 --- /dev/null +++ b/src/main/startup/gpu-lifecycle.ts @@ -0,0 +1,177 @@ +import { app, type BrowserWindow } from 'electron' +import { relaunchApp } from '../app-relaunch' +import { destroySystemTray } from '../tray/system-tray' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' +import { + clearGpuFallbackMarker, + readActiveGpuFallbackMarker, + writeGpuFallbackMarker, + type WindowsGpuFallbackEnvironment +} from './gpu-fallback-marker' +import { + handleGpuFallbackRecoveredLaunch, + promptForGpuFallbackRecoveredLaunch +} from '../crash-reporting/gpu-fallback-recovered-launch' +import { promptForGpuFallbackRestart } from '../crash-reporting/gpu-fallback-restart-prompt' +import { engageGpuFallbackAfterCrashBurst } from '../crash-reporting/gpu-fallback-engagement' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' +import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' + +export function updateGpuAccelerationAboutPanel(): void { + app.setAboutPanelOptions( + createGpuAccelerationAboutPanelOptions({ + appName: app.name, + appVersion: app.getVersion(), + platform: process.platform, + gpuFallbackActive: state.gpuFallbackActiveThisLaunch, + gpuFeatureStatus: state.gpuFeatureStatus + }) + ) +} + +function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { + const environment = gpuFallbackEnvironment() + return environment.platform === 'win32' ? { ...environment, platform: 'win32' } : null +} + +function persistGpuFallbackMarker( + userDataPath: string, + info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } +): boolean { + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return false + } + try { + writeGpuFallbackMarker(userDataPath, info, environment) + return true + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return false + } +} + +/** Apply persisted software-rendering switches before Electron consumes its command line. */ +export function maybeApplyGpuFallbackForThisLaunch(): void { + if (state.isServeMode || process.platform !== 'win32') { + return + } + const marker = readActiveGpuFallbackMarker(app.getPath('userData'), gpuFallbackEnvironment()) + if (!marker) { + return + } + state.activeGpuFallbackMarker = marker + app.disableHardwareAcceleration() + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + state.gpuFallbackActiveThisLaunch = true + recordCrashBreadcrumb('gpu_fallback_applied', { + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') + }) +} + +export async function presentGpuFallbackRecoveredLaunchPrompt( + window: BrowserWindow +): Promise { + const marker = state.activeGpuFallbackMarker + if (!marker || marker.userConfirmed || window.isDestroyed() || state.isQuitting) { + return + } + state.activeGpuFallbackMarker = null + const userDataPath = app.getPath('userData') + await handleGpuFallbackRecoveredLaunch({ + isQuitting: () => state.isQuitting, + prompt: () => promptForGpuFallbackRecoveredLaunch(window), + confirmSafeGraphics: () => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: marker.engagedAt, + crashesInWindow: marker.crashesInWindow, + userConfirmed: true + }) + }, + clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), + onSafeGraphicsKept: () => + recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { + crashesInWindow: marker.crashesInWindow + }), + restartWithHardware: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', { + mode: 'hardware-retry', + crashesInWindow: marker.crashesInWindow + }) + destroySystemTray() + app.exit(0) + } + }) +} + +export async function handleGpuChildCrash( + reason: string, + exitCode: number | null, + crashedAt: number +): Promise { + if (state.gpuFallbackActiveThisLaunch || state.isQuitting || state.isServeMode) { + return + } + const result = state.gpuCrashFallbackTracker.recordGpuCrash(crashedAt) + if (!result.shouldEngageFallback) { + return + } + const fallbackData = { processReason: reason, exitCode, crashesInWindow: result.crashesInWindow } + const userDataPath = app.getPath('userData') + await engageGpuFallbackAfterCrashBurst( + { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, + { + isQuitting: () => state.isQuitting, + onEngaged: (engagement) => + recordCrashBreadcrumb('gpu_fallback_engaged', { + reason: engagement.reason, + exitCode: engagement.exitCode, + crashesInWindow: engagement.crashesInWindow + }), + persistMarker: (engagement) => + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: false + }), + confirmMarker: (engagement) => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: true + }) + }, + clearMarker: () => clearGpuFallbackMarker(userDataPath), + promptForRestart: () => + promptForGpuFallbackRestart( + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + ), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show restart prompt:', error), + onRestartDeferred: () => + recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), + restartIntoSafeGraphics: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', fallbackData) + destroySystemTray() + app.exit(0) + } + } + ) +} + +export function registerGpuLifecycleHandlers(): void { + app.on('gpu-info-update', () => { + state.gpuFeatureStatus = app.getGPUFeatureStatus() + state.gpuCrashDiagnostics?.warm() + if (app.isReady()) { + updateGpuAccelerationAboutPanel() + } + }) +} diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 2263cdbf94f..e866a5d1926 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -21,9 +21,12 @@ describe('headless PTY registry hydration ordering', () => { }) it('hydrates Electron serve after provider and handler readiness but before RPC', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serve = source.indexOf('if (serveOptions) {') - const provider = source.indexOf('await localPtyProviderStartupReady', serve) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serve = source.indexOf('async function launchServeMode(') + const provider = source.indexOf('await state.localPtyProviderStartupReady', serve) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', provider) const rpc = source.indexOf('await runtimeRpc.start()', handlersAndHydration) const readiness = source.indexOf('await printServeReady(serveOptions)', rpc) diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts index 5e6fbfdf367..6d46d52679d 100644 --- a/src/main/startup/host-port-bootstrap-wiring.test.ts +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -17,7 +17,11 @@ import { describe, expect, it } from 'vitest' * startup, so there is no seam to assert against at runtime. */ describe('host port bootstrap wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const INSTALLS = [ 'setAppEnvironment(new ElectronAppEnvironment())', @@ -37,18 +41,23 @@ describe('host port bootstrap wiring', () => { } }) - it('installs every port before the runtime, the PTY handlers, or any window exists', () => { - // Why these three: they are the first things that resolve a path, seal a credential, - // or register against an injected surface. - const firstUse = Math.min( - ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow('] - .map((marker) => source.indexOf(marker)) - .filter((index) => index >= 0) - ) - expect(firstUse).toBeGreaterThan(0) - + it('installs every port during preflight before it hands off to ready services', () => { + // Why: the ready phase creates the runtime, PTY handlers, and windows. Keeping all host-port + // installs in the preflight phase preserves process-level defaults for both desktop and serve. + const preflightStart = source.indexOf('export function runMainProcessPreflight(') + const preflightReturn = source.indexOf('\n return true', preflightStart) + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightStart).toBeGreaterThanOrEqual(0) + expect(preflightReturn).toBeGreaterThan(preflightStart) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse) + const installIndex = source.indexOf(install) + expect(installIndex, `${install} should run in preflight`).toBeGreaterThan(preflightStart) + expect(installIndex, `${install} should run before preflight completes`).toBeLessThan( + preflightReturn + ) } }) @@ -58,7 +67,7 @@ describe('host port bootstrap wiring', () => { // port is a window where an early path resolve either kills the process — which is what took // down every macOS `orca serve` — or caches the pre-override directory for the whole session. // Keeping the four statements adjacent is what makes that window zero rather than merely small. - const decide = source.indexOf('configureDevUserDataPath(is.dev)') + const decide = source.indexOf('configureDevUserDataPath(isDev)') const install = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') const capture = source.indexOf('initDataPath()') @@ -73,7 +82,7 @@ describe('host port bootstrap wiring', () => { .filter((line) => line.length > 0 && !line.startsWith('//')) expect(statements).toEqual([ - 'configureDevUserDataPath(is.dev)', + 'configureDevUserDataPath(isDev)', 'configureOrcaUserDataPathEnv()', 'setAppEnvironment(new ElectronAppEnvironment())' ]) @@ -82,12 +91,12 @@ describe('host port bootstrap wiring', () => { it('installs the ports at process level, not per window', () => { // Why: installing per window registered the PTY surfaces against no-ops on the // serve path, where no window ever opens. Caught in CI by the SSH docker E2E. - const openWindow = source.indexOf('function openMainWindow(') + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect( - source.indexOf(install, openWindow), - `${install} must not be re-installed per window` - ).toBe(-1) + expect(source.split(install).length - 1, `${install} should be owned by preflight`).toBe(1) } }) }) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts new file mode 100644 index 00000000000..4a47d2c2ec2 --- /dev/null +++ b/src/main/startup/main-process-account-services.ts @@ -0,0 +1,151 @@ +import { app } from 'electron' +import { RateLimitService } from '../rate-limits/service' +import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { CodexAccountService } from '../codex-accounts/service' +import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import { ClaudeAccountService } from '../claude-accounts/service' +import { KeybindingService } from '../keybindings/keybinding-service' +import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' +import { startCodexSessionIndexHealInBackground } from '../codex/codex-session-index-heal' +import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target' +import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target' +import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' +import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' +import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { browserManager } from '../browser/browser-manager' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAccountServices(): void { + const store = state.store + if (!store || !state.claudeUsage || !state.codexUsage || !state.openCodeUsage) { + throw new Error('Usage stores must be initialized before account services') + } + state.rateLimits = new RateLimitService() + state.codexRuntimeHome = new CodexRuntimeHomeService(store) + void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) + state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) + setSystemCodexHomeHookSweepSuppressed( + () => + state.codexRuntimeHome !== null && + state.codexRuntimeHome.isHostSystemDefaultRealHome() && + isAgentStatusHooksEnabled(state.store?.getSettings()) + ) + state.codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => + state.codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, + isQuitting: () => state.isQuitting, + resolveSystemCodexHomePathOverride: () => + resolveHostCodexSessionSourceHome(store.getSettings()), + prepareScheduledRun: (scanDates) => + state.codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), + finishScheduledRun: () => state.codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), + startBackfill: startCodexSessionBackfillInBackground, + startIndexHeal: startCodexSessionIndexHealInBackground + }) + state.codexAccounts = new CodexAccountService(store, state.rateLimits, state.codexRuntimeHome, { + onHostSystemDefaultSelected: state.codexSessionMigration.requestRun + }) + state.codexSessionMigration.scheduleInitialRun() + state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) + state.rateLimits.setCodexHomePathResolver((target) => + state.codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + state.rateLimits.setKimiHomeResolver(() => + resolveKimiHome(getKimiRuntimeTarget(store.getSettings())) + ) + state.rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( + state.rateLimits, + store.getSettings() + ) + store.onSettingsChanged((updates, settings) => { + void syncAccountRuntimeTargets(updates, settings).catch((error) => + console.warn('[rate-limits] Failed to apply account runtime target:', error) + ) + }) + state.rateLimits.setClaudeAuthPreparationResolver((target) => + state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) + // Live Claude sessions publish quota windows through their status-line hook; + // consume those snapshots before falling back to OAuth polling. + agentHookServer.setClaudeStatusLineListener((event) => { + state.rateLimits!.ingestLiveClaudeRateLimits(event) + }) + state.rateLimits.setOpenCodeGoConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: settings.opencodeSessionCookie, + workspaceIdOverride: settings.opencodeWorkspaceId + } + }) + state.rateLimits.setMiniMaxConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: readMiniMaxSessionCookie() ?? '', + groupId: settings.minimaxGroupId, + models: settings.minimaxUsageModels + } + }) + state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) + state.keybindings = new KeybindingService({ + homePath: app.getPath('home'), + getLegacyOverrides: () => store.getSettings().keybindings, + legacyTabSwitchSeed: { + isPending: () => store.getSettings().tabSwitchKeybindingSeed === 'pending', + markSeeded: () => store.updateSettings({ tabSwitchKeybindingSeed: 'done' }) + } + }) + browserManager.setSettingsResolver(() => ({ keybindings: state.keybindings?.getOverrides() })) + state.rateLimits.setInactiveClaudeAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.claudeManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) + }) + state.rateLimits.setInactiveCodexAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.codexManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + resolveHome: () => { + const resolved = + state.codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) + return resolved.kind === 'ready' + ? { kind: 'ready' as const, managedHomePath: resolved.homePath } + : { kind: 'skip' as const } + } + })) + }) +} diff --git a/src/main/startup/main-process-automations.ts b/src/main/startup/main-process-automations.ts new file mode 100644 index 00000000000..77092ed66b8 --- /dev/null +++ b/src/main/startup/main-process-automations.ts @@ -0,0 +1,99 @@ +import { AutomationService } from '../automations/service' +import { createHeadlessAutomationOutputSnapshotBuffer } from '../automations/headless-dispatch' +import { buildHeadlessAutomationWorktreeCreateArgs } from '../automations/headless-workspace-create' +import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAutomations(): AutomationService { + const store = state.store + const runtime = state.runtime + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + if (!store || !runtime || !claudeUsage || !codexUsage) { + throw new Error('Runtime and usage stores must be initialized before automations') + } + const service = new AutomationService(store, { + claudeUsage, + codexUsage, + terminalObserver: createRuntimeAutomationRunTerminalObserver(runtime), + onAutomationsChanged: (payload) => runtime.notifyAutomationsChanged(payload), + allowRemoteHostScheduling: state.isServeMode, + headlessDispatcher: state.isServeMode + ? async ({ automation, run, target }) => { + const terminalSnapshotLimit = 2_000 + let terminalHandle: string + let terminalSessionId: string | null = null + let terminalPaneKey: string | null = null + let terminalPtyId: string | null = null + let workspaceId: string + let workspaceDisplayName: string | null = null + if (automation.workspaceMode === 'new_per_run') { + const created = await runtime.createManagedWorktree( + buildHeadlessAutomationWorktreeCreateArgs({ automation, run, repo: target.repo }) + ) + terminalHandle = created.startupTerminal?.handle ?? '' + terminalSessionId = created.startupTerminal?.tabId ?? null + terminalPaneKey = created.startupTerminal?.paneKey ?? null + terminalPtyId = created.startupTerminal?.ptyId ?? null + workspaceId = created.worktree.id + workspaceDisplayName = created.worktree.displayName ?? null + if (!terminalHandle) { + throw new Error( + created.warning || + 'Automation workspace was created, but no agent terminal started.' + ) + } + } else { + if (!automation.workspaceId) { + throw new Error('The target workspace is no longer available.') + } + const terminal = await runtime.launchAgentTerminal(`id:${automation.workspaceId}`, { + agent: automation.agentId, + prompt: automation.prompt, + title: run.title + }) + terminalHandle = terminal.handle + terminalSessionId = terminal.tabId ?? null + terminalPaneKey = terminal.paneKey ?? null + terminalPtyId = terminal.ptyId ?? null + workspaceId = terminal.worktreeId + const worktree = await runtime.showManagedWorktree(`id:${workspaceId}`) + workspaceDisplayName = worktree.displayName ?? null + } + const completion = (async () => { + const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle' }) + const read = await runtime.readTerminal(terminalHandle, { + limit: terminalSnapshotLimit + }) + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + snapshotBuffer.append(read.tail.join('\n')) + if (wait.satisfied) { + return { + status: 'completed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: null + } + } + return { + status: 'dispatch_failed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } + })() + return { + workspaceId, + workspaceDisplayName, + terminalSessionId, + terminalPaneKey, + terminalPtyId, + completion + } + } + : undefined + }) + state.automations = service + runtime.setAutomationService(service) + return service +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts new file mode 100644 index 00000000000..b6e494c1ad0 --- /dev/null +++ b/src/main/startup/main-process-i18n-menu.ts @@ -0,0 +1,94 @@ +import { app, BrowserWindow } from 'electron' +import { ensureMainI18n, setMainUiLanguage } from '../i18n/main-i18n' +import { + registerAppMenu, + rebuildAppMenu, + getNextDefaultOnAppearanceSettingValue +} from '../menu/register-app-menu' +import { zoomDashboardPopoutIfFocused } from '../window/dashboard-popout-window' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { mainProcessState as state } from './main-process-state' +import { + openSettingsFromSystemMenu, + runUserInitiatedUpdateCheck, + sendOpenCrashReport, + sendOpenFeatureTour, + sendOpenSetupGuide +} from './main-window-actions' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeMainProcessI18nAndMenu(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before menu') + } + await ensureMainI18n() + await setMainUiLanguage(store.getSettings().uiLanguage) + logStartupMilestone('i18n-ready') + registerAppMenu({ + appMenuLabel: state.devInstanceIdentity?.name ?? app.name, + onCheckForUpdates: (options) => { + ensureAutoUpdaterConfigured() + runUserInitiatedUpdateCheck(options) + }, + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + state.expectedRendererReload.mark(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onOpenSettings: openSettingsFromSystemMenu, + onOpenSetupGuide: (targetWindow) => { + recordCrashBreadcrumb('setup_guide_opened') + sendOpenSetupGuide(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenCrashReport: (targetWindow) => { + recordCrashBreadcrumb('crash_report_opened') + sendOpenCrashReport(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenFeatureTour: (targetWindow) => { + recordCrashBreadcrumb('feature_tour_opened') + sendOpenFeatureTour(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onZoomIn: () => { + if (!zoomDashboardPopoutIfFocused('in')) { + state.mainWindow?.webContents.send('terminal:zoom', 'in') + } + }, + onZoomOut: () => { + if (!zoomDashboardPopoutIfFocused('out')) { + state.mainWindow?.webContents.send('terminal:zoom', 'out') + } + }, + onZoomReset: () => { + if (!zoomDashboardPopoutIfFocused('reset')) { + state.mainWindow?.webContents.send('terminal:zoom', 'reset') + } + }, + onToggleLeftSidebar: () => state.mainWindow?.webContents.send('ui:toggleLeftSidebar'), + onToggleRightSidebar: () => state.mainWindow?.webContents.send('ui:toggleRightSidebar'), + onToggleAppearance: (key) => { + if (key === 'statusBarVisible') { + state.mainWindow?.webContents.send('ui:toggleStatusBar') + return + } + const current = store.getSettings() + const next = getNextDefaultOnAppearanceSettingValue(current[key]) + store.updateSettings({ [key]: next }, { notifyListeners: true }) + rebuildAppMenu() + }, + getAppearanceState: () => { + const settings = store.getSettings() + const ui = store.getUI() + return { + showTasksButton: settings.showTasksButton !== false, + showAutomationsButton: settings.showAutomationsButton !== false, + showMobileButton: settings.showMobileButton !== false, + showTitlebarAppName: settings.showTitlebarAppName !== false, + statusBarVisible: ui.statusBarVisible !== false + } + }, + getKeybindings: () => state.keybindings?.getOverrides() + }) +} diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts new file mode 100644 index 00000000000..a948e4fb13f --- /dev/null +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -0,0 +1,47 @@ +import { ipcMain } from 'electron' +import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' + +export function registerMainProcessIpcHandlers(): void { + ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + }) + ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + await state.runtime?.prepareStructuredAgentSessionStartupRestoration() + }) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => + recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: state.firstWindowStartupServicesReady, + managedWslCliStartupBarrierReady: state.managedWslCliStartupBarrierReady, + localPtyProviderStartupReady: state.localPtyProviderStartupReady, + reconcile: async () => { + await state.runtime?.refreshRestoredOrchestrationAuthority() + return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) + }, + onDeferredRecoveryError: (error) => { + console.warn('[orchestration] legacy worker provider-ready recovery failed', error) + } + }) + ) + ipcMain.handle('ui:consumePendingOpenSettings', (event) => + state.pendingOpenSettings.matches(event.sender.id, { consume: true }) + ) + ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + ipcMain.handle( + 'app:startupDiagnostic', + (_event, event: string, details?: Record) => { + if (!state.startupDiagnosticsEnabled || !event.startsWith('renderer-')) { + return + } + logStartupMilestone(event, details && typeof details === 'object' ? details : {}) + } + ) +} diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts new file mode 100644 index 00000000000..0521f600605 --- /dev/null +++ b/src/main/startup/main-process-observers.ts @@ -0,0 +1,132 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { AgentAwakeService } from '../agent-awake-service' +import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' +import { registerSystemResumeBroadcast } from '../system-resume-broadcast' +import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' +import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { initTelemetry, track } from '../telemetry/client' +import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' +import { initObservability } from '../observability' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { recoverPendingSkillTransactions } from '../skills/skill-transaction-startup-recovery' +import { initCohortClassifier } from '../telemetry/cohort-classifier' +import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier' +import { StatsCollector } from '../stats/collector' +import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' +import { ClaudeUsageStore } from '../claude-usage/store' +import { CodexUsageStore } from '../codex-usage/store' +import { OpenCodeUsageStore } from '../opencode-usage/store' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessObservers(): void { + const store = state.store + const runtime = state.runtime + if (!store) { + throw new Error('Store must be initialized before observers') + } + state.unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() + state.agentAwakeService = new AgentAwakeService() + state.agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) + state.agentAwakeService.setStatuses([]) + const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { + const ownedIdentities = identities.map((identity) => ({ + ...identity, + worktreeId: + identity.worktreeId ?? + runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + undefined + })) + for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) + } + } + state.publishProviderSessionChanges = publishProviderSessionChanges + const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + state.agentAwakeService?.setStatuses(statuses) + }) + const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( + (sessions) => publishProviderSessionChanges(sessions) + ) + const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() + const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { + if (hookStatusChangedSessionTabs(enriched)) { + runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) + } + }) + const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { + const clearedPaneKeys = + 'paneKey' in clear + ? [clear.paneKey] + : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) + for (const paneKey of clearedPaneKeys) { + hookStatusChangedSessionTabs.forgetPane(paneKey) + runtime?.touchMobileSessionTabsForPane(paneKey) + } + }) + state.unsubscribeAgentAwakeStatusChanges = () => { + unsubscribeStatusChanges() + unsubscribeProviderSessionChanges() + unsubscribeHookStatusSessionTabs() + unsubscribeHookStatusClear() + } + initTelemetry(store) + if (state.hangDetection) { + track('main_thread_hang_detected', { + unresponsive_ms: Math.round(state.hangDetection.unresponsiveMs), + self_recovered: state.hangDetection.selfRecovered + }) + } + setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + lane, + ...(reason !== undefined ? { fallback_reason: reason } : {}), + ...(errorClass !== undefined ? { error_class: errorClass } : {}), + ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) + }) + }) + initObservability() + recordDurableCrashBreadcrumb('main_process_lifecycle_started', { + packaged: app.isPackaged, + platform: process.platform + }) + state.skillTransactionRecovery = recoverPendingSkillTransactions( + join(app.getPath('userData'), 'skill-installs') + ) + void state.skillTransactionRecovery + .then((report) => { + const result = report as { + scanned: number + recovered: number + failures: { code: string }[] + truncated: boolean + } + if (result.scanned || result.failures.length || result.truncated) { + console.info('[skills] startup transaction recovery:', { + scanned: result.scanned, + recovered: result.recovered, + failures: result.failures.map((failure) => failure.code), + truncated: result.truncated + }) + } + }) + .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) + initCohortClassifier(store) + initOnboardingCohortClassifier(store) + state.stats = new StatsCollector() + const agentSessionRecorder = new AgentSessionTransitionRecorder(state.stats) + agentHookServer.subscribeEnrichedStatus((enriched) => agentSessionRecorder.onStatus(enriched)) + agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear)) + state.claudeUsage = new ClaudeUsageStore(store) + state.codexUsage = new CodexUsageStore(store) + state.openCodeUsage = new OpenCodeUsageStore(store) +} diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts new file mode 100644 index 00000000000..5a71cc6f8b3 --- /dev/null +++ b/src/main/startup/main-process-plugins.ts @@ -0,0 +1,158 @@ +import { app, BrowserWindow } from 'electron' +import { performance } from 'node:perf_hooks' +import { PluginService } from '../plugins/plugin-service' +import { PluginKillListService } from '../plugins/plugin-kill-list-service' +import { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator' +import { getPluginsDataDir } from '../plugins/plugin-discovery' +import { resolveBundledPluginRoot } from '../plugins/plugin-bundled-bootstrap' +import { resolvePluginHostEntryPath } from '../plugins/plugin-host-process' +import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { setPluginServiceForRpc } from '../runtime/rpc/methods/plugins' +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' +import { rebuildAppMenu } from '../menu/register-app-menu' +import { logStartupMilestone } from './startup-diagnostics' +import { agentHookServer } from '../agent-hooks/server' +import { emitPluginWorktreeLifecycle } from './main-process-pty-startup' +import { mainProcessState as state } from './main-process-state' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): Promise { + const store = state.store + const keybindings = state.keybindings + if (!store || !keybindings) { + throw new Error('Store and keybindings must be initialized before plugins') + } + const pluginSystemStartupStartedAt = performance.now() + state.pluginKillListService = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')) + }) + await state.pluginKillListService.initialize() + state.pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null + }) + const requestOfficialMarketplaceSeed = (): void => { + if (store.getSettings().pluginSystemEnabled !== true) { + return + } + void state.pluginMarketplaceService + ?.seedOfficialSource() + .catch((error) => + console.warn('[plugins] failed to configure the official marketplace:', error) + ) + } + state.pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: state.pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null + }) + state.pluginService = new PluginService({ + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(state.store?.getSettings().devPluginPaths), + getKeybindings: () => state.keybindings?.getOverrides() ?? {}, + getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null, + hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) + }) + const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }), + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => state.pluginService?.refresh() ?? Promise.resolve() + }) + const requestBundledPluginBootstrap = (): void => { + void bundledPluginBootstrap + .request() + .then((result) => { + for (const failure of result?.errors ?? []) { + console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) + } + }) + .catch((error) => console.warn('[plugins] failed to bootstrap bundled plugins:', error)) + } + state.pluginKillListService.onChanged(() => { + void state.pluginService + ?.reconcileActivationState() + .catch((error) => + console.warn('[plugins] failed to apply plugin safety-list refresh:', error) + ) + }) + store.onSettingsChanged((updates) => { + if (updates.pluginSystemEnabled === true) { + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + } + if (app.isPackaged && updates.pluginSystemEnabled === true) { + void state.pluginKillListService + ?.refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + }) + setPluginServiceForRpc(state.pluginService, { + applyConsent: (request) => + applyPluginConsent({ store, pluginService: state.pluginService!, ...request }), + applyEnablement: (pluginKey, enabled) => + applyPluginEnablement({ store, pluginService: state.pluginService!, pluginKey, enabled }) + }) + void state.pluginService + .initialize() + .then(() => { + logStartupMilestone('plugin-system-initialized', { + durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), + installedPlugins: state.pluginService?.getDiscovered().length ?? 0 + }) + }) + .catch((error) => console.warn('[plugins] failed to initialize plugin service:', error)) + if (app.isPackaged && store.getSettings().pluginSystemEnabled === true) { + void state.pluginKillListService + .refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + state.pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(state.pluginService?.contentPacks.languagePacks.list() ?? []) + ) { + void setMainUiLanguage(store.getSettings().uiLanguage).then(() => rebuildAppMenu()) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:changed', event) + } + } + }) + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + agentHookServer.subscribeEnrichedStatus((enriched) => { + if (enriched.restoredUnconfirmed) { + return + } + state.pluginService?.emitEvent('agent.status.changed', { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt + }) + }) + runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts new file mode 100644 index 00000000000..a8a887bd896 --- /dev/null +++ b/src/main/startup/main-process-preflight.ts @@ -0,0 +1,323 @@ +import { app, ipcMain, powerMonitor, session } from 'electron' +import { is } from '@electron-toolkit/utils' +import os from 'node:os' +import { join } from 'node:path' +import { maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' +import { maybeRedirectPackagedCliEntryLaunch } from './packaged-cli-entry-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' +import { + configureDevUserDataPath, + configureElectronNetworkCompatibility, + configureOrcaUserDataPathEnv, + disableUnsupportedChromiumFeatures, + enableMainProcessGpuFeatures, + installDevParentDisconnectQuit, + installDevParentSignalQuit, + installDevParentWatchdog, + patchPackagedProcessPath +} from './configure-process' +import { installServeSupervisorDisconnectQuit } from '../serve-update-handoff' +import { + installUncaughtPipeErrorGuard, + installUnhandledRejectionLogging +} from './main-process-error-guards' +import { hydrateShellPath, mergePathSegments } from './hydrate-shell-path' +import { configureRemoteServerUpdater } from '../runtime/remote-server-updater' +import { + getRemoteServerUpdaterSnapshot, + checkForRemoteServerUpdate, + downloadRemoteServerUpdate, + installRemoteServerUpdate, + isQuittingForUpdate +} from '../updater' +import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' +import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' +import { startEventLoopStallProbe } from './event-loop-stall-probe' +import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe' +import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' +import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' +import { createServeDesktopActivationGate } from './serve-desktop-activation' +import { + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, + acquireSingleInstanceLock, + logSingleInstanceLockBypass, + logSingleInstanceLockFailure, + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE +} from './single-instance-lock' +import { setAppEnvironment } from '../../shared/app-environment' +import { ElectronAppEnvironment } from '../host/electron-app-environment' +import { setSecretStore } from '../../shared/secret-store' +import { ElectronSecretStore } from '../host/electron-secret-store' +import { setPtyHostBindings } from '../ipc/pty-host-bindings' +import { electronRuntimeDesktopSurface } from '../host/electron-runtime-desktop-surface' +import { setRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' +import { electronRuntimeBrowserCommandsFactory } from '../host/electron-browser-commands' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { electronHttpClient } from '../host/electron-http-client' +import { setMainHttpClient } from '../network/http-client' +import { electronSpeechServiceFactories } from '../host/electron-speech-services' +import { setSpeechServiceFactories } from '../speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' +import { desktopWorktreeWatcherRemoval } from '../ipc/filesystem-watcher' +import { setDefaultProxySessionResolver } from '../network/proxy-settings' +import { initDataPath, getCanonicalUserDataPath } from '../persistence' +import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' +import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' +import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initStatsPath } from '../stats/collector' +import { initClaudeUsagePath } from '../claude-usage/store' +import { initCodexUsagePath } from '../codex-usage/store' +import { initOpenCodeUsagePath } from '../opencode-usage/store' +import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol' +import { startCrashpadCapture } from '../crash-reporting/crashpad-capture' +import { CrashReportStore } from '../crash-reporting/crash-report-store' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' +import { optOutOfHiddenPageWakeUpThrottling } from './configure-process' +import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' +import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' +import { mainProcessState as state } from './main-process-state' +import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' + +export type MainProcessPreflightOptions = { + focusExistingWindow: () => void + requestDesktopActivation: (argv?: readonly string[]) => void +} + +/** Performs all module-scope work that must happen before Electron's ready event. */ +export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. + // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. + // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its + // command-name lookup finds a port number and strands the launch in an in-process serve. The + // packaged-CLI one matches on the entry path instead, so order cannot affect it either way. + const packagedRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (packagedRedirect.redirected) { + app.exit(packagedRedirect.status) + } + const appImageRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (appImageRedirect.redirected) { + app.exit(appImageRedirect.status) + } + // Why: extracted AppRun / binary launches can land CLI-form `serve` args on the + // Electron process without the CLI rewrite that injects `--serve` (#12677). + // Guarded so a normal GUI launch keeps its original argv array identity. + if (argvRequestsServeMode(process.argv)) { + process.argv = normalizeServeModeArgv(process.argv) + } + state.isServeMode = process.argv.includes('--serve') + if (state.isServeMode) { + reserveServeStdoutForReadiness() + } + state.devInstanceIdentity = getDevInstanceIdentity(is.dev) + state.devAgentHookEndpointNamespace = state.devInstanceIdentity.isDev + ? state.devInstanceIdentity.appUserModelId + : undefined + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: state.isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + options.focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) + }) + installUncaughtPipeErrorGuard() + // Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). + installUnhandledRejectionLogging() + // Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. + process.env.ORCA_APP_VERSION = app.getVersion() + configureRemoteServerUpdater({ + getSnapshot: getRemoteServerUpdaterSnapshot, + check: checkForRemoteServerUpdate, + download: downloadRemoteServerUpdate, + install: installRemoteServerUpdate + }) + patchPackagedProcessPath() + // Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. + if (app.isPackaged && process.platform !== 'win32') { + void hydrateShellPath().then((result) => { + if (result.ok) { + mergePathSegments(result.segments) + } else { + // Why: on failure the seeded fallbacks stay in front. For an nvm user that is + // now their `default` version rather than the newest install, so it is usually + // survivable — but it is still not what their shell would have resolved. Name + // the reason so it shows up in a log bundle instead of as a missing CLI. + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) + } + }) + } + const isDev = is.dev + configureDevUserDataPath(isDev) + configureOrcaUserDataPathEnv() + // Why these four lines are one step (#16761): the two above decide where userData lives, and + // everything below may resolve a path. Installing the accessor any later leaves a window where an + // early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the + // pre-override directory and silently writes user state to the wrong place for the whole session. + // Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it + // changes no timing, and initDataPath only joins strings. + setAppEnvironment(new ElectronAppEnvironment()) + // Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) + // changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. + initDataPath() + state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('before-single-instance-lock', { + version: app.getVersion(), + packaged: app.isPackaged, + platform: process.platform, + osRelease: os.release(), + userData: app.getPath('userData'), + e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + }) + startEventLoopStallProbe() + } + // Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). + // Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache + // look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. + startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) + // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. + // Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). + const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + if (bypass) { + // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. + logSingleInstanceLockBypass() + } + const hasLock = skip || bypass || acquireSingleInstanceLock(app, options.requestDesktopActivation) + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('single-instance-lock-result', { + acquired: hasLock, + bypassed: bypass, + skippedForDev: skip + }) + } + if (!hasLock) { + // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. + logSingleInstanceLockFailure() + // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). + app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + return false + } + // Why first in this block: the accessor throws until installed and everything below may read a + // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so + // installing here changes no timing, in particular not the pre-ready Keychain service-name + // resolution. The app-environment port and the userData capture install earlier still, next to + // the path decision they depend on. + setSecretStore(new ElectronSecretStore()) + // Why at process level, not per-window: pty.ts registers against injected surfaces so + // it can load without electron, and an Electron main process always has ipcMain — + // whether a window exists is irrelevant. Installing this in attachMainWindowServices + // meant `orca serve` registered its PTY handlers against no-ops before any window + // attached, so a paired desktop owner never received them. + setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) + // Why also at process level: the runtime's notification, window-lookup and + // tab-create-reply channel are desktop-only. A Node host installs none and the + // runtime routes notifications to paired clients instead. + setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser + // cluster into the graph. The desktop installs it; a Node host installs none and every + // browser RPC rejects, which capability filtering already tells clients about. + setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) + // Why here: proxy-settings only needed electron for `session.defaultSession`. The + // desktop supplies it; a Node host has no Chromium proxy config to consult, so the + // environment variables are the whole answer there. + setDefaultProxySessionResolver(() => session.defaultSession) + // Why here: integrations use Chromium's network stack on the desktop. A Node host + // falls back to the platform default, which is a real behavioural difference (proxy + // read from the environment, Node's user agent) rather than a transparent swap. + setMainHttpClient(electronHttpClient) + // Why here: constructing the speech services is what pulls Electron's streaming net + // request in. A host without them rejects speech calls rather than pretending. + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) + // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. + const shouldCoupleToDevParent = isDev && !state.isServeMode + installDevParentDisconnectQuit(shouldCoupleToDevParent) + installDevParentWatchdog(shouldCoupleToDevParent) + installDevParentSignalQuit(shouldCoupleToDevParent) + // Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff + // path, so it throws until setAppEnvironment() above installs the accessor — which killed every + // `orca serve` process before it could listen. After initDataPath() specifically, so the + // path-equality check against the CLI's env var uses the dir captured before app.setName(). + // Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module + // finishes evaluating, so moving this behind an await would open a real orphan window. + installServeSupervisorDisconnectQuit(state.isServeMode) + // Why here: initDataPath above gives the canonical userData path for the record file; the write + // itself lands for the next launch (see macos-press-and-hold-default.ts). + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) + // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) + initOrcaProfilePaths() + // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. + initStatsPath() + initClaudeUsagePath() + initCodexUsagePath() + initOpenCodeUsagePath() + // Why: Electron resolves the macOS safeStorage Keychain service name + // (" Safe Storage") before `ready`, so the setName in whenReady is + // too late to move it — dev otherwise lands on the package.json name. Dev-only + // so a packaged build keeps deriving the key from its own CFBundleName. + // Safe here: dev always pins userData via app.setPath (configure-process.ts), + // so setName cannot shift the paths captured just above. + if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { + app.setName(state.devInstanceIdentity.appName) + } + // Why: Electron freezes the privileged scheme table at ready, so the doc-preview + // scheme must be declared here or its webview loses fetch/secure-origin privileges. + registerDocPreviewSchemePrivileges() + // Why: must precede app.whenReady() so Crashpad is installed before the + // first renderer spawns; a CHECK before this point is still exit-code-only. + startCrashpadCapture() + state.crashReports = CrashReportStore.fromUserData() + state.gpuCrashDiagnostics = + process.platform === 'win32' + ? new GpuCrashDiagnosticsRecorder({ + provider: { + getGPUInfo: (infoType) => app.getGPUInfo(infoType), + getGPUFeatureStatus: () => app.getGPUFeatureStatus() + }, + recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) + }) + : null + recordCrashBreadcrumb('app_started', { + packaged: app.isPackaged, + platform: process.platform, + ...getMainProcessLifecycleIdentity() + }) + disableUnsupportedChromiumFeatures() + // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. + optOutOfHiddenPageWakeUpThrottling() + configureElectronNetworkCompatibility() + enableRendererHeapHeadroom() + maybeApplyGpuFallbackForThisLaunch() + if (!state.gpuFallbackActiveThisLaunch) { + enableMainProcessGpuFeatures() + } + // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. + state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ + isServeMode: state.isServeMode + }) + initializeSyntheticTitleRuntime() + registerGpuLifecycleHandlers() + return true +} diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts new file mode 100644 index 00000000000..e847646da51 --- /dev/null +++ b/src/main/startup/main-process-pty-startup.ts @@ -0,0 +1,195 @@ +import { app } from 'electron' +import { classifyError } from '../telemetry/classify-error' +import { track } from '../telemetry/client' +import { getPtyIdForPaneKey } from '../ipc/pty' +import { + getDaemonProvider, + initDaemonPtyProvider, + listLiveDaemonPtyIds +} from '../daemon/daemon-init' +import { + getCodexPaneAccount, + hasAnyRecordedLegacyWslCodexPane, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, + reconcileCodexPaneAccountsWithLivePtys, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { reconcileRetainedCodexHookHomes } from '../codex/retained-codex-hook-state' +import { codexHookService } from '../codex/hook-service' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { + indexPersistedPaneKeyPtyIds, + isLocalExecutionHost, + resolveAgentWorkspaceExecutionHostId, + sweepRestoredSubagentsWithoutLiveAgent +} from '../agent-hooks/restored-subagent-liveness-sweep' +import { startFirstWindowStartupServices } from './first-window-startup-services' +import { logStartupMilestone } from './startup-diagnostics' +import type { WindowsDesktopStartupServices } from './windows-desktop-shell-path-startup' +import type { RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' +import { mainProcessState as state } from './main-process-state' + +export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + state.pluginService?.emitEvent( + event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' + ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } + : { worktreeId: event.worktreeId, path: event.path } + ) +} + +export function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (state.codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + state.codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } + const fullScanRequired = + state.codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + state.codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +export function handlePtyExit(id: string, exitSequence: number): void { + state.codexSessionMigration?.finishLaunch(id, exitSequence) +} + +export async function reapRestoredSubagentsWithoutLiveAgent(): Promise { + const store = state.store + if (!store) { + return + } + const provider = getDaemonProvider() + if (!provider) { + return + } + const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( + store.getWorkspaceSession().terminalLayoutsByTabId ?? {} + ) + await sweepRestoredSubagentsWithoutLiveAgent({ + probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), + isLocalExecutionHost: (worktreeId) => + isLocalExecutionHost( + resolveAgentWorkspaceExecutionHostId(worktreeId, { + getRepo: (repoId) => store.getRepo(repoId), + getWorktreeMeta: (resolvedWorktreeId) => store.getWorktreeMeta(resolvedWorktreeId), + getFolderWorkspace: (folderWorkspaceId) => store.getFolderWorkspace(folderWorkspaceId), + getProjectGroups: () => store.getProjectGroups() + }) + ), + getBoundPtyIdForPaneKey: getPtyIdForPaneKey, + getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), + reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => + agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalHost, + isLocalPaneAgentLive, + isLocalPaneLivenessEvidenceCurrent + ) + }) +} + +export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { + logStartupMilestone('first-window-startup-services-start') + const startupServices = startFirstWindowStartupServices({ + // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. + startDaemonPtyProvider: async (signal) => { + logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) + await initDaemonPtyProvider(signal, { + macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode + }) + const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() + if ( + state.codexRuntimeHome && + (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane()) + ) { + const livePtyIds = await listLiveDaemonPtyIds() + if (livePtyIds) { + reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = state.store?.getSettings() + if (hasRetainedManagedHostPane) { + void reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: state.codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }).catch((error) => + console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) + ) + } + } + } + state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() + logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) + }, + startAgentHookServer: async () => { + const settings = state.store?.getSettings() + if (!isAgentStatusHooksEnabled(settings)) { + return + } + logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) + agentHookServer.setTransportInterferenceListener((report) => { + track('agent_hook_transport_blocked', { count: report.count }) + }) + await agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + userDataPath: app.getPath('userData'), + endpointNamespace: state.devAgentHookEndpointNamespace + }) + logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) + }, + onDaemonError: (error) => { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` + ) + track('daemon_start_failed', classifyError(error)) + }, + onAgentHookServerError: (error) => { + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + void startupServices.firstWindowReady.then(() => + logStartupMilestone('first-window-startup-services-ready') + ) + void startupServices.localPtyReady.then(() => { + logStartupMilestone('local-pty-startup-ready') + void reapRestoredSubagentsWithoutLiveAgent().catch((error) => + console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + ) + }) + return startupServices +} + +export function bindTerminalRuntimeStartupServices( + services: Promise +): void { + state.firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) + state.localPtyStartupReady = services.then((value) => value.localPtyReady) + state.localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) +} diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts new file mode 100644 index 00000000000..4505e51ba93 --- /dev/null +++ b/src/main/startup/main-process-quit.ts @@ -0,0 +1,217 @@ +import { app, type Event } from 'electron' +import { closeAllWatchers } from '../ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' +import { stopFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { killAllPty } from '../ipc/pty' +import { disconnectDaemon, shutdownDaemon } from '../daemon/daemon-init' +import { beginSshShutdown } from '../ipc/ssh-shutdown-drain' +import { agentHookServer } from '../agent-hooks/server' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { removeManagedAgentHooksAsync } from '../agent-hooks/managed-agent-hook-controls' +import { stopStructuredAgentSessionRuntime } from '../runtime/structured-agent-session-runtime' +import { awaitRuntimeFileWatcherUnsubscribes } from '../runtime/orca-runtime-files' +import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' +import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' +import { browserManager } from '../browser/browser-manager' +import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' +import { quitTeardownStartGate } from '../quit-teardown-start-gate' +import { setUnreadDockBadgeCount } from '../dock/unread-badge' +import { destroySystemTray } from '../tray/system-tray' +import { shutdownTelemetry } from '../telemetry/client' +import { shutdownObservability } from '../observability' +import { isQuittingForUpdate } from '../updater' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { stopTccPromptNotice } from '../macos-tcc-prompt-notice' +import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' +import { mainProcessState as state } from './main-process-state' +import { isDevParentShutdownRequested } from './configure-process' +import { getCanonicalUserDataPath } from '../persistence' + +let daemonDisconnectDone = false +let watcherShutdownPromise: Promise | null = null +const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 + +function shutdownWatchersOnce(): Promise { + if (state.watcherShutdownDone) { + return Promise.resolve() + } + if (!watcherShutdownPromise) { + stopFolderRepoGitUpgradeWatch() + watcherShutdownPromise = Promise.allSettled([ + closeAllWatchers(), + disposeWorktreeBaseDirectoryWatchers() + ]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') { + console.error('[filesystem-watcher] shutdown failed:', result.reason) + } + } + }) + .then(() => { + state.watcherShutdownDone = true + }) + } + return watcherShutdownPromise +} + +function installBeforeQuitHandler(): void { + app.on('before-quit', () => { + if (isQuittingForUpdate()) { + recordUpdaterLifecycle('before_quit_allowed', undefined, { + message: 'before-quit allowed for update install' + }) + } + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + state.runtimeRpc?.setMobileRelayPairingProvider(null) + state.unsubscribeAgentAwakeStatusChanges?.() + state.unsubscribeAgentAwakeStatusChanges = null + state.agentAwakeService?.dispose() + state.agentAwakeService = null + state.rateLimits?.stop() + }) +} + +function installWillQuitHandler(): void { + app.on('will-quit', (event: Event) => { + if (daemonDisconnectDone) { + return + } + if (!quitTeardownStartGate.tryStart(event)) { + return + } + state.unsubscribeSystemResumeBroadcast?.() + state.unsubscribeSystemResumeBroadcast = null + stopTccPromptNotice() + const updateQuitInProgress = isQuittingForUpdate() + if (updateQuitInProgress) { + recordUpdaterLifecycle( + 'will_quit_cleanup_started', + { daemonTeardown: 'disconnect' }, + { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } + ) + } + destroySystemTray() + state.starNag?.stop() + state.automations?.stop() + state.pluginKillListService = null + state.pluginMarketplaceService = null + state.pluginMarketplaceInstaller = null + const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() + const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() + state.pluginService = null + setUnreadDockBadgeCount(0) + agentHookServer.stop() + const grokHookCleanup = + process.platform === 'win32' + ? settleWithinMs( + removeManagedAgentHooksAsync({ agents: ['grok'] }), + GROK_HOOK_CLEANUP_DEADLINE_MS + ).then((settled) => { + if (settled.outcome === 'timed-out') { + console.warn('[agent-hooks] Grok hook cleanup on quit timed out') + return + } + if (settled.outcome === 'failed') { + console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) + return + } + for (const status of settled.value.filter((entry) => entry.detail)) { + console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) + } + }) + : Promise.resolve() + wslHookRelayManager.disposeAll() + const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() + const browserShutdown = (async (): Promise => { + await state.runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + await state.runtime?.getAgentBrowserBridge()?.destroyAllSessions() + })() + const localSshRouteShutdown = import('../browser/local-ssh-browser-route') + .then((routes) => routes.closeAllLocalSshBrowserRoutes()) + .catch(() => {}) + browserManager.setBrowserGuestStateChangedListener(null) + const emulatorShutdown = + state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() + const sshShutdown = beginSshShutdown() + killAllPty() + const watcherShutdown = shutdownWatchersOnce() + const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + const usageCacheFlush = Promise.all([ + state.claudeUsage?.flush(), + state.codexUsage?.flush(), + state.openCodeUsage?.flush() + ]).then(() => {}) + const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() + const skillUploadShutdown = state.runtime?.disposeSkillUploadSessions() ?? Promise.resolve() + const ownedPid = process.pid + const ownedRuntimeId = state.runtime?.getRuntimeId() + const rpcStopAndClear = state.runtimeRpc + ? state.runtimeRpc + .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) + .then(() => { + if (ownedRuntimeId) { + clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) + } + }) + .catch((error) => console.error('[runtime] Failed to stop local RPC transport:', error)) + : Promise.resolve() + const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + settleTeardownWithinDeadline([ + { name: 'daemon', promise: daemonTeardown }, + { name: 'browser', promise: browserShutdown }, + { name: 'runtime-rpc', promise: rpcStopAndClear }, + { name: 'watchers', promise: watcherShutdown }, + { name: 'emulator', promise: emulatorShutdown }, + { name: 'browser-client-hosts', promise: browserClientHostShutdown }, + { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, + { name: 'ssh', promise: sshShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown }, + { name: 'skill-uploads', promise: skillUploadShutdown }, + { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, + { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, + { name: 'usage-cache', promise: usageCacheFlush }, + { name: 'stats', promise: statsFlush }, + { name: 'state', promise: storeFlush } + ]) + .then((pendingTeardowns) => { + if (pendingTeardowns.length > 0) { + console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) + } + }) + .then(() => shutdownTelemetry()) + .then(() => shutdownObservability()) + .catch(() => {}) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) + }) +} + +function installWindowAllClosedHandler(): void { + app.on('window-all-closed', () => { + if ( + shouldQuitWhenAllWindowsClosed({ + platform: process.platform, + isQuitting: state.isQuitting, + isServeMode: state.isServeMode + }) + ) { + app.quit() + } + }) +} + +/** Installs the process-level shutdown listeners once during bootstrap. */ +export function installMainProcessQuitHandlers(): void { + process.once('exit', stopTccPromptNotice) + installBeforeQuitHandler() + installWillQuitHandler() + installWindowAllClosedHandler() +} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts new file mode 100644 index 00000000000..ec7a48753a5 --- /dev/null +++ b/src/main/startup/main-process-ready-foundation.ts @@ -0,0 +1,235 @@ +import { app, session } from 'electron' +import { electronApp, is } from '@electron-toolkit/utils' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { applyElectronProxySettings } from '../network/proxy-settings' +import { installElectronProxyRequestGuard } from '../network/electron-proxy-request-guard' +import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' +import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' +import { + consumeHangDetectionMarker, + hangDetectionMarkerPath +} from '../hang-watchdog/hang-detection-marker' +import { browserCertificateTrustController } from '../browser/browser-manager' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { Store, getCanonicalUserDataPath } from '../persistence' +import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' +import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { neutralizeLegacyTerminalShimDir } from '../pty/legacy-terminal-shim-dir' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import { + configureWindowsHostGitEnvironmentReadiness, + setDefaultWslDistroOverride +} from '../git/runner' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { + attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from '../claude-accounts/live-pty-gate' +import { applyAppIcon } from '../app-icon' +import { + shouldSuppressDevEducation, + suppressDevEducationForStore +} from './dev-education-suppression' +import { setBrowserNetworkProxySettingsResolver } from '../browser/browser-session-proxy' +import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' +import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' +import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' +import { applyBrowserSessionProxies } from '../browser/browser-session-proxy' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { syncMacMenuBarIcon } from './main-window-actions' +import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' +import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' +import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' + +export async function initializeReadyFoundation(): Promise { + logStartupMilestone('app-ready') + applyBackgroundActivationPolicy({ warn: console.warn }) + installElectronProxyRequestGuard(session.defaultSession) + app.on('login', (event, webContents, details, authInfo, callback) => { + handleElectronProxyLogin( + event, + webContents, + details, + authInfo, + callback, + session.defaultSession + ) + }) + const canonicalUserDataPath = getCanonicalUserDataPath() + installMainThreadHangWatchdog({ userDataPath: canonicalUserDataPath }) + state.hangDetection = consumeHangDetectionMarker(hangDetectionMarkerPath(canonicalUserDataPath)) + if (state.hangDetection) { + recordDurableCrashBreadcrumb('main_thread_hang_detected', { + unresponsiveMs: state.hangDetection.unresponsiveMs, + previousPid: state.hangDetection.parentPid, + selfRecovered: state.hangDetection.selfRecovered + }) + } + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) + const identity = state.devInstanceIdentity + if (!identity) { + throw new Error('Development identity is unavailable') + } + electronApp.setAppUserModelId(identity.appUserModelId) + app.setName(identity.appName) + updateGpuAccelerationAboutPanel() + state.managedWslCliReconciliationStatus = 'pending' + state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: canonicalUserDataPath, + appVersion: app.getVersion() + }) + .then((results) => { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } + state.managedWslCliReconciliationStatus = 'settled' + }) + .catch((error) => { + state.managedWslCliReconciliationStatus = 'failed' + console.warn( + '[wsl-cli] Managed registration reconciliation discovery failed:', + error instanceof Error ? error.message : String(error) + ) + }) + state.managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( + state.managedWslCliReconciliationReady + ) + const profile = ensureActiveOrcaProfile() + state.activeOrcaProfile = profile + initializeBrowserClientHostId(profile.profileDirectory) + const store = new Store({ + dataFile: profile.dataFile, + storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + }) + state.store = store + const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + installElectronProxyRequestGuard(session.defaultSession) + scheduleSecretProtectionGapReport({ + dataFile: profile.dataFile, + force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', + deferUntilFirstWindow: !state.isServeMode + }) + initSshHostKeyStoreFile(profile.dataFile) + neutralizeLegacyTerminalShimDir(app.getPath('userData')) + const windowsShellPathHydration = createWindowsShellPathHydration() + state.windowsShellPathHydration = windowsShellPathHydration + configureWindowsHostGitEnvironmentReadiness( + process.platform === 'win32' ? windowsShellPathHydration.whenReady : null + ) + if (process.platform === 'win32') { + const settings = store.getSettings() + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + wslHookRelayManager.setManagedHookSettingsResolver(() => state.store?.getSettings() ?? null) + logStartupMilestone('store-loaded') + setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) + store.onSettingsChanged((updates, settings) => { + if ('terminalWindowsWslDistro' in updates) { + setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) + } + if ( + ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && + process.platform === 'win32' + ) { + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + if ('showMenuBarIcon' in updates) { + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + if ('agentStatusHooksEnabled' in updates) { + if (isAgentStatusHooksEnabled(settings)) { + wslHookRelayManager.resumeStoppedRelays() + } else { + wslHookRelayManager.disposeAll({ permanent: false }) + } + } + }) + attachClaudeLivePtyPersistence(store) + onLiveClaudePtysDrained(() => { + void state.rateLimits?.refreshAfterClaudeLivePtysDrained() + }) + const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() + seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + if (persistedClaudePtyIds.length > 0) { + console.log( + `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` + ) + } + applyAppIcon(store.getSettings().appIcon) + if (shouldSuppressDevEducation({ isDev: is.dev })) { + suppressDevEducationForStore(store) + } + try { + const proxyApplyResult = await initialProxyApplication + if (proxyApplyResult.source === 'invalid-settings') { + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + } catch { + console.warn('[proxy] Failed to apply network proxy settings') + } + setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) + installDocPreviewProtocolHandler() + registerDocPreviewGrantHandlers() + initializeBrowserSessionsForApp({ + orcaProfileId: profile.profile.id, + profileDirectory: profile.profileDirectory, + listLocalSshTargetIds: () => { + const currentStore = state.store + if (!currentStore) { + throw new Error('ssh target store unavailable at partition sweep') + } + return currentStore.getSshTargets().map((target) => target.id) + } + }) + try { + await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) + } catch { + console.warn('[proxy] Failed to apply network proxy settings to browser sessions') + } +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts new file mode 100644 index 00000000000..c2e92607f2e --- /dev/null +++ b/src/main/startup/main-process-ready-runtime.ts @@ -0,0 +1,134 @@ +import { app, nativeTheme } from 'electron' +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { is } from '@electron-toolkit/utils' +import { StarNagService } from '../star-nag/service' +import { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { EmulatorBridge } from '../emulator/emulator-bridge' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { browserManager } from '../browser/browser-manager' +import { configureBrowserClientPageAutomationRuntime } from '../browser/browser-client-page-automation-runtime' +import { BrowserClientPageCommandError } from '../browser/browser-client-page-command-failure' +import { startPreGoneProcessMetricsSampling } from '../crash-reporting/process-gone-diagnostics' +import { recordProcessGoneCrash } from './main-window-lifecycle-flags' +import { handleGpuChildCrash } from './gpu-lifecycle' +import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup, + shouldInstallStartupManagedAgentHook +} from '../agent-hooks/managed-agent-hook-controls' +import { shouldInstallManagedHooks } from './configure-process' +import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' +import { mainProcessState as state } from './main-process-state' +import { initializeMainProcessObservers } from './main-process-observers' +import { initializeMainProcessAccountServices } from './main-process-account-services' +import { + initializeMainProcessRuntime, + configureRuntimeServices +} from './main-process-runtime-service' +import { initializeMainProcessAutomations } from './main-process-automations' +import { initializeMainProcessPlugins } from './main-process-plugins' +import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeReadyRuntimeServices(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before ready services') + } + initializeMainProcessObservers() + initializeMainProcessAccountServices() + const runtime = initializeMainProcessRuntime() + initializeMainProcessAutomations() + configureRuntimeServices(runtime) + await initializeMainProcessPlugins(runtime) + state.starNag = new StarNagService(store, state.stats!) + state.starNag.start() + state.starNag.registerIpcHandlers() + state.agentBrowserBridge = new AgentBrowserBridge(browserManager, { + onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + runtime.setAgentBrowserBridge(state.agentBrowserBridge) + void state.agentBrowserBridge.sweepOrphanedSessions() + const browserClientAutomationDispatcher = new RpcDispatcher({ runtime }) + configureBrowserClientPageAutomationRuntime({ + browserManager, + getAgentBrowserBridge: () => state.agentBrowserBridge, + executeRpc: async (method, params, signal) => { + const response = await browserClientAutomationDispatcher.dispatch( + { id: randomUUID(), authToken: 'local-browser-client-automation', method, params }, + { signal } + ) + if (!response.ok) { + throw new BrowserClientPageCommandError(response.error.code) + } + return response.result + } + }) + state.emulatorBridge = new EmulatorBridge() + runtime.setEmulatorBridge(state.emulatorBridge) + // Remove directories left behind by an interrupted worktree deletion. + void sweepStaleWorktreeTrash( + collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) + ).catch((error) => { + console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) + }) + nativeTheme.themeSource = store.getSettings().theme ?? 'system' + const startupManagedHookSettings = store.getSettings() + const shouldReconcileStartupManagedHooks = + shouldInstallManagedHooks(is.dev) && + resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' + const realHomeCodexHookState = + shouldReconcileStartupManagedHooks && + shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && + state.codexRuntimeHome?.isHostSystemDefaultRealHomeSelected() + ? ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: app.getPath('userData') + }).catch((error: unknown) => { + console.warn('[codex-real-home-hooks] startup ensure failed:', error) + }) + : Promise.resolve() + if (shouldReconcileStartupManagedHooks) { + const managedHookStore = store + void realHomeCodexHookState + .then(() => + installManagedAgentHooks(managedHookStore.getSettings(), { + shouldHydrateShellPath: app.isPackaged, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup( + state.isQuitting, + managedHookStore.getSettings(), + agent + ) + }) + ) + .catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) + } + startPreGoneProcessMetricsSampling() + app.on('child-process-gone', (_event, details) => { + recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { + name: details.name, + serviceName: details.serviceName, + type: details.type + }) + if ( + isGpuFallbackCrashCandidate({ + platform: process.platform, + processType: details.type, + reason: details.reason + }) + ) { + const crashedAt = performance.now() + void state.gpuCrashDiagnostics?.record() + void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) + } + }) + logStartupMilestone('services-initialized') +} diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts new file mode 100644 index 00000000000..e6d8d782e6e --- /dev/null +++ b/src/main/startup/main-process-ready.ts @@ -0,0 +1,17 @@ +import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { initializeReadyFoundation } from './main-process-ready-foundation' +import { initializeReadyRuntimeServices } from './main-process-ready-runtime' +import { + initializeMainProcessRuntimeLaunch, + type MainProcessRuntimeLaunchOptions +} from './main-process-runtime-launch' + +/** Runs the ready-phase composition in the same dependency order as the legacy entry point. */ +export async function initializeMainProcessReady( + options: MainProcessRuntimeLaunchOptions +): Promise { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + await initializeMainProcessI18nAndMenu() + await initializeMainProcessRuntimeLaunch(options) +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts new file mode 100644 index 00000000000..64160f3d65c --- /dev/null +++ b/src/main/startup/main-process-runtime-launch.ts @@ -0,0 +1,283 @@ +import { app, powerMonitor, type BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from '../persistence' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { registerMobileHandlers } from '../ipc/mobile' +import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' +import { LocalPtyProvider } from '../providers/local-pty-provider' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' +import { browserManager } from '../browser/browser-manager' +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve' +import { + bindTerminalRuntimeStartupServices, + handleCodexHomePtySpawned, + handlePtyExit, + startTerminalRuntimeStartupServices +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' +import { registerServeSignalHandlers } from './serve-signal-handlers' +import { settleServeDesktopActivation } from './serve-desktop-activation' +import { + recordRuntimeRpcStartFailure, + showRuntimeRpcStartupFailureDialog +} from '../runtime/runtime-rpc-startup-failure' +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' +import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' +import { mainProcessState as state } from './main-process-state' +import { logStartupMilestone } from './startup-diagnostics' + +type RuntimeService = NonNullable + +export type MainProcessRuntimeLaunchOptions = { + openMainWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow + handleMacAppActivation: () => void +} + +function settleDesktopActivation(): void { + const gate = state.desktopActivationGate + if (!gate) { + return + } + settleServeDesktopActivation(gate, { + hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) + }) +} + +function installRuntimeRpc( + runtime: RuntimeService, + serveOptions: ReturnType | null +): OrcaRuntimeRpcServer { + migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT + const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) + if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { + throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) + } + const devWsPort = is.dev && !isE2E ? 6769 : undefined + const runtimeRpc = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: getCanonicalUserDataPath(), + enableWebSocket: true, + exposeNetworkByDefault: Boolean(serveOptions) || isE2E, + ...(isE2E ? { wsPort: e2eWsPort } : {}), + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { wsPort: serveOptions.wsPort, preferPinnedWsPort: true } + : {}), + webClientRoot: getBundledWebClientRoot() + }) + state.runtimeRpc = runtimeRpc + registerMobileHandlers(runtimeRpc, { + getRelayStatus: () => state.desktopRelayStatus, + consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { + if ( + !state.mainWindow || + state.mainWindow.isDestroyed() || + state.mainWindow.webContents.id !== webContentsId || + !state.pendingUnpairedDeviceAuthFailure + ) { + return false + } + state.pendingUnpairedDeviceAuthFailure = false + return true + } + }) + runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { + state.pendingUnpairedDeviceAuthFailure = true + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') + } + }) + return runtimeRpc +} + +async function launchServeMode( + runtime: RuntimeService, + runtimeRpc: OrcaRuntimeRpcServer, + serveOptions: NonNullable> +): Promise { + logStartupMilestone('wsl-cli-barrier-start') + await state.managedWslCliStartupBarrierReady + logStartupMilestone('wsl-cli-barrier-resolved', { + reconciliation: state.managedWslCliReconciliationStatus + }) + await state.localPtyStartupReady + await state.localPtyProviderStartupReady + await registerHeadlessPtyRuntime( + runtime, + prepareCodexRuntimeHomeForLaunch, + () => state.store!.getSettings(), + (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + state.store!, + prepareCodexSessionResumeForLaunch, + { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } + ) + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() + if (state.headlessBrowserDisplayAvailable) { + runtime.setOffscreenBrowserBackend( + new OffscreenBrowserBackend(browserManager, { + getAgentBrowserBridge: () => state.agentBrowserBridge + }) + ) + } + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + await runtimeRpc.start().catch((error) => { + console.error('[runtime] Failed to start headless RPC transport:', error) + throw error + }) + settleDesktopActivation() + registerServeSignalHandlers(process, () => app.quit()) + if (process.platform === 'darwin' || process.platform === 'linux') { + try { + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + } + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + state.automations?.start() + scheduleAllPendingHistoryTreeRemovals() + await printServeReady(serveOptions) +} + +async function launchDesktopMode( + runtimeRpc: OrcaRuntimeRpcServer, + shellPathReady: Promise, + desktopWindow: BrowserWindow | null, + openMainWindow: MainProcessRuntimeLaunchOptions['openMainWindow'] +): Promise { + // Preserve the pre-split startup failure contract if composition ever hands + // this phase an incomplete runtime graph. + if (!runtimeRpc) { + throw new Error('runtime_rpc_unavailable') + } + const [win, runtimeRpcStartResult] = await Promise.all([ + Promise.resolve(desktopWindow ?? openMainWindow()), + shellPathReady + .then(() => runtimeRpc.start()) + .then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) + ]) + if (!runtimeRpcStartResult.ok) { + void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + } + const cloudAuth = getOrcaCloudAuthConfig() + if (cloudAuth.configured) { + try { + const relayService = new DesktopRelayService({ + authConfig: cloudAuth.config, + userDataPath: getProfileUserDataPath(), + appVersion: app.getVersion(), + runtimeRpc, + onStatus: (status) => { + state.desktopRelayStatus = status + state.mainWindow?.webContents.send('mobile:relayStatusChanged', status) + } + }) + state.desktopRelayService = relayService + runtimeRpc.setMobileRelayPairingProvider({ + createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), + onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + onDemandStateChanged: () => relayService.demandStateChanged(), + getEndpoints: (context, params) => relayService.getEndpoints(context, params), + provisionRelay: (context, params) => relayService.provisionRelay(context, params) + }) + relayService.start() + powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) + } catch (error) { + console.warn( + '[relay] Desktop relay startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + } + } + win.once('show', () => { + const store = state.store + if (store && store.getOnboarding().closedAt !== null) { + triggerStartupNotificationRegistration(store) + } + }) +} + +export async function initializeMainProcessRuntimeLaunch( + options: MainProcessRuntimeLaunchOptions +): Promise { + const runtime = state.runtime + const shellPathHydration = state.windowsShellPathHydration + if (!runtime || !shellPathHydration) { + throw new Error('Runtime and shell-path services must be initialized before launch') + } + let serveOptions: ReturnType | null = null + try { + serveOptions = state.isServeMode ? getServeOptions() : null + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + app.exit(1) + return + } + state.serveOptions = serveOptions + const runtimeRpc = installRuntimeRpc(runtime, serveOptions) + const shellPathReady = shellPathHydration.whenReady() + let desktopWindow: BrowserWindow | null = null + if (process.platform === 'win32' && app.isPackaged && !serveOptions) { + const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + bindServices: bindTerminalRuntimeStartupServices, + openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true }), + shellPathReady, + startServices: startTerminalRuntimeStartupServices + }) + desktopWindow = desktopStartup.window + } else { + await shellPathReady + bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) + } + app.on('activate', options.handleMacAppActivation) + if (serveOptions) { + await launchServeMode(runtime, runtimeRpc, serveOptions) + return + } + await launchDesktopMode(runtimeRpc, shellPathReady, desktopWindow, options.openMainWindow) +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts new file mode 100644 index 00000000000..46e65c8c01f --- /dev/null +++ b/src/main/startup/main-process-runtime-service.ts @@ -0,0 +1,136 @@ +import { app } from 'electron' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import { browserManager } from '../browser/browser-manager' +import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { getDaemonProvider } from '../daemon/daemon-init' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { OrchestrationEnvironmentTransport } from '../runtime/orchestration/environment-transport' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' +import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' +import { SkillCloudService } from '../skills/skill-cloud-service' +import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' + +export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const activation = state.desktopActivationGate + if (!activation) { + return 'available' + } + const value = activation.getState() + return value === 'ready' ? 'openable' : value +} + +export function initializeMainProcessRuntime(): OrcaRuntimeService { + const store = state.store + const stats = state.stats + if (!store || !stats) { + throw new Error('Store and stats must be initialized before runtime') + } + const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { + resolve: (selector) => { + const environment = resolveEnvironment(app.getPath('userData'), selector) + const pairing = getPreferredPairingOffer(environment) + return { + environmentId: environment.id, + name: environment.name, + peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) + } + }, + call: (selector, method, params, timeoutMs, envelope) => + callRuntimeEnvironment( + app.getPath('userData'), + selector, + method, + params, + timeoutMs, + undefined, + envelope + ) + } + const runtime = new OrcaRuntimeService(store, stats, { + agentSessionClaimSigner: loadAgentSessionClaimSigner( + getProfileUserDataPath(), + getProfileUserDataPath() + ), + getLocalProvider: () => getLocalPtyProvider(), + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), + onPtyStopped: clearProviderPtyState, + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus, + getAgentStatusSnapshot: () => + agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + agentHookServer.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey) => + agentHookServer.retirePaneAuthority(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, + getPairedDeviceName: (pairedDeviceId) => + state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, + getAdditionalAiVaultCodexHomePaths: () => + state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: state.codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + orchestrationEnvironmentTransport, + skillTransactionRecovery: state.skillTransactionRecovery + }) + state.runtime = runtime + runtime.prepareLegacyWorkerTerminalRecovery() + runtime.rehydrateClientHostedBrowserPages() + state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + return runtime +} + +export function configureRuntimeServices(runtime: OrcaRuntimeService): void { + const store = state.store + const claudeAccounts = state.claudeAccounts + const codexAccounts = state.codexAccounts + const rateLimits = state.rateLimits + if (!store || !claudeAccounts || !codexAccounts || !rateLimits) { + throw new Error('Account services must be initialized before runtime wiring') + } + runtime.setArtifactService( + new ArtifactCloudService(app.getPath('userData'), () => + isArtifactSharingEnabled(state.store?.getSettings()) + ) + ) + runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + runtime.setCommitMessageAgentEnvironmentResolvers({ + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target) + }) +} diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts new file mode 100644 index 00000000000..b38d7b3ac62 --- /dev/null +++ b/src/main/startup/main-process-serve.ts @@ -0,0 +1,131 @@ +import { existsSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { app } from 'electron' +import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' +import { notifyServeSupervisorReady } from '../serve-update-handoff' +import { mainProcessState as state } from './main-process-state' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +export function getServeOptions(argv = process.argv): ServeOptions { + const valueAfter = (flag: string): string | null => { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + const value = argv[index + 1] + return value && !value.startsWith('--') ? value : null + } + const rawPort = valueAfter('--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + return { + json: argv.includes('--serve-json'), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter('--serve-pairing-address'), + noPairing: argv.includes('--serve-no-pairing'), + mobilePairing: argv.includes('--serve-mobile-pairing'), + recipeJson: argv.includes('--serve-recipe-json'), + projectRoot: valueAfter('--serve-project-root') + } +} + +export function getBundledWebClientRoot(): string | undefined { + const appPath = app.getAppPath() + const roots = [ + join(appPath, 'out', 'web'), + // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. + join(appPath, '..', 'web') + ] + return roots.find((root) => existsSync(join(root, 'web-index.html'))) +} + +async function renderTerminalPairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +export async function printServeReady(options: ServeOptions): Promise { + const runtime = state.runtime + const runtimeRpc = state.runtimeRpc + if (!runtime || !runtimeRpc) { + throw new Error('Runtime server must be initialized before printing serve readiness') + } + if (options.recipeJson) { + if (!options.projectRoot) { + throw new Error('--serve-recipe-json requires --serve-project-root') + } + if (!isAbsolute(options.projectRoot)) { + throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) + } + if (!statSync(options.projectRoot).isDirectory()) { + throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) + } + } + const boundEndpoint = runtimeRpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const pairing = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : runtimeRpc.createPairingOffer({ + address: options.pairingAddress, + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' + }) + const pairingQr = + pairing.available && options.mobilePairing + ? await renderTerminalPairingQr(pairing.pairingUrl) + : null + await state.serveReadinessPublisher.publish( + { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + managedWslCliReconciliation: state.managedWslCliReconciliationStatus, + pairing: pairing.available + ? { + available: true, + url: pairing.pairingUrl, + endpoint: pairing.endpoint, + deviceId: pairing.deviceId, + webClientUrl: pairing.webClientUrl, + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: pairingQr + } + : pairing + }, + options.recipeJson + ? { mode: 'recipe-json', projectRoot: options.projectRoot! } + : { mode: options.json ? 'json' : 'human' } + ) + notifyServeSupervisorReady(runtime.getRuntimeId()) +} diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts new file mode 100644 index 00000000000..330f690d268 --- /dev/null +++ b/src/main/startup/main-process-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow, Tray } from 'electron' +import { app } from 'electron' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RateLimitService } from '../rate-limits/service' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import type { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import type { StarNagService } from '../star-nag/service' +import type { AgentAwakeService } from '../agent-awake-service' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import type { AutomationService } from '../automations/service' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginKillListService } from '../plugins/plugin-kill-list-service' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { KeybindingService } from '../keybindings/keybinding-service' +import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import type { EmulatorBridge } from '../emulator/emulator-bridge' +import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' +import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import type { getDevInstanceIdentity } from './dev-instance-identity' +import type { createServeDesktopActivationGate } from './serve-desktop-activation' +import type { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import type { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import type { ServeOptions } from './main-process-serve' +import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' +import { ServeReadinessPublisher } from '../server/serve-readiness' +import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { + DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, + DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + GpuCrashFallbackTracker +} from '../crash-reporting/gpu-crash-fallback-decision' +import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { createWebContentsTimedFlag } from './web-contents-timed-flag' + +/** Mutable composition-root state shared by startup, window, serve, and quit phases. */ +export const mainProcessState = { + mainWindow: null as BrowserWindow | null, + isQuitting: false, + store: null as Store | null, + stats: null as StatsCollector | null, + claudeUsage: null as ClaudeUsageStore | null, + codexUsage: null as CodexUsageStore | null, + openCodeUsage: null as OpenCodeUsageStore | null, + codexAccounts: null as CodexAccountService | null, + codexRuntimeHome: null as CodexRuntimeHomeService | null, + codexSessionMigration: null as ReturnType | null, + claudeAccounts: null as ClaudeAccountService | null, + claudeRuntimeAuth: null as ClaudeRuntimeAuthService | null, + runtime: null as OrcaRuntimeService | null, + rateLimits: null as RateLimitService | null, + runtimeRpc: null as OrcaRuntimeRpcServer | null, + serveReadinessPublisher: new ServeReadinessPublisher(), + desktopRelayService: null as DesktopRelayService | null, + desktopRelayStatus: 'offline' as RelayBrokerStatus, + pendingUnpairedDeviceAuthFailure: false, + headlessBrowserDisplayAvailable: false, + starNag: null as StarNagService | null, + agentAwakeService: null as AgentAwakeService | null, + crashReports: null as CrashReportStore | null, + unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, + publishProviderSessionChanges: null as + | ((identities: AgentHookProviderSessionIdentity[]) => void) + | null, + unsubscribeSystemResumeBroadcast: null as (() => void) | null, + watcherShutdownPromise: null as Promise | null, + watcherShutdownDone: false, + automations: null as AutomationService | null, + pluginService: null as PluginService | null, + pluginKillListService: null as PluginKillListService | null, + pluginMarketplaceService: null as PluginMarketplaceService | null, + pluginMarketplaceInstaller: null as PluginMarketplaceInstaller | null, + keybindings: null as KeybindingService | null, + expectedRendererReload: createWebContentsTimedFlag(), + recoveryReloadInFlight: createWebContentsTimedFlag(), + pendingOpenSettings: createWebContentsTimedFlag(), + skillShareDeepLinks: new SkillShareDeepLinkState(), + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliReconciliationReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + managedWslCliReconciliationStatus: 'settled' as 'pending' | 'settled' | 'failed', + gpuCrashFallbackTracker: new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }), + activeGpuFallbackMarker: null as GpuFallbackMarker | null, + gpuFallbackActiveThisLaunch: false, + gpuFeatureStatus: null as Electron.GPUFeatureStatus | null, + gpuCrashDiagnostics: null as GpuCrashDiagnosticsRecorder | null, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve(), + isServeMode: false, + devInstanceIdentity: null as ReturnType | null, + devAgentHookEndpointNamespace: undefined as string | undefined, + startupDiagnosticsEnabled: false, + desktopActivationGate: null as ReturnType | null, + activeOrcaProfile: null as ReturnType | null, + windowsShellPathHydration: null as ReturnType | null, + shellPathReady: Promise.resolve(), + hangDetection: null as HangDetectionMarker | null, + skillTransactionRecovery: Promise.resolve() as Promise, + serveOptions: null as ServeOptions | null, + desktopWindow: null as BrowserWindow | null, + agentBrowserBridge: null as AgentBrowserBridge | null, + emulatorBridge: null as EmulatorBridge | null, + tray: null as Tray | null +} + +/** Environment passed to GPU fallback marker helpers. */ +export function gpuFallbackEnvironment(): GpuFallbackEnvironment { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + platform: process.platform + } +} diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts new file mode 100644 index 00000000000..c1009d22baa --- /dev/null +++ b/src/main/startup/main-window-actions.ts @@ -0,0 +1,169 @@ +import { app, clipboard, dialog, type BrowserWindow, type Tray } from 'electron' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { isQuittingForUpdate } from '../updater' +import { + createSystemTray, + setMacMenuBarIconVisible, + type SystemTrayOptions +} from '../tray/system-tray' +import { checkForUpdatesFromMenu } from '../updater' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' +import { mainProcessState as state } from './main-process-state' +import { loadMainWindow } from '../window/createMainWindow' +import { describeInstallDirAclPoison } from './windows-install-dir-acl-recovery' +import { presentRendererRecoveryPrompt } from '../window/renderer-recovery-prompt' + +// The window module injects this callback to avoid a cycle between actions and lifecycle code. +let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +export function setMainWindowOpener( + opener: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +): void { + openWindow = opener +} + +export function focusExistingWindow(): void { + focusExistingMainWindow({ + app, + getWindow: () => state.mainWindow, + openWindow, + warn: console.warn + }) +} + +export function showMainWindowFromTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + safelyRevealWindow(state.mainWindow) + return + } + if (!isQuittingForUpdate()) { + openWindow() + } +} + +export function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + targetWindow.webContents.send('ui:openSettings') + state.pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +export function quitFromSystemTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + showMainWindowFromTray() + } + state.isQuitting = true + app.quit() +} + +export function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +export function getSystemTrayOptions(): SystemTrayOptions | null { + const store = state.store + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + isDevInstance: state.devInstanceIdentity?.isDev ?? false, + devInstanceLabel: state.devInstanceIdentity?.devLabel ?? null, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +export function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || state.isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + +export function createSystemTrayDeferred( + window: BrowserWindow, + onCreated?: () => void +): () => void { + let trayCreated = false + return () => { + if (trayCreated || window.isDestroyed() || state.isQuitting || !state.store) { + return + } + trayCreated = true + if (process.platform === 'darwin') { + if (syncMacMenuBarIcon(state.store.getSettings().showMenuBarIcon !== false)) { + onCreated?.() + } + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + onCreated?.() + } + } +} + +export function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openFeatureTour') +} + +export function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openSetupGuide') +} + +export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openCrashReport') +} + +// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. +export async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { + await presentRendererRecoveryPrompt({ + recentRecoveryCount, + isQuitting: () => state.isQuitting, + diagnose: describeInstallDirAclPoison, + showMessageBox: (options) => { + const window = + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) + }, + copyToClipboard: (text) => clipboard.writeText(text), + reload: () => { + if (!state.mainWindow || state.mainWindow.isDestroyed()) { + return + } + recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') + // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. + loadMainWindow(state.mainWindow) + }, + quit: () => { + state.isQuitting = true + app.quit() + } + }) +} diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts new file mode 100644 index 00000000000..629c0550581 --- /dev/null +++ b/src/main/startup/main-window-agent-status.ts @@ -0,0 +1,138 @@ +import type { BrowserWindow } from 'electron' +import { agentHookServer } from '../agent-hooks/server' +import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state' +import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' +import { isAskUserQuestionTool } from '../../shared/agent-question-answered-intent' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { + driveSyntheticTitleFromHook, + shouldSuppressCodexAutoApprovalSyntheticTitleFromHook, + stopAllSyntheticTitleSpinners +} from './synthetic-title-runtime' +import { mainProcessState as state } from './main-process-state' + +export type MainWindowAgentStatusOptions = { + window: BrowserWindow + maybeAutoRenameBranchOnFirstWork: (event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined + }) => void + onRecordAgentState: (agentType: string, status: string) => void +} + +export function installMainWindowAgentStatusListeners(options: MainWindowAgentStatusOptions): void { + agentHookServer.setListener( + ({ + paneKey, + tabId, + worktreeId, + connectionId, + payload, + receivedAt, + stateStartedAt, + launchToken, + providerSession, + providerSessionOnly, + promptInteractionKey, + restoredUnconfirmed, + observation, + isReplay + }) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (providerSessionOnly) { + state.mainWindow?.webContents.send('agentStatus:set', { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(observation ? { observation } : {}), + providerSessionOnly: true + }) + return + } + if (!restoredUnconfirmed) { + options.maybeAutoRenameBranchOnFirstWork({ paneKey, tabId, worktreeId, payload, isReplay }) + } + const runtime = state.runtime + const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) + const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + const statusEvent = { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + ...(terminalHandle ? { terminalHandle } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(observation ? { observation } : {}), + ...(orchestration ? { orchestration } : {}) + } + state.mainWindow?.webContents.send('agentStatus:set', statusEvent) + if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + } + options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + const profile = getSyntheticAgentTitleProfile(payload.agentType) + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { + driveSyntheticTitleFromHook(paneKey, payload.state, profile) + } + } + ) + agentHookServer.setPaneStatusClearListener((clear) => { + if (state.mainWindow?.isDestroyed()) { + return + } + state.mainWindow?.webContents.send('agentStatus:clear', clear) + getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) + }) + setMigrationUnsupportedPtyListener((event) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (event.type === 'set') { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) + } else { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { + ptyId: event.ptyId + }) + } + }) +} + +export function clearMainWindowAgentStatusListeners(): void { + agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) + setMigrationUnsupportedPtyListener(null) + stopAllSyntheticTitleSpinners() +} diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts new file mode 100644 index 00000000000..177f4231a65 --- /dev/null +++ b/src/main/startup/main-window-controller.ts @@ -0,0 +1,197 @@ +import { app, type BrowserWindow } from 'electron' +import { createMainWindow, loadMainWindow } from '../window/createMainWindow' +import { + recordCrashBreadcrumb, + recordCoalescedCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { resolveConsent } from '../telemetry/consent' +import { trackAppOpenedOnce } from '../telemetry/client' +import { ensureWindowsUserDataAclGrant } from './windows-user-data-acl' +import { probeWindowsInstallDirAcl } from './windows-install-dir-acl-probe' +import { startWindowsInstallDirAclRepairIfPoisoned } from './windows-install-dir-acl-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' +import { setTrayAttention } from '../tray/system-tray' +import { + createSystemTrayDeferred, + getSystemTrayOptions, + showMainWindowFromTray, + syncMacMenuBarIcon +} from './main-window-actions' +import { attachMainWindowCoreServices } from './main-window-core-services' +import { + clearMainWindowAgentStatusListeners, + installMainWindowAgentStatusListeners +} from './main-window-agent-status' +import { mainProcessState as state } from './main-process-state' +import { + clearExpectedRendererReload, + markExpectedRendererReload, + markRecoveryReloadInFlight, + getExpectedTeardownScope, + recordProcessGoneCrash +} from './main-window-lifecycle-flags' +import { showRendererRecoveryPrompt } from './main-window-actions' +import { presentGpuFallbackRecoveredLaunchPrompt } from './gpu-lifecycle' +import { maybeAutoRenameBranchOnFirstWorkFromHook } from './branch-rename-hook' +import { + resumeSyntheticTitleSpinnerTimer, + stopSyntheticTitleSpinnerTimer +} from './synthetic-title-runtime' +import { requireMainWindowServices } from './main-window-service-readiness' + +const TRAY_CREATE_FALLBACK_MS = 12_000 +const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + logStartupMilestone('open-main-window-start') + const { store, keybindings } = requireMainWindowServices({ + store: state.store, + runtime: state.runtime, + stats: state.stats, + claudeUsage: state.claudeUsage, + codexUsage: state.codexUsage, + openCodeUsage: state.openCodeUsage, + rateLimits: state.rateLimits, + automations: state.automations, + codexAccounts: state.codexAccounts, + codexRuntimeHome: state.codexRuntimeHome, + claudeAccounts: state.claudeAccounts, + claudeRuntimeAuth: state.claudeRuntimeAuth, + keybindings: state.keybindings + }) + if (process.platform === 'win32') { + logStartupMilestone('acl-grant-start') + ensureWindowsUserDataAclGrant(app.getPath('userData'), { + onDone: (result) => { + logStartupMilestone('acl-grant-done', { mode: result.mode }) + if (result.mode === 'failed') { + console.warn('[win32-acl] userData ACL grant failed:', result.reason) + } + } + }) + // Why here: read-only, and the install DACL is the one thing a 0x80000003 + // child death cannot tell us about itself. See electron/electron#51761. + probeWindowsInstallDirAcl({ + isServeMode: state.isServeMode, + onDone: (data) => + startWindowsInstallDirAclRepairIfPoisoned(data, { + isServeMode: state.isServeMode, + userDataPath: app.getPath('userData'), + appVersion: app.getVersion() + }) + }) + } + const window = createMainWindow(store, { + getIsQuitting: () => state.isQuitting, + onQuitAborted: () => { + state.isQuitting = false + clearExpectedRendererReload() + }, + onRendererProcessGone: (details, webContentsId) => + recordProcessGoneCrash( + 'renderer', + 'renderer', + details.reason, + details.exitCode ?? null, + { processType: 'renderer' }, + webContentsId + ), + shouldRecoverRenderer: (details, webContentsId) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: getExpectedTeardownScope(webContentsId, false) + }), + onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { + recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { + reason: details.reason, + exitCode: details.exitCode ?? null, + recentRecoveryCount + }) + void showRendererRecoveryPrompt(recentRecoveryCount) + }, + deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), + title: state.devInstanceIdentity?.name ?? app.name, + getKeybindings: () => keybindings.getOverrides(), + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + markExpectedRendererReload(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onBeforeRecoveryReload: (webContentsId) => { + markRecoveryReloadInFlight(webContentsId) + recordDurableCrashBreadcrumb('renderer_recovery_reload') + } + }) + recordCrashBreadcrumb('main_window_created') + logStartupMilestone('window-created') + const createTray = createSystemTrayDeferred(window, () => logStartupMilestone('tray-created')) + window.once('ready-to-show', () => { + logStartupMilestone('ready-to-show') + setImmediate(createTray) + }) + window.once('show', () => { + logStartupMilestone('window-shown') + void presentGpuFallbackRecoveredLaunchPrompt(window) + }) + const trayCreateFallback = setTimeout(createTray, TRAY_CREATE_FALLBACK_MS) + trayCreateFallback.unref?.() + const rendererWebContentsId = window.webContents.id + const onFirstWindowLoad = (): void => { + clearExpectedRendererReload(rendererWebContentsId) + recordCrashBreadcrumb('main_window_loaded') + logStartupMilestone('did-finish-load') + const currentStore = state.store + if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { + trackAppOpenedOnce() + } + } + window.webContents.on('did-finish-load', onFirstWindowLoad) + attachMainWindowCoreServices(window, { + markExpectedRendererReload, + recordRendererReload: (ignoreCache) => + recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) + }) + state.mainWindow = window + window.on('show', resumeSyntheticTitleSpinnerTimer) + window.on('restore', resumeSyntheticTitleSpinnerTimer) + window.on('hide', stopSyntheticTitleSpinnerTimer) + window.on('minimize', stopSyntheticTitleSpinnerTimer) + window.on('show', notifyMainWindowBecameVisible) + window.on('restore', notifyMainWindowBecameVisible) + window.on('show', () => setTrayAttention(false)) + window.on('restore', () => setTrayAttention(false)) + installMainWindowAgentStatusListeners({ + window, + maybeAutoRenameBranchOnFirstWork: maybeAutoRenameBranchOnFirstWorkFromHook, + onRecordAgentState: (agentType, status) => + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType, state: status }, + coalesceKey: `agent:${agentType}:${status}`, + minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS + }) + }) + window.on('closed', () => { + if (state.mainWindow === window) { + state.mainWindow = null + } + clearExpectedRendererReload(rendererWebContentsId) + state.automations?.setWebContents(null) + clearMainWindowAgentStatusListeners() + }) + logStartupMilestone('load-start') + loadMainWindow(window) + return window +} + +export function configureWindowActions(): void { + // Kept as a named seam for startup composition; action callbacks are state-backed. + void getSystemTrayOptions + void showMainWindowFromTray + void syncMacMenuBarIcon +} diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts new file mode 100644 index 00000000000..f6f51e221ec --- /dev/null +++ b/src/main/startup/main-window-core-services.ts @@ -0,0 +1,130 @@ +import type { BrowserWindow } from 'electron' +import { registerCoreHandlers } from '../ipc/register-core-handlers/register-core-handlers' +import { attachMainWindowServices } from '../window/attach-main-window-services' +import { initTccPromptNotice } from '../macos-tcc-prompt-notice' +import { resolveUpdateInstallMode } from '../updater' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { preserveAgentAuthBeforeRestart } from '../agent-auth-restart-preservation' +import { + emitPluginWorktreeLifecycle, + handleCodexHomePtySpawned, + handlePtyExit +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags' + +export function attachMainWindowCoreServices( + window: BrowserWindow, + deps: { + markExpectedRendererReload: (webContentsId: number) => void + recordRendererReload: (ignoreCache: boolean) => void + } +): void { + const store = state.store + const runtime = state.runtime + const stats = state.stats + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + const openCodeUsage = state.openCodeUsage + const codexAccounts = state.codexAccounts + const claudeAccounts = state.claudeAccounts + const rateLimits = state.rateLimits + const automations = state.automations + const keybindings = state.keybindings + const codexRuntimeHome = state.codexRuntimeHome + const claudeRuntimeAuth = state.claudeRuntimeAuth + if ( + !store || + !runtime || + !stats || + !claudeUsage || + !codexUsage || + !openCodeUsage || + !codexAccounts || + !claudeAccounts || + !rateLimits || + !automations || + !keybindings || + !codexRuntimeHome || + !claudeRuntimeAuth + ) { + throw new Error('Main window services must be initialized before attaching') + } + registerCoreHandlers( + store, + runtime, + stats, + claudeUsage, + codexUsage, + openCodeUsage, + codexAccounts, + claudeAccounts, + rateLimits, + window.webContents.id, + automations, + { + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target) + }, + state.agentAwakeService ?? undefined, + state.crashReports ?? undefined, + keybindings, + { + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery(), + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + onBeforeRelaunch: async () => { + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome, + claudeRuntimeAuth, + store + }) + }, + onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), + onBeforeOrcaProfileSignOut: () => state.desktopRelayService?.fenceAndCloseNow() + }, + state.pluginService ?? undefined, + state.pluginMarketplaceService && state.pluginMarketplaceInstaller + ? { marketplace: state.pluginMarketplaceService, installer: state.pluginMarketplaceInstaller } + : undefined + ) + automations.setWebContents(window.webContents) + automations.start() + attachMainWindowServices( + window, + store, + runtime, + prepareCodexRuntimeHomeForLaunch, + (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, + awaitLocalPtyStartup: () => state.localPtyStartupReady, + awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady, + onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { + if (window.webContents.id === webContentsId) { + deps.markExpectedRendererReload(webContentsId) + } + deps.recordRendererReload(ignoreCache) + }, + isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, + onBeforeUpdateQuit: () => + preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + updateInstallMode: resolveUpdateInstallMode(state.isServeMode), + onWorktreeLifecycle: emitPluginWorktreeLifecycle + } + ) + initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) + rateLimits.attach(window) + rateLimits.start({ fetchImmediately: false }) +} diff --git a/src/main/startup/main-window-lifecycle-flags.ts b/src/main/startup/main-window-lifecycle-flags.ts new file mode 100644 index 00000000000..2a4964c8f4e --- /dev/null +++ b/src/main/startup/main-window-lifecycle-flags.ts @@ -0,0 +1,53 @@ +import { resolveExpectedTeardownScope } from '../crash-reporting/expected-teardown-state' +import type { ExpectedTeardownScope } from '../crash-reporting/process-gone-classification' +import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from '../crash-reporting/process-gone-recorder' +import { isQuittingForUpdate } from '../updater' +import { mainProcessState as state } from './main-process-state' + +export function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { + state.expectedRendererReload.mark(webContentsId, durationMs) +} + +export function clearExpectedRendererReload(webContentsId?: number): void { + state.expectedRendererReload.clear(webContentsId) +} + +export function getExpectedTeardownScope( + webContentsId?: number, + includeSystemSessionEnd = true +): ExpectedTeardownScope { + return resolveExpectedTeardownScope({ + isQuitting: state.isQuitting, + isQuittingForUpdate: isQuittingForUpdate(), + isExpectedRendererReload: + webContentsId !== undefined && state.expectedRendererReload.matches(webContentsId), + includeSystemSessionEnd + }) +} + +export function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { + state.recoveryReloadInFlight.mark(webContentsId, durationMs) +} + +export function isRecoveryReloadInFlight(webContentsId: number): boolean { + return state.recoveryReloadInFlight.matches(webContentsId, { consume: true }) +} + +export function recordProcessGoneCrash( + source: 'renderer' | 'child', + processType: string, + reason: string, + exitCode: number | null, + details: Record, + webContentsId?: number +): void { + recordProcessGoneCrashEvent(state.crashReports, { + source, + processType, + reason, + exitCode, + expectedTeardown: getExpectedTeardownScope(webContentsId), + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) + }) +} diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts new file mode 100644 index 00000000000..2adfd612ba7 --- /dev/null +++ b/src/main/startup/main-window-service-readiness.ts @@ -0,0 +1,42 @@ +/** + * Keep startup diagnostics specific and ordered. These messages are useful + * when a partial bootstrap opens a window and are part of the existing contract. + */ +const MAIN_WINDOW_SERVICE_REQUIREMENTS = [ + ['store', 'Store must be initialized before opening the main window'], + ['runtime', 'Runtime must be initialized before opening the main window'], + ['stats', 'Stats must be initialized before opening the main window'], + ['claudeUsage', 'Claude usage store must be initialized before opening the main window'], + ['codexUsage', 'Codex usage store must be initialized before opening the main window'], + ['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'], + ['rateLimits', 'Rate limit service must be initialized before opening the main window'], + ['automations', 'Automation service must be initialized before opening the main window'], + ['codexAccounts', 'Codex account service must be initialized before opening the main window'], + [ + 'codexRuntimeHome', + 'Codex runtime home service must be initialized before opening the main window' + ], + ['claudeAccounts', 'Claude account service must be initialized before opening the main window'], + [ + 'claudeRuntimeAuth', + 'Claude runtime auth service must be initialized before opening the main window' + ], + ['keybindings', 'Keybinding service must be initialized before opening the main window'] +] as const + +type MainWindowServiceKey = (typeof MAIN_WINDOW_SERVICE_REQUIREMENTS)[number][0] + +type RequiredServices> = { + [K in keyof T]: NonNullable +} + +export function requireMainWindowServices>( + services: T +): RequiredServices { + for (const [key, message] of MAIN_WINDOW_SERVICE_REQUIREMENTS) { + if (!services[key]) { + throw new Error(message) + } + } + return services as RequiredServices +} diff --git a/src/main/startup/secret-protection-report-deferral-wiring.test.ts b/src/main/startup/secret-protection-report-deferral-wiring.test.ts index eda9721667f..2d97c51d7b0 100644 --- a/src/main/startup/secret-protection-report-deferral-wiring.test.ts +++ b/src/main/startup/secret-protection-report-deferral-wiring.test.ts @@ -13,18 +13,22 @@ import { describe, expect, it } from 'vitest' * as a dead host); `false` puts the blocking probe back in front of the window. Deleting the * call entirely restores the original regression. * - * Source-level because that is the property: this runs once inside `app.whenReady()` during - * startup, so there is no seam to assert against at runtime. + * Source-level because that is the property: this runs once during the ready-phase foundation, + * so there is no seam to assert against at runtime. */ describe('secret protection report deferral wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const SCHEDULE = 'scheduleSecretProtectionGapReport({' it('arms the deferred report exactly once and never calls the blocking one directly', () => { expect(source.split(SCHEDULE).length - 1, `${SCHEDULE} should appear exactly once`).toBe(1) expect(source).toContain( - "import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'" + "import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report'" ) // Why also assert the absence: re-importing the blocking entry point reinstates the // pre-window probe without touching the call site the next test pins. Note the scheduling @@ -48,26 +52,27 @@ describe('secret protection report deferral wiring', () => { // Why anchor the indent: `SCHEDULE` matches anywhere, including as the body of an added // `if (...) schedule(...)` guard, which leaves every assertion here true while the call // stops running unconditionally. Pinning it as a statement at whenReady's own indent is - // what makes "this runs on every desktop startup" the thing under test. + // what makes "this runs on every startup" the thing under test. expect(source).toContain(`\n ${SCHEDULE}`) - expect(call).toContain('deferUntilFirstWindow: !isServeMode') + expect(call).toContain('deferUntilFirstWindow: !state.isServeMode') // Why assert the constants are absent too: `!isServeMode` being present does not stop a // later property in the same literal from overriding it. expect(call).not.toContain('deferUntilFirstWindow: true') expect(call).not.toContain('deferUntilFirstWindow: false') }) - it('arms the report after the profile exists and inside app readiness', () => { + it('arms the report after the profile exists during app readiness', () => { // Why: the report remembers what it last said beside the profile data file, so arming it // before the profile is resolved would key the state off a path that does not exist yet. // Anchored on code, never a comment — a reworded comment silently becomes -1. - const ready = source.indexOf('app.whenReady().then(') - const profile = source.indexOf('const activeOrcaProfile = ensureActiveOrcaProfile()') + const ready = entrySource.indexOf('void app.whenReady().then(async () => {') + const profile = source.indexOf('const profile = ensureActiveOrcaProfile()') const schedule = source.indexOf(SCHEDULE) expect(ready).toBeGreaterThanOrEqual(0) - expect(profile).toBeGreaterThan(ready) + expect(profile).toBeGreaterThanOrEqual(0) expect(schedule).toBeGreaterThan(profile) + expect(entrySource.indexOf('initializeMainProcessReady({')).toBeGreaterThan(ready) }) }) diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index 0aea295c657..64d628c51ff 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -3,59 +3,84 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('serve desktop activation wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const runtimeServiceSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-service.ts'), + 'utf8' + ) + const windowCoreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) it('routes second-instance and windowless app activation through one safety gate', () => { - expect(source).toContain('createServeDesktopActivationGate({') - expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') - expect(source).toContain('createMacAppActivationHandler({') - expect(source).toContain("app.on('activate', handleMacAppActivation)") - expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + expect(preflightSource).toContain('createServeDesktopActivationGate({') + expect(preflightSource).toContain( + 'acquireSingleInstanceLock(app, options.requestDesktopActivation)' + ) + expect(entrySource).toContain('createMacAppActivationHandler({') + expect(runtimeSource).toContain("app.on('activate', options.handleMacAppActivation)") + expect(runtimeServiceSource).toContain('getDesktopWindowStatus,') }) it('settles the persistent provider before headless PTY registration', () => { - const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') - const startupIndex = source.indexOf( - 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))', - appReadyIndex + const startupIndex = runtimeSource.indexOf( + 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))' ) - const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) - const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) - const providerReadyIndex = source.indexOf('await localPtyProviderStartupReady', serveIndex) - const headlessRegistrationIndex = source.indexOf( + const serveLaunchIndex = runtimeSource.indexOf('async function launchServeMode(') + const serveDispatchIndex = runtimeSource.indexOf(' if (serveOptions) {', startupIndex) + const ptyReadyIndex = runtimeSource.indexOf( + 'await state.localPtyStartupReady', + serveLaunchIndex + ) + const providerReadyIndex = runtimeSource.indexOf( + 'await state.localPtyProviderStartupReady', + serveLaunchIndex + ) + const headlessRegistrationIndex = runtimeSource.indexOf( 'await registerHeadlessPtyRuntime(', - serveIndex + serveLaunchIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveLaunchIndex) expect(startupIndex).toBeGreaterThanOrEqual(0) - expect(startupIndex).toBeLessThan(serveIndex) - expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(serveDispatchIndex).toBeGreaterThan(startupIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveLaunchIndex) expect(providerReadyIndex).toBeGreaterThan(ptyReadyIndex) expect(headlessRegistrationIndex).toBeGreaterThan(providerReadyIndex) expect(headlessRegistrationIndex).toBeLessThan(rpcIndex) - expect(source).not.toContain( + expect(runtimeSource).not.toContain( 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' ) }) it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { - const serveIndex = source.indexOf('if (serveOptions) {') - const sentinelIndex = source.indexOf( + const serveIndex = runtimeSource.indexOf('async function launchServeMode(') + const sentinelIndex = runtimeSource.indexOf( 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', serveIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) - const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = runtimeSource.indexOf('settleDesktopActivation()', rpcIndex) expect(serveIndex).toBeGreaterThanOrEqual(0) expect(sentinelIndex).toBeGreaterThan(serveIndex) expect(rpcIndex).toBeGreaterThan(sentinelIndex) expect(settleIndex).toBeGreaterThan(rpcIndex) - expect(source).not.toContain('runtime.syncWindowGraph(0,') + expect(runtimeSource).not.toContain('runtime.syncWindowGraph(0,') }) it('keeps the headless install policy after desktop promotion', () => { - expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)') + expect(windowCoreSource).toContain( + 'updateInstallMode: resolveUpdateInstallMode(state.isServeMode)' + ) }) }) diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index be6bf76e625..3455c8c59ab 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -49,14 +49,17 @@ describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) }) - // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // Why source text: the ordering is the preflight phase's executable statement order, and the // cases above stay green if it is reversed — nothing else would catch the regression. - it('keeps index.ts running both CLI redirects before the argv rewrite', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + it('keeps the preflight running both CLI redirects before the argv rewrite', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') - const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + const serveModeCheck = source.indexOf("state.isServeMode = process.argv.includes('--serve')") expect(packagedRedirect).toBeGreaterThanOrEqual(0) expect(appImageRedirect).toBeGreaterThanOrEqual(0) diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts index 7cf61a4af0f..15623c2459f 100644 --- a/src/main/startup/single-instance-lock-exit.electron.test.ts +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -34,10 +34,13 @@ afterAll(() => { /** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ function readLockLossTermination(): string { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = source.indexOf('if (!hasSingleInstanceLock) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('if (!hasLock) {') expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('\n}', start) + const end = source.indexOf('\n }', start) expect(end).toBeGreaterThan(start) return source diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts index 3c9468f595e..f79afa3f5ba 100644 --- a/src/main/startup/single-instance-lock-headless-exit.test.ts +++ b/src/main/startup/single-instance-lock-headless-exit.test.ts @@ -19,28 +19,32 @@ function readSystemdUnitBlocks(doc: string): Map { } describe('headless lock-loss exit contract', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const doc = readFileSync(join(process.cwd(), 'docs/reference/headless-linux-server.md'), 'utf8') it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { - const gateStart = source.indexOf('if (!hasSingleInstanceLock) {') + const gateStart = preflightSource.indexOf('if (!hasLock) {') // Why: bound the anchor — an unresolved indexOf slices to EOF and passes vacuously. expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = source.indexOf('\n}', gateStart) + const gateEnd = preflightSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) - const gate = source.slice(gateStart, gateEnd) + const gate = preflightSource.slice(gateStart, gateEnd) expect(gate).toContain('app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)') expect(gate).not.toContain('app.quit()') }) it('keeps a duplicate serve launch from promoting the live server to a desktop window', () => { - const activationStart = source.indexOf('function requestDesktopActivation(') + const activationStart = entrySource.indexOf('function requestDesktopActivation(') expect(activationStart).toBeGreaterThanOrEqual(0) - const activationEnd = source.indexOf('\n}', activationStart) + const activationEnd = entrySource.indexOf('\n}', activationStart) expect(activationEnd).toBeGreaterThan(activationStart) - expect(source.slice(activationStart, activationEnd)).toContain( + expect(entrySource.slice(activationStart, activationEnd)).toContain( 'shouldActivateDesktopForSecondInstance(argv)' ) }) diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts new file mode 100644 index 00000000000..dde50f4d2c8 --- /dev/null +++ b/src/main/startup/synthetic-title-runtime.ts @@ -0,0 +1,187 @@ +import { registerPaneKeyTeardownListener, getPtyIdForPaneKey } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import type { AgentStatusState } from '../../shared/agent-status-types' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook, + type SyntheticAgentTitleProfile +} from '../../shared/synthetic-agent-title' +import { + advanceSyntheticTitleSpinnerEntries, + getSyntheticTitleSpinnerPaneKeyToStop, + type SyntheticTitleSpinnerEntry +} from '../synthetic-title-spinner' +import { shouldSendSyntheticTitleFrame } from '../synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from '../synthetic-title-frame-routing' +import { resolveTuiAgentPermissionMode } from '../../shared/tui-agent-permissions' +import { mainProcessState as state } from './main-process-state' + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const syntheticTitleSpinnerByPaneKey = new Map< + string, + SyntheticTitleSpinnerEntry +>() +let syntheticTitleSpinnerTimer: ReturnType | null = null + +function isSyntheticTitleWindowVisible(): boolean { + const window = state.mainWindow + return window !== null && !window.isDestroyed() && window.isVisible() && !window.isMinimized() +} + +function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { + const window = state.mainWindow + if (!window || window.isDestroyed()) { + return + } + if ( + !shouldSendSyntheticTitleFrame({ + force: options.force === true, + windowVisible: isSyntheticTitleWindowVisible() + }) + ) { + return + } + state.runtime?.ingestSyntheticTitleFrame(ptyId, data) + if (shouldCopySyntheticTitleFrameToPtyData(state.store?.getSettings())) { + window.webContents.send('pty:data', { id: ptyId, data }) + } +} + +function canSendDecorativeSyntheticTitle(): boolean { + return shouldSendSyntheticTitleFrame({ + force: false, + windowVisible: isSyntheticTitleWindowVisible() + }) +} + +export function stopSyntheticTitleSpinner(paneKey: string): void { + if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { + stopSyntheticTitleSpinnerTimerIfIdle() + } +} + +export function stopAllSyntheticTitleSpinners(): void { + syntheticTitleSpinnerByPaneKey.clear() + stopSyntheticTitleSpinnerTimer() +} + +export function stopSyntheticTitleSpinnerTimer(): void { + if (syntheticTitleSpinnerTimer) { + clearInterval(syntheticTitleSpinnerTimer) + syntheticTitleSpinnerTimer = null + } +} + +function stopSyntheticTitleSpinnerTimerIfIdle(): void { + if (syntheticTitleSpinnerByPaneKey.size === 0) { + stopSyntheticTitleSpinnerTimer() + } +} + +function tickSyntheticTitleSpinners(): void { + if (!canSendDecorativeSyntheticTitle()) { + stopSyntheticTitleSpinnerTimer() + return + } + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries: syntheticTitleSpinnerByPaneKey, + frameCount: SPINNER_FRAMES.length, + getPtyIdForPaneKey + }) + for (const tick of ticks) { + sendSyntheticTitle( + tick.ptyId, + `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` + ) + } + stopSyntheticTitleSpinnerTimerIfIdle() +} + +function ensureSyntheticTitleSpinnerTimer(): void { + if ( + syntheticTitleSpinnerTimer || + syntheticTitleSpinnerByPaneKey.size === 0 || + !canSendDecorativeSyntheticTitle() + ) { + return + } + syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) +} + +export function resumeSyntheticTitleSpinnerTimer(): void { + ensureSyntheticTitleSpinnerTimer() +} + +export function driveSyntheticTitleFromHook( + paneKey: string, + agentState: AgentStatusState, + profile: SyntheticAgentTitleProfile +): void { + const ptyId = getPtyIdForPaneKey(paneKey) + if (!ptyId) { + return + } + if (agentState === 'working') { + const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) + const frame = existing ? existing.frame : 0 + sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) + if (existing) { + existing.profile = profile + return + } + syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) + ensureSyntheticTitleSpinnerTimer() + return + } + stopSyntheticTitleSpinner(paneKey) + const needsUserInput = agentState === 'blocked' || agentState === 'waiting' + const label = needsUserInput ? profile.permissionLabel : profile.idleLabel + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { force: true }) +} + +export function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { agentArgs?: string | null; agentEnv?: Record | null } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + +export function initializeSyntheticTitleRuntime(): void { + registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) + // Retire synthetic titles with either pane-scoped clears or explicit status drops. + agentHookServer.subscribePaneStatusClear((clear) => { + const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) + if (paneKey) { + stopSyntheticTitleSpinner(paneKey) + } + }) + agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) +} + +export function driveSyntheticTitleForAgentStatus( + paneKey: string, + agentType: string | null | undefined, + agentState: AgentStatusState +): void { + const profile = getSyntheticAgentTitleProfile(agentType) + if (profile && shouldDriveSyntheticAgentTitleFromHook(agentType, agentState)) { + driveSyntheticTitleFromHook(paneKey, agentState, profile) + } +} diff --git a/src/main/startup/web-contents-timed-flag.ts b/src/main/startup/web-contents-timed-flag.ts new file mode 100644 index 00000000000..4e281c29a02 --- /dev/null +++ b/src/main/startup/web-contents-timed-flag.ts @@ -0,0 +1,31 @@ +/** A short-lived renderer intent scoped to one WebContents instance. */ +export function createWebContentsTimedFlag(defaultDurationMs = 10_000): { + mark: (webContentsId: number, durationMs?: number) => void + clear: (webContentsId?: number) => void + matches: (webContentsId: number, options?: { consume?: boolean }) => boolean +} { + let state: { webContentsId: number; until: number } | null = null + return { + mark(webContentsId, durationMs = defaultDurationMs) { + state = { webContentsId, until: Date.now() + durationMs } + }, + clear(webContentsId) { + if (webContentsId === undefined || state?.webContentsId === webContentsId) { + state = null + } + }, + matches(webContentsId, options) { + if (!state || Date.now() > state.until) { + state = null + return false + } + if (state.webContentsId !== webContentsId) { + return false + } + if (options?.consume) { + state = null + } + return true + } + } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 912c681cfad..e22e18e84ba 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,11 +1,7 @@ -/* eslint-disable max-lines */ -import { app, BrowserWindow, powerMonitor } from 'electron' -import { is } from '@electron-toolkit/utils' +import type { BrowserWindow } from 'electron' import type { LinuxPackageInstallInstructions, - LinuxPackageInstallRecovery, UpdateCheckOptions, - UpdateSource, UpdateStatus } from '../shared/update-status-types' import type { @@ -13,2341 +9,86 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../shared/remote-server-update' -import { - isWindowsSignatureCheckUnavailableFailure, - isWindowsSignatureMismatchFailure -} from '../shared/updater-windows-signature-check' -import { killAllPty } from './ipc/pty' -import { withUpdaterSpan } from './observability/instrumentation' -import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' -import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' -import { runWithLaunchPath } from './startup/hydrate-shell-path' -import { - beginMacUpdateDownload, - deferMacQuitUntilInstallerReady, - isMacInstallerReady, - markMacQuitAndInstallInFlight, - resetMacInstallState -} from './updater-mac-install' -import { - armUpdateInstallExitWatchdog, - disarmUpdateInstallExitWatchdog -} from './update-install-exit-watchdog' -import { registerAutoUpdaterHandlers } from './updater-events' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { getLinuxRootPackageType } from './linux-update-package-type' -import { - beginLinuxPackageInstallDiagnosticCapture, - createUpdaterDiagnosticLogger, - endLinuxPackageInstallDiagnosticCapture, - getLinuxPackageInstallDiagnostic, - parseLinuxPackageInstallExitCode, - redactLinuxPackageInstallText, - type LinuxPackageInstallDiagnostic -} from './linux-package-install-diagnostic' -import { - clearTrackedLinuxPackageArtifact, - getTrackedLinuxPackageArtifact, - resolveLinuxPackageInstallInstructions, - revalidateLinuxPackageForInstall, - revealLinuxPackage, - type LinuxPackageArtifact, - type LinuxPackageRecoveryUnavailableReason -} from './linux-package-update-recovery' -import { - compareVersions, - isBenignCheckFailure, - isMissingUpdateManifestFailure, - isPrereleaseVersion, - statusesEqual -} from './updater-fallback' -import { - fetchNewerReleaseTagsWithReadiness, - getReleaseDownloadUrl -} from './updater-prerelease-feed' -import { fetchNudge, shouldApplyNudge } from './updater-nudge' -import { - failServeUpdateHandoff, - getServeUpdateHandoffFailure, - hasServeUpdateSupervisor, - requestServeUpdateHandoff -} from './serve-update-handoff' -import type { LocalBuildFeed } from './local-builds/local-build-feed-server' -import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' -import { - DEV_CHANNEL_PLATFORM_LABEL, - getVersionChannel, - hasDedicatedReleaseRepo, - isChannelSupportedOnPlatform, - RELEASE_CHANNEL_LABELS, - requiresManualDevChannelInstall, - type ReleaseBuild, - type ReleaseChannel -} from '../shared/release-channel' +import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' +import type { UpdateInstallMode } from './updater/updater-state' -type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' -type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } -type PrimaryEventSuppression = { failureKey: string; error: unknown } -type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' -type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' -// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. -class ReleaseFeedPreflightError extends Error { - constructor( - readonly reason: ReleaseFeedPreflightFailure, - readonly releaseChannel: UpdateCheckVariant, - message: string - ) { - super(message) - this.name = 'ReleaseFeedPreflightError' - } -} -type ReleaseFeedPreflightResult = 'ready' | 'not-available' -export type UpdateInstallMode = - | 'interactive' - | 'supervised-headless-serve' - | 'unsupported-headless-serve' +// Keep one service instance so all public API calls share updater state and event listeners. +const updater = new UpdaterSetup() -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 -const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 -// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. -const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 -const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 -const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 -const QUIT_AND_INSTALL_DELAY_MS = 100 -const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 -const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 -const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 - -let mainWindowRef: BrowserWindow | null = null -let currentStatus: UpdateStatus = { state: 'idle' } -let userInitiatedCheck = false -let onBeforeQuitCleanup: (() => void | Promise) | null = null -let autoUpdaterInitialized = false -// Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. -let includePrereleaseActive = false -let availableVersion: string | null = null -let availableReleaseUrl: string | null = null -let pendingCheckFailureKey: string | null = null -let pendingCheckFailurePromise: Promise | null = null -let autoUpdateCheckTimer: ReturnType | null = null -let nudgeCheckTimer: ReturnType | null = null -let pendingQuitAndInstallTimer: ReturnType | null = null -let quitAndInstallInProgress = false -// Why: the pre-install digest re-proof streams the whole package, so a second install request can -// arrive while it runs — after the quit timer was cleared but before the handoff owns the process. -let linuxPackageRevalidationInFlight = false -let updateInstallMode: UpdateInstallMode = 'interactive' -let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null } -// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. -let updateInstallCommitted = false -// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. -let quitAndInstallNativeInvoked = false -// Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. -let lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null -let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null -let _getLastUpdateCheckAt: (() => number | null) | null = null -let backgroundCheckLaunchPending = false -// Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. -let backgroundCheckPromotedToUserInitiated = false -let updateCheckStallTimer: ReturnType | null = null -let updateCheckSilentSettleTimer: ReturnType | null = null -let updateCheckAttemptSequence = 0 -let activeUpdateCheckAttemptId: number | null = null -let activeUpdateCheckLaunchAttemptId: number | null = null -let activeUpdateCheckEventAttemptId: number | null = null -let updateAvailableEventPendingAttemptId: number | null = null -let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null -let activeUpdateNudgeId: string | null = null -let awaitingNudgeCheckOutcome = false -let nudgeCheckInFlight = false -let lastNudgeCheckAt = 0 -let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null -let pendingPrereleaseFallback: { - primaryTag: string - fallbackTag: string - // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. - userInitiated: boolean - suppressedPrimaryPromiseFailureKey: string | null - suppressedPrimaryEventFailure: PrimaryEventSuppression | null - suppressedFallbackPromiseFailureKey: string | null - suppressedFallbackEventFailureKey: string | null - fallbackResultHandled: boolean - fallbackCheckingForUpdateSeen: boolean - retryLaunched: boolean -} | null = null - -let _getPendingUpdateNudgeId: (() => string | null) | null = null -let _getDismissedUpdateNudgeId: (() => string | null) | null = null -let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null -let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null -// Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. -let downloadInFlight = false -/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ -let quittingForUpdate = false -let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | UpdateSource = 'release' -let activeLocalBuildFeed: LocalBuildFeed | null = null -let localBuildSelectionInProgress = false -// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade -// like local builds — but off a real release feed, not a loopback server. -let pinnedBuildSelectionInProgress = false -// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a -// deliberate downgrade, so newer-only gates must yield to it too. -let isPinnedBuildActive = false -let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null - -function getAutoUpdater(): ElectronAutoUpdater { - if (!autoUpdater) { - autoUpdater = loadElectronAutoUpdater() - } - return autoUpdater -} - -function clearAvailableUpdateContext(): void { - availableVersion = null - availableReleaseUrl = null -} - -function closeLocalBuildFeed(): void { - const feed = activeLocalBuildFeed - activeLocalBuildFeed = null - if (feed) { - void feed.close() - } -} - -function restoreReleaseUpdateSource(): void { - closeLocalBuildFeed() - activeUpdateSource = 'release' - isPinnedBuildActive = false - if (autoUpdater) { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - // Why: a pinned jump forces allowPrerelease on; leaving it set would opt - // every later background check into the RC channel behind the user's back. - autoUpdater.allowPrerelease = includePrereleaseActive - } -} - -function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { - clearAvailableUpdateContext() - if ( - currentStatus.state !== 'error' || - currentStatus.message !== message || - currentStatus.userInitiated !== userInitiated || - currentStatus.source !== 'local' - ) { - sendStatus({ state: 'error', message, userInitiated, source: 'local' }) - } - restoreReleaseUpdateSource() -} - -function clearPrereleaseFallbackContext(): void { - pendingPrereleaseFallback = null -} - -function clearPendingUpdateNudge(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false - _setPendingUpdateNudgeId?.(null) -} - -function deferPendingUpdateNudgeUntilRetry(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false -} - -function clearPublishingWindowLastGoodCheck(): void { - publishingWindowLastGoodCheck = null -} - -function getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { - return publishingWindowLastGoodCheck -} - -function getPersistedPendingUpdateNudgeId(): string | null { - return _getPendingUpdateNudgeId?.() ?? null -} - -function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { - // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. - if (!activeUpdateNudgeId) { - return status - } - if (status.state === 'idle' || status.state === 'checking' || status.state === 'not-available') { - return status - } - return { ...status, activeNudgeId: activeUpdateNudgeId } -} - -/** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ -function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { - const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight - const shouldLaunchPendingUserInitiatedCheck = - pendingUserInitiatedCheckVariant !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - const shouldPreserveNudgeForPublishingWindow = - publishingWindowLastGoodCheck !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - if (awaitingNudgeCheckOutcome) { - if (status.state === 'available') { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. - deferPendingUpdateNudgeUntilRetry() - } else { - awaitingNudgeCheckOutcome = false - } - } else if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'error' - ) { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. - deferPendingUpdateNudgeUntilRetry() - } else { - // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. - if (activeUpdateNudgeId) { - _setDismissedUpdateNudgeId?.(activeUpdateNudgeId) - } - clearPendingUpdateNudge() - } - } - } - - const sourcedStatus: UpdateStatus = - activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource } - const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) - - if (isUpdateCheckResultState(status.state)) { - finishActiveUpdateCheckAttempt() - } - - if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error' - ) { - clearPublishingWindowLastGoodCheck() - } - - // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. - if ( - decoratedStatus.state === 'downloading' || - decoratedStatus.state === 'error' || - decoratedStatus.state === 'idle' - ) { - downloadInFlight = false - } - if (shouldLaunchPendingUserInitiatedCheck) { - // Why: a forced status must still land before the queued check restarts the cycle. - if (options?.force) { - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) - } - launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) - return - } - if (!options?.force && statusesEqual(currentStatus, decoratedStatus)) { - return - } - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) -} - -function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { - switch (variant) { - case 'perf': - return { includePrerelease: true, includePerfPrerelease: true } - case 'prerelease': - return { includePrerelease: true } - case 'default': - return { includePrerelease: false } - } -} - -function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { - if (options?.includePerfPrerelease) { - return 'perf' - } - if (options?.includePrerelease) { - return 'prerelease' - } - // Why: a persisted 'rc' override makes every routine check follow the RC series - // without the user re-holding shift; the dev channels need an explicit tag, so - // neither is a routine-check variant. - if (getReleaseChannelOverride?.() === 'rc') { - return 'prerelease' - } - return 'default' -} - -function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { - pendingUserInitiatedCheckAfterInFlight = null - setTimeout(() => { - // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. - if (currentStatus.state === 'checking') { - currentStatus = { state: 'idle' } - } - checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) - }, 0) -} - -function clearBackgroundCheckLaunchPending(): void { - backgroundCheckLaunchPending = false -} - -function clearUpdateCheckStallTimer(): void { - if (!updateCheckStallTimer) { - return - } - clearTimeout(updateCheckStallTimer) - updateCheckStallTimer = null -} - -function clearUpdateCheckSilentSettleTimer(): void { - if (!updateCheckSilentSettleTimer) { - return - } - clearTimeout(updateCheckSilentSettleTimer) - updateCheckSilentSettleTimer = null -} - -function clearUpdateCheckTimers(): void { - clearUpdateCheckStallTimer() - clearUpdateCheckSilentSettleTimer() -} - -function finishActiveUpdateCheckAttempt(): void { - activeUpdateCheckAttemptId = null - activeUpdateCheckLaunchAttemptId = null - activeUpdateCheckEventAttemptId = null - clearUpdateCheckTimers() -} - -function getActiveUpdateCheckEventAttemptId(): number | null { - if (activeUpdateCheckAttemptId === null) { - return null - } - if (activeUpdateCheckEventAttemptId !== activeUpdateCheckAttemptId) { - return null - } - return activeUpdateCheckAttemptId -} - -function isActiveUpdateCheckAttempt(attemptId: number): boolean { - return activeUpdateCheckAttemptId === attemptId -} - -function markUpdateCheckEventAttempt(): boolean { - if (activeUpdateCheckAttemptId === null) { - return false - } - if (activeUpdateCheckLaunchAttemptId !== activeUpdateCheckAttemptId) { - return false - } - activeUpdateCheckEventAttemptId = activeUpdateCheckAttemptId - return true -} - -function markUpdateCheckLaunched(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - activeUpdateCheckLaunchAttemptId = attemptId -} - -function markUpdateAvailableEventPending(attemptId: number | null): void { - updateAvailableEventPendingAttemptId = attemptId -} - -function clearUpdateAvailableEventPending(attemptId: number | null): void { - if (updateAvailableEventPendingAttemptId !== attemptId) { - return - } - updateAvailableEventPendingAttemptId = null -} - -function armUpdateCheckStallTimer(attemptId: number): void { - clearUpdateCheckStallTimer() - updateCheckStallTimer = setTimeout(() => { - updateCheckStallTimer = null - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - if (currentStatus.state === 'checking') { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - void sendCheckFailureStatus( - 'Update check timed out. Try again in a few minutes.', - wasUserInitiated, - 'promise' - ) - return - } - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - }, UPDATE_CHECK_STALL_TIMEOUT_MS) -} - -function beginUpdateCheckAttempt(): number { - finishActiveUpdateCheckAttempt() - updateAvailableEventPendingAttemptId = null - updateCheckAttemptSequence += 1 - activeUpdateCheckAttemptId = updateCheckAttemptSequence - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) - // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. - writeMainThreadDiagnosticMarker('updater-check-attempt') - return activeUpdateCheckAttemptId -} - -function rearmActiveUpdateCheckStallTimer(): void { - if (activeUpdateCheckAttemptId === null) { - return - } - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) -} - -function getSettledCheckUserInitiated(): boolean | undefined { - return userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined -} - -function isUpdateCheckResultState(state: UpdateStatus['state']): boolean { - return ( - state === 'idle' || - state === 'not-available' || - state === 'available' || - state === 'error' || - state === 'downloading' || - state === 'downloaded' - ) -} - -function consumeSilentCheckShortRetryReason(): boolean { - if (publishingWindowLastGoodCheck !== null) { - return true - } - return consumeMissingManifestPrereleaseFallbackResult() !== null -} - -function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { - const shouldRetrySoon = consumeSilentCheckShortRetryReason() - clearAvailableUpdateContext() - if (shouldRetrySoon) { - // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - return true - } - recordCompletedUpdateCheck() - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - return false -} - -function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - if (updateAvailableEventPendingAttemptId === attemptId) { - return - } - if (currentStatus.state !== 'checking') { - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - const shouldRetrySoon = completeSilentUpdateCheck(userInitiated) - if (awaitingNudgeCheckOutcome) { - if (shouldRetrySoon) { - deferPendingUpdateNudgeUntilRetry() - return - } - sendStatus({ state: 'not-available', userInitiated }) - } - } - return - } - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - completeSilentUpdateCheck(userInitiated) - sendStatus({ state: 'not-available', userInitiated }) -} - -function handleSettledUpdateCheckPromise(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - clearUpdateCheckSilentSettleTimer() - // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. - updateCheckSilentSettleTimer = setTimeout(() => { - updateCheckSilentSettleTimer = null - settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated()) - }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) -} - -function shouldHandleUpdaterErrorEvent(): boolean { - if (getActiveUpdateCheckEventAttemptId() !== null) { - return true - } - // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. - return ( - downloadInFlight || - currentStatus.state === 'downloading' || - currentStatus.state === 'downloaded' - ) -} - -function sendErrorStatus(message: string, userInitiated?: boolean): void { - if ( - currentStatus.state === 'error' && - currentStatus.message === message && - currentStatus.userInitiated === userInitiated - ) { - return - } - // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. - if (isWindowsSignatureCheckUnavailableFailure(message)) { - recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { - level: 'warn', - message: 'Windows update signature check could not run' - }) - } - sendStatus({ state: 'error', message, userInitiated }) -} - -function getKnownReleaseUrl(): string | undefined { - return availableReleaseUrl ?? undefined -} - -function hasInstallableDownloadedVersion(): boolean { - return ( - availableVersion !== null && - // Why: local builds and pinned dev jumps may intentionally move backwards. - (activeUpdateSource !== 'release' || - isPinnedBuildActive || - compareVersions(availableVersion, app.getVersion()) > 0) - ) -} - -function getPendingInstallVersion(): string { - if (availableVersion) { - return availableVersion - } - if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') { - return currentStatus.version - } - return '' -} - -function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { - if (updateInstallMode !== 'unsupported-headless-serve') { - return false - } - const diagnosticVersion = version || 'unknown' - if (lastInstallDeferralVersion[phase] !== diagnosticVersion) { - lastInstallDeferralVersion[phase] = diagnosticVersion - recordUpdaterLifecycle( - 'headless_serve_install_deferred', - { phase, version: version || null }, - { - level: 'warn', - message: 'Update install deferred while hosting orca serve' - } - ) - } - sendErrorStatus( - 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', - true - ) - return true -} +export type { UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { - if (!isServeMode) { - return 'interactive' - } - return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' -} - -function getCheckFailureKey(message: string, userInitiated?: boolean): string { - return `${userInitiated ? 'user' : 'auto'}:${message}` -} - -function clearPrereleaseFallbackContextIfSettled(): void { - if ( - pendingPrereleaseFallback?.fallbackResultHandled && - !pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && - !pendingPrereleaseFallback.suppressedPrimaryEventFailure && - !pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && - !pendingPrereleaseFallback.suppressedFallbackEventFailureKey - ) { - clearPrereleaseFallbackContext() - } -} - -async function performQuitAndInstall(): Promise { - if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) { - recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) - return - } - - if (pendingQuitAndInstallTimer) { - clearTimeout(pendingQuitAndInstallTimer) - pendingQuitAndInstallTimer = null - } - - const pendingVersion = getPendingInstallVersion() - if (deferHeadlessServeInstall('install', pendingVersion)) { - return - } - // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about - // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a - // swapped or vanished package aborts instead of being installed as root. The synchronous guard - // keeps every non-Linux install on its existing timing. - if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { - // Why: the renderer armed its restart before invoking, and it infers the abort from the error - // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where - // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. - mainWindowRef?.webContents.send('updater:quitAndInstallAborted') - return - } - quitAndInstallInProgress = true - - markMacQuitAndInstallInFlight() - - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. - quittingForUpdate = true - - try { - await withUpdaterSpan({ stage: 'install' }, async (span) => { - span.setAttribute('updater.version', pendingVersion || 'unknown') - span.setAttribute('updater.platform', process.platform) - span.setAttribute( - 'updater.macosInstallerReady', - process.platform === 'darwin' ? isMacInstallerReady() : true - ) - recordUpdaterLifecycle('quit_and_install_started', { - version: pendingVersion || null, - macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true - }) - span.addEvent('pre_quit_cleanup_start') - await runBeforeUpdateQuitCleanup() - span.addEvent('pre_quit_cleanup_done') - - if ( - updateInstallMode === 'supervised-headless-serve' && - !requestServeUpdateHandoff(pendingVersion) - ) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { version: pendingVersion || null }, - { - level: 'warn', - message: 'Could not persist supervised serve update handoff' - } - ) - sendErrorStatus( - 'Could not prepare the supervised server restart. Orca remains running.', - true - ) - resetQuitForUpdateState() - // Why: a bare return would exit this span Success and hide the aborted install from tracing. - span.fail('Could not persist the supervised serve update handoff') - return - } - - recordUpdaterLifecycle('quit_and_install_invoking_native', { - version: pendingVersion || null - }) - // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. - if (!quitAndInstallInProgress) { - return - } - // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. - quitAndInstallNativeInvoked = true - // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. - const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve' - // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. - beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) - try { - runWithLaunchPath(() => - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) - ) - } finally { - const diagnostic = endLinuxPackageInstallDiagnosticCapture() - // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. - lastInstallAttemptDiagnostic = quitAndInstallInProgress ? diagnostic : null - } - span.addEvent('native_quit_and_install_invoked') - - // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. - if (!quitAndInstallInProgress) { - // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. - span.fail( - currentStatus.state === 'error' - ? currentStatus.message - : 'quitAndInstall returned without invoking the installer' - ) - return - } - - // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the - // package is installed. Commit here or a throw in the cleanup below is reported as an install - // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. - if (getLinuxRootPackageType() !== null) { - updateInstallCommitted = true - armUpdateInstallExitWatchdog() - } - - killAllPty() - span.addEvent('local_pty_kill_all') - - for (const win of BrowserWindow.getAllWindows()) { - win.removeAllListeners('close') - } - span.addEvent('window_close_listeners_removed', { - windowCount: BrowserWindow.getAllWindows().length - }) - - // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. - if (!updateInstallCommitted && (process.platform !== 'darwin' || isMacInstallerReady())) { - updateInstallCommitted = true - // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). - armUpdateInstallExitWatchdog() - } - }) - } catch (error) { - // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is - // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear - // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. - if (updateInstallCommitted) { - recordUpdaterLifecycle( - 'post_commit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install cleanup failed after commit; install already applied' - } - ) - return - } - // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. - const quitAndInstallNativeInvokedBeforeReset = quitAndInstallNativeInvoked - const recoveryStatus = - quitAndInstallNativeInvokedBeforeReset && !updateInstallCommitted - ? buildLinuxPackageInstallFailureStatus(error) - : null - failServeUpdateHandoff('Could not invoke the native updater.') - resetQuitForUpdateState() - recordUpdaterLifecycle( - 'quit_and_install_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Could not start update install' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. - // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. - message: quitAndInstallNativeInvokedBeforeReset - ? withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - : 'Could not restart to install the update. Quit and reopen Orca, then try again.' - } - ) - } -} - -function resetQuitForUpdateState(): void { - quitAndInstallInProgress = false - quittingForUpdate = false - updateInstallCommitted = false - quitAndInstallNativeInvoked = false - lastInstallAttemptDiagnostic = null - disarmUpdateInstallExitWatchdog() - resetMacInstallState() -} - -/** - * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage - * it — so keep that advice there. Everywhere else a restart is not known to help. - */ -function getPreCommitInstallFailureMessage(): string { - return process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' -} - -/** - * Sends an install-failure status even when it repeats the current one. "Try Automatic Install - * Again" usually fails identically, and a deduped status would never reach the preload abort relay, - * leaving the renderer stuck in its restart checkpoint. - */ -function sendInstallFailureStatus(status: UpdateStatus): void { - sendStatus(status, { force: true }) -} - -const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200 - -/** - * Appends the updater's own text to the generic install-failure copy. Without it the only record of - * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` - * command the user has to run by hand, and remote clients get nothing but "it didn't come back". - */ -function withInstallFailureCause(baseMessage: string, error: unknown): string { - const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - // Why: the retained-package card runs its text through this same sanitizer, so a home directory, - // user name, or terminal escape must not reach the card merely because no artifact was tracked. - const redacted = - redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' - const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH) - if (!cause || cause === 'Unknown error') { - return baseMessage - } - // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. - if ( - isWindowsSignatureCheckUnavailableFailure(cause) || - isWindowsSignatureMismatchFailure(cause) - ) { - return cause - } - return `${baseMessage} (${cause})` -} - -/** - * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can - * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. - */ -function buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return null - } - const pendingVersion = getPendingInstallVersion() - if (pendingVersion && pendingVersion !== artifact.version) { - return null - } - const diagnostic = getLinuxPackageInstallDiagnostic() ?? lastInstallAttemptDiagnostic - // Why: the reason was classified from the original output, before redaction could rewrite a match. - const reason = diagnostic?.reason ?? 'package-install-failed' - // Durable data carries classification only — never the package path, home path, command, or stderr. - const exitCode = parseLinuxPackageInstallExitCode(error) - recordUpdaterLifecycle( - 'linux_package_install_failed', - { - packageType: artifact.packageType, - reason, - // Omitted rather than null when the child status could not be parsed. - ...(exitCode === null ? {} : { exitCode }), - version: artifact.version, - errorType: error instanceof Error ? error.name : typeof error - }, - { level: 'warn', message: 'Linux package install failed; cached package retained' } - ) - // Why: this text is shown in the card, so it gets the same redaction as retained stderr. - const message = - diagnostic?.message ?? - (error instanceof Error ? redactLinuxPackageInstallText(error.message, artifact.path) : null) ?? - 'The system package installer did not start.' - return { - state: 'error', - message, - recovery: { - kind: 'linux-package-install', - packageType: artifact.packageType, - reason, - version: artifact.version - } - } -} - -// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. -function handleQuitAndInstallFailure(error?: unknown): boolean { - if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) { - return false - } - const recoveryStatus = buildLinuxPackageInstallFailureStatus(error) - failServeUpdateHandoff('The native updater rejected the install request.') - resetQuitForUpdateState() - // Durable data carries classification only — the cause text stays on the status the user can read. - recordUpdaterLifecycle( - 'quit_and_install_failed_via_event', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install could not start; recovered app state' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - } - ) - return true -} - -// Why: while quit-and-install owns the process, general check/download error UI must not run. -function isQuitAndInstallHandoffActive(): boolean { - return quitAndInstallInProgress -} - -async function runBeforeUpdateQuitCleanup(): Promise { - if (!onBeforeQuitCleanup) { - return - } - - let timeout: ReturnType | null = null - const cleanup = Promise.resolve() - .then(() => onBeforeQuitCleanup?.()) - .catch((error) => { - recordUpdaterLifecycle( - 'pre_quit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' - } - ) - }) - const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) - }) - - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` - } - ) - return - } - - if (timeout) { - clearTimeout(timeout) - } -} - -async function sendCheckFailureStatus( - message: string, - userInitiated?: boolean, - source: CheckFailureSource = 'promise', - sourceError?: unknown -): Promise { - if (activeUpdateSource === 'local') { - sendLocalBuildErrorAndRestore(message, userInitiated) - return - } - if (isPinnedBuildActive) { - // Why: a failed pinned jump must hand the feed back before surfacing the - // error, or the pin blocks background checks for the process lifetime. - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ state: 'error', message, userInitiated }) - return - } - const failureKey = getCheckFailureKey(message, userInitiated) - if ( - source === 'promise' && - pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - if ( - source === 'fallback-promise' && - pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - - if ( - retryPrereleaseFallbackAfterMissingManifest( - message, - userInitiated, - source, - failureKey, - sourceError - ) - ) { - return - } - - if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) { - return pendingCheckFailurePromise - } - - const handleFailure = async (): Promise => { - if (isBenignCheckFailure(message) || isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). - console.warn('[updater] benign check failure:', message) - clearAvailableUpdateContext() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - if (userInitiated) { - // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - sendErrorStatus( - isStableReleaseNotReadyFailure(sourceError) - ? "A newer release isn't available for this device yet. Check again later." - : "Couldn't reach the update server. Try again in a few minutes.", - true - ) - } else { - if (isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. - deferPendingUpdateNudgeUntilRetry() - } - sendStatus({ state: 'idle' }) - } - return - } - - clearAvailableUpdateContext() - persistLastUpdateCheckAt?.(Date.now()) - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - sendErrorStatus(message, userInitiated) - } - - pendingCheckFailureKey = failureKey - pendingCheckFailurePromise = handleFailure().finally(() => { - if (pendingCheckFailureKey === failureKey) { - pendingCheckFailureKey = null - pendingCheckFailurePromise = null - } - }) - return pendingCheckFailurePromise -} - -function isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') - ) -} - -function isStableReleaseNotReadyFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - sourceError.reason === 'release-not-ready' && - sourceError.releaseChannel === 'default' - ) + return updater.resolveUpdateInstallMode(isServeMode) } export function getUpdateStatus(): UpdateStatus { - return currentStatus + return updater.getUpdateStatus() } export function getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { - if (!app.isPackaged || is.dev) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'unpackaged-build' - } - } - if (!autoUpdaterInitialized) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'updater-unavailable' - } - } - if (updateInstallMode === 'unsupported-headless-serve') { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'manual-service-update-required' - } - } - return { installMode: updateInstallMode, automatic: true, reason: 'available' } + return updater.getRemoteServerUpdateSupport() } export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { - return { - appVersion: app.getVersion(), - runtimeId, - support: getRemoteServerUpdateSupport(), - status: getUpdateStatus() - } -} - -function assertRemoteServerUpdateAvailable(): void { - if (!getRemoteServerUpdateSupport().automatic) { - throw new Error('remote_update_manual_required') - } + return updater.getRemoteServerUpdaterSnapshot(runtimeId) } export function checkForRemoteServerUpdate( runtimeId: string, options?: UpdateCheckOptions ): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - checkForUpdatesFromMenu(options) - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.checkForRemoteServerUpdate(runtimeId, options) } export function downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'available') { - throw new Error('remote_update_not_available') - } - downloadUpdate() - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.downloadRemoteServerUpdate(runtimeId) } export function installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'downloaded') { - throw new Error('remote_update_not_downloaded') - } - const targetVersion = currentStatus.version - const result: RemoteServerUpdateInstallResult = { - accepted: true, - fromVersion: app.getVersion(), - targetVersion, - runtimeId - } - quitAndInstall() - return result -} - -let consecutiveAutomaticRetrySchedules = 0 - -function scheduleAutomaticUpdateCheck(delayMs: number): void { - let effectiveDelayMs = delayMs - // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. - if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { - effectiveDelayMs = Math.min( - AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules, - MAX_AUTO_UPDATE_RETRY_INTERVAL_MS - ) - consecutiveAutomaticRetrySchedules += 1 - } - if (autoUpdateCheckTimer) { - clearTimeout(autoUpdateCheckTimer) - } - autoUpdateCheckTimer = setTimeout(() => { - // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. - if (!runBackgroundUpdateCheck()) { - // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - }, effectiveDelayMs) -} - -function recordCompletedUpdateCheck(): void { - consecutiveAutomaticRetrySchedules = 0 - persistLastUpdateCheckAt?.(Date.now()) -} - -function getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - return pendingPrereleaseFallback.userInitiated -} - -function markMissingManifestPrereleaseFallbackChecking(): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true -} - -function consumeMissingManifestPrereleaseFallbackResult(): MissingManifestPrereleaseFallbackResult | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - const result = { userInitiated: pendingPrereleaseFallback.userInitiated } - pendingPrereleaseFallback.fallbackResultHandled = true - clearPrereleaseFallbackContextIfSettled() - return result -} - -function suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -function shouldSuppressMissingManifestPrereleaseFallbackEvent( - message: string, - error: unknown -): boolean { - if (!pendingPrereleaseFallback?.retryLaunched) { - return false - } - const failureKey = getCheckFailureKey(message, pendingPrereleaseFallback.userInitiated) - const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure - if (primaryEventSuppression?.failureKey === failureKey) { - const isPrimaryPromisePair = primaryEventSuppression.error === error - // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. - if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { - pendingPrereleaseFallback.suppressedPrimaryEventFailure = null - clearPrereleaseFallbackContextIfSettled() - return true - } - } - if (pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return true - } - return false -} - -function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -async function pinDefaultReleaseFeed( - variant: UpdateCheckVariant = 'default' -): Promise { - const autoUpdater = getAutoUpdater() - // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). - const currentVersion = app.getVersion() - const isPerfCheck = variant === 'perf' - const includePrerelease = - isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) - const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( - currentVersion, - includePrerelease ? 2 : 1, - { - includePrerelease, - ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) - } - ) - const newerTag = releaseTagsResult.tags[0] ?? null - const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null - pendingPrereleaseFallback = - includePrerelease && newerTag && fallbackTag - ? { - primaryTag: newerTag, - fallbackTag, - userInitiated: false, - suppressedPrimaryPromiseFailureKey: null, - suppressedPrimaryEventFailure: null, - suppressedFallbackPromiseFailureKey: null, - suppressedFallbackEventFailureKey: null, - fallbackResultHandled: false, - fallbackCheckingForUpdateSeen: false, - retryLaunched: false - } - : null - // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. - if (newerTag) { - clearPublishingWindowLastGoodCheck() - const url = getReleaseDownloadUrl(newerTag) - console.info( - `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } else if (releaseTagsResult.state === 'not-ready') { - clearPrereleaseFallbackContext() - if (releaseTagsResult.lastGoodTag) { - // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. - const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) - console.info( - `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } - clearPublishingWindowLastGoodCheck() - console.info( - `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` - ) - throw new ReleaseFeedPreflightError( - 'release-not-ready', - isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', - 'Latest release artifacts are not ready' - ) - } else if ( - releaseTagsResult.state === 'unavailable' && - releaseTagsResult.unavailableReason === 'manifest' && - !includePrerelease - ) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - throw new ReleaseFeedPreflightError( - 'manifest-unavailable', - 'default', - 'Unable to find latest version on GitHub' - ) - } else if (isPerfCheck) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - if (releaseTagsResult.state === 'no-newer') { - console.info( - `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` - ) - return 'not-available' - } - throw new Error('Could not resolve perf update feed') - } else { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - const url = 'https://github.com/stablyai/orca/releases/latest/download' - console.info( - `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } -} - -function retryPrereleaseFallbackAfterMissingManifest( - message: string, - userInitiated: boolean | undefined, - source: CheckFailureSource, - failureKey: string, - sourceError?: unknown -): boolean { - if ( - !pendingPrereleaseFallback || - pendingPrereleaseFallback.retryLaunched || - !isMissingUpdateManifestFailure(message) - ) { - return false - } - const attemptId = activeUpdateCheckAttemptId - if (attemptId === null) { - return false - } - - // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. - pendingPrereleaseFallback.retryLaunched = true - pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = - source === 'event' ? failureKey : null - pendingPrereleaseFallback.suppressedPrimaryEventFailure = - source === 'promise' ? { failureKey, error: sourceError } : null - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false - const { primaryTag, fallbackTag } = pendingPrereleaseFallback - const url = getReleaseDownloadUrl(fallbackTag) - console.info( - `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` - ) - const autoUpdater = getAutoUpdater() - autoUpdater.setFeedURL({ provider: 'generic', url }) - userInitiatedCheck = Boolean(userInitiated) - backgroundCheckLaunchPending = !userInitiated - armUpdateCheckStallTimer(attemptId) - markUpdateCheckLaunched(attemptId) - void autoUpdater - .checkForUpdates() - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const message = String(err?.message ?? err) - if (userInitiated) { - userInitiatedCheck = false - } else { - backgroundCheckLaunchPending = false - } - markMissingManifestPrereleaseFallbackPromiseHandled(message) - consumeMissingManifestPrereleaseFallbackResult() - void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err) - }) - return true -} - -/** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ -function runBackgroundUpdateCheck( - nudgeId: string | null = getPersistedPendingUpdateNudgeId() -): boolean { - // Why: a pinned dev jump owns the feed until it settles; a background check - // would repoint it mid-flight and download the wrong build. - if ( - activeUpdateSource !== 'release' || - isPinnedBuildActive || - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress - ) { - return false - } - if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { - return false - } - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available' }) - return false - } - // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. - activeUpdateNudgeId = nudgeId - // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. - backgroundCheckLaunchPending = true - backgroundCheckPromotedToUserInitiated = false - const attemptId = beginUpdateCheckAttempt() - // Don't send 'checking' here — the 'checking-for-update' handler does; sending from both dupes notifications (issue #35). - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed().then(launch) - void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - if (wasUserInitiated) { - userInitiatedCheck = false - } - void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err) - }) - return true + return updater.installRemoteServerUpdate(runtimeId) } export function checkForUpdates(): void { - // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. - void withUpdaterSpan({ stage: 'check' }, async (span) => { - span.setAttribute('updater.outcome', 'launched') - runBackgroundUpdateCheck() - }) + updater.checkForUpdates() } -function enablePrereleaseManifestChecks(): void { - getAutoUpdater().allowPrerelease = true -} - -function enableIncludePrerelease(): void { - if (includePrereleaseActive) { - return - } - // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. - enablePrereleaseManifestChecks() - includePrereleaseActive = true -} - -/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - if (options?.localBuild) { - void checkForLocalBuildFromMenu() - return - } - if (options?.targetTag && options.channel) { - void checkForPinnedBuild(options.channel, options.targetTag) - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - if ( - activeUpdateSource !== 'release' && - (currentStatus.state === 'checking' || currentStatus.state === 'downloading') - ) { - return - } - restoreReleaseUpdateSource() - - const checkVariant = getUpdateCheckVariant(options) - if (checkVariant === 'prerelease') { - clearPrereleaseFallbackContext() - enableIncludePrerelease() - } else if (checkVariant === 'perf') { - clearPrereleaseFallbackContext() - // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. - enablePrereleaseManifestChecks() - } - - const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' - userInitiatedCheck = true - // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. - activeUpdateNudgeId = null - // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. - sendStatus({ state: 'checking', userInitiated: true }) - if (checkAlreadyInFlight) { - backgroundCheckPromotedToUserInitiated = true - rearmActiveUpdateCheckStallTimer() - if (checkVariant !== 'default') { - // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. - pendingUserInitiatedCheckAfterInFlight = checkVariant - } - return - } - - const attemptId = beginUpdateCheckAttempt() - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { - if (preflightResult === 'not-available') { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return false - } - userInitiatedCheck = false - finishActiveUpdateCheckAttempt() - recordCompletedUpdateCheck() - sendStatus({ state: 'not-available', userInitiated: true }) - return false - } - return launch() - }) - void Promise.resolve(run) - .then((launchResult) => { - if (launchResult === false) { - return - } - handleSettledUpdateCheckPromise(attemptId) - }) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - userInitiatedCheck = false - void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) - }) -} - -async function checkForLocalBuildFromMenu(): Promise { - if (process.platform !== 'darwin') { - sendLocalBuildErrorAndRestore( - 'Local build switching is currently available only on macOS.', - true - ) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress) { - return - } - localBuildSelectionInProgress = true - try { - const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ - import('./local-builds/local-build-switch'), - import('./local-builds/local-build-feed-server') - ]) - const candidate = await chooseLocalBuild(mainWindowRef) - if (!candidate) { - return - } - closeLocalBuildFeed() - const feed = await startLocalBuildFeed(candidate) - activeLocalBuildFeed = feed - activeUpdateSource = 'local' - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.setFeedURL({ provider: 'generic', url: feed.url }) - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) - } finally { - localBuildSelectionInProgress = false - } -} - -export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return listReleaseBuilds(channel) -} - -/** - * Pins the updater at one exact release tag and checks it, so a dev can move to - * any published build on any channel — including an older one. - * - * Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's - * hourly" is a downgrade by semver. The pin is torn down as soon as the attempt - * settles so ordinary background checks never inherit it. - */ -async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - // Why here as well as in the picker: the renderer disables the option, but IPC - // is reachable regardless, and there is no artifact to install on a platform - // the dev workflows do not build for. - if (!isChannelSupportedOnPlatform(channel, process.platform)) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, - userInitiated: true - }) - return - } - // Why: electron-updater would otherwise take this all the way to a download - // and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead - // — the installer is run by hand once, and in-app updates work from there on. - if ( - requiresManualDevChannelInstall({ - platform: process.platform, - runningChannel: getVersionChannel(app.getVersion()), - targetChannel: channel - }) - ) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, - userInitiated: true - }) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - pinnedBuildSelectionInProgress = true - try { - const target = resolveTargetBuild(channel, tag) - if (compareVersions(target.version, app.getVersion()) === 0) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - closeLocalBuildFeed() - activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' - isPinnedBuildActive = true - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - // Why: an intentional jump to an older tag must not be filtered out as "not newer". - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.allowPrerelease = true - console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) - updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) - availableReleaseUrl = target.feedUrl - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ - state: 'error', - message: String((error as Error)?.message ?? error), - userInitiated: true - }) - } finally { - pinnedBuildSelectionInProgress = false - } -} - -export function isQuittingForUpdate(): boolean { - return quittingForUpdate -} - -function getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { - if (currentStatus.state !== 'error') { - return null - } - return currentStatus.recovery?.kind === 'linux-package-install' ? currentStatus.recovery : null -} - -const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { - missing: - 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', - // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. - 'not-regular': - 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', - 'hash-mismatch': - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', - 'read-failed': - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', - 'no-sudo': - 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - 'no-package-manager': - 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. - 'invalid-package-path': - 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' -} - -// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. -const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ - 'missing', - 'not-regular', - 'hash-mismatch' -] - -function recordLinuxPackageRecoveryUnavailable( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): void { - recordUpdaterLifecycle( - 'linux_package_recovery_unavailable', - { reason, packageType: recovery.packageType, version: recovery.version }, - { level: 'warn', message: 'Linux package recovery action unavailable' } - ) -} - -function failLinuxPackageRecovery( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): never { - recordLinuxPackageRecoveryUnavailable(recovery, reason) - const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] - // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would - // destroy the newer artifact and clobber whatever card replaced this one. - const active = getActiveLinuxPackageRecovery() - const stillCurrent = - active?.version === recovery.version && active?.packageType === recovery.packageType - if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { - clearTrackedLinuxPackageArtifact() - sendStatus({ state: 'error', message }) - } - throw new Error(message) -} - -/** - * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash - * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. - */ -function getInstallCycleSignature(): string { - const recovery = getActiveLinuxPackageRecovery() - if (recovery) { - return `recovery:${recovery.packageType}:${recovery.version}` - } - return currentStatus.state === 'downloaded' - ? `downloaded:${currentStatus.version}` - : `state:${currentStatus.state}` -} - -/** - * Re-proves the retained package before the install starts. Returns false when the install must be - * abandoned; the artifact is only re-read here, so callers still own every teardown decision. - */ -async function proveRetainedLinuxPackage(pendingVersion: string): Promise { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return true - } - // Why: an artifact retained from another cycle says nothing about the file electron-updater is - // about to install, so proving it would block a legitimate install on an unrelated digest. - if (pendingVersion && pendingVersion !== artifact.version) { - return true - } - const recovery = getActiveLinuxPackageRecovery() - const cycle = getInstallCycleSignature() - const reason = await revalidateRetainedLinuxPackage(artifact) - if (!reason) { - return true - } - reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) - return false -} - -/** The failing reason, or null when the retained package still matches its release digest. */ -async function revalidateRetainedLinuxPackage( - artifact: LinuxPackageArtifact -): Promise { - linuxPackageRevalidationInFlight = true - try { - const verdict = await revalidateLinuxPackageForInstall(artifact) - return verdict.ok ? null : verdict.reason - } catch (error) { - recordUpdaterLifecycle( - 'linux_package_revalidation_errored', - { errorType: error instanceof Error ? error.name : typeof error }, - { level: 'warn', message: 'Could not re-verify the retained update package' } - ) - // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. - return 'read-failed' - } finally { - // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall - // early-return for the rest of the session. - linuxPackageRevalidationInFlight = false - } -} - -function reportLinuxPackageRevalidationFailure({ - artifact, - recovery, - reason, - cycle -}: { - artifact: LinuxPackageArtifact - recovery: LinuxPackageInstallRecovery | null - reason: LinuxPackageRecoveryUnavailableReason - cycle: string -}): void { - recordUpdaterLifecycle( - 'linux_package_revalidation_failed', - { - action: recovery ? 'retry-automatic' : 'restart-to-install', - packageType: artifact.packageType, - version: artifact.version, - reason - }, - { level: 'warn', message: 'Retained update package failed its pre-install digest check' } - ) - // Why: a package proven bad must not stay tracked, but a download that landed during the hash - // owns the slot now and destroying it would force a needless 160 MB redownload. - const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) - if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { - clearTrackedLinuxPackageArtifact() - } - // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been - // replaced must not clobber whatever card the user is looking at now. - if (getInstallCycleSignature() !== cycle) { - return - } - sendInstallFailureStatus({ - state: 'error', - message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], - // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its - // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. - ...(recovery && !clearsArtifact ? { recovery } : {}) - }) -} - -export async function getLinuxPackageInstallInstructions(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'copy-command', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await resolveLinuxPackageInstallInstructions(recovery) - if (!result.ok) { - // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote - // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). - if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { - recordLinuxPackageRecoveryUnavailable(recovery, result.reason) - return { - ok: false, - reason: result.reason, - message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] - } - } - failLinuxPackageRecovery(recovery, result.reason) - } - return { ok: true, command: result.command, packageFileName: result.packageFileName } -} - -export async function showLinuxPackage(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'show-package', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await revealLinuxPackage(recovery) - if (!result.ok) { - failLinuxPackageRecovery(recovery, result.reason) - } -} - -export function quitAndInstall(): void { - if ( - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress || - pendingQuitAndInstallTimer || - quitAndInstallInProgress || - // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so - // without this a second click would schedule a parallel install of the same package. - linuxPackageRevalidationInFlight - ) { - return - } - - const retriedRecovery = getActiveLinuxPackageRecovery() - if (retriedRecovery) { - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: retriedRecovery.packageType, - version: retriedRecovery.version - }) - } - - if (deferHeadlessServeInstall('install', getPendingInstallVersion())) { - return - } - - if ( - deferMacQuitUntilInstallerReady( - currentStatus, - hasInstallableDownloadedVersion(), - getPendingInstallVersion, - sendStatus - ) - ) { - return - } - - // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. - pendingQuitAndInstallTimer = setTimeout(() => { - void performQuitAndInstall() - }, QUIT_AND_INSTALL_DELAY_MS) -} - -async function checkForUpdateNudge(): Promise { - if (!app.isPackaged || is.dev) { - return - } - if (nudgeCheckInFlight) { - return - } - - const now = Date.now() - if (now - lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { - return - } - lastNudgeCheckAt = now - - nudgeCheckInFlight = true - try { - const nudge = await fetchNudge() - if (!nudge) { - return - } - - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - - const appVersion = app.getVersion() - const pendingUpdateNudgeId = _getPendingUpdateNudgeId?.() ?? null - const dismissedUpdateNudgeId = _getDismissedUpdateNudgeId?.() ?? null - - if ( - shouldApplyNudge({ - nudge, - appVersion, - pendingUpdateNudgeId, - dismissedUpdateNudgeId - }) - ) { - awaitingNudgeCheckOutcome = true - _setPendingUpdateNudgeId?.(nudge.id) - mainWindowRef?.webContents.send('updater:clearDismissal') - runBackgroundUpdateCheck(nudge.id) - } - } finally { - nudgeCheckInFlight = false - } -} - -function scheduleUpdateNudgeCheck(): void { - if (nudgeCheckTimer) { - clearTimeout(nudgeCheckTimer) - } - nudgeCheckTimer = setTimeout(() => { - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - }, NUDGE_POLL_INTERVAL_MS) -} - -export function dismissNudge(): void { - const pendingId = activeUpdateNudgeId ?? _getPendingUpdateNudgeId?.() ?? null - if (pendingId) { - _setDismissedUpdateNudgeId?.(pendingId) - clearPendingUpdateNudge() - } -} - -/** - * The user closed an offered update without taking it. For a local build or a - * pinned dev jump that ends the session: nothing will consume that feed now, so - * release checks must stop being deferred. - */ -export function dismissAvailableUpdate(): void { - if (activeUpdateSource === 'release' && !isPinnedBuildActive) { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. - if (currentStatus.state !== 'available') { - return - } - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. - sendStatus({ state: 'idle' }) -} - -export function setupAutoUpdater( - mainWindow: BrowserWindow, - opts?: { - getLastUpdateCheckAt?: () => number | null - onBeforeQuit?: () => void | Promise - setLastUpdateCheckAt?: (timestamp: number) => void - getPendingUpdateNudgeId?: () => string | null - getDismissedUpdateNudgeId?: () => string | null - setPendingUpdateNudgeId?: (id: string | null) => void - setDismissedUpdateNudgeId?: (id: string | null) => void - getReleaseChannelOverride?: () => ReleaseChannel | null - installMode?: UpdateInstallMode - } -): void { - mainWindowRef = mainWindow - onBeforeQuitCleanup = opts?.onBeforeQuit ?? null - persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null - _getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null - _getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null - _getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null - _setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null - _setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null - getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null - updateInstallMode = opts?.installMode ?? 'interactive' - lastInstallDeferralVersion = { download: null, install: null } - - const serveHandoffFailure = getServeUpdateHandoffFailure() - if (serveHandoffFailure) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { reason: serveHandoffFailure }, - { level: 'warn', message: 'Supervised serve update did not complete' } - ) - sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) - } - - if (!app.isPackaged && !is.dev) { - return - } - if (is.dev) { - return - } - - const autoUpdater = getAutoUpdater() - autoUpdater.autoDownload = false - if (activeUpdateSource === 'release') { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - } - // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. - // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. - autoUpdater.autoInstallOnAppQuit = - updateInstallMode === 'interactive' && getLinuxRootPackageType() === null - // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. - autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive' - - // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. - // The adapter also retains the redacted child stderr that BaseUpdater logs but drops from the 'error' event. - autoUpdater.logger = createUpdaterDiagnosticLogger() as never - - // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. - - // Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download. - if (activeUpdateSource === 'release') { - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) - } - - if (autoUpdaterInitialized) { - return - } - autoUpdaterInitialized = true - - registerAutoUpdaterHandlers({ - autoUpdater, - clearAvailableUpdateContext, - consumeMissingManifestPrereleaseFallbackResult, - getMissingManifestPrereleaseFallbackUserInitiated, - getPublishingWindowLastGoodCheck, - getActiveUpdateCheckEventAttemptId, - getCurrentStatus: () => currentStatus, - getKnownReleaseUrl, - getPendingInstallVersion, - getUserInitiatedCheck: () => userInitiatedCheck, - handleQuitAndInstallFailure, - isQuitAndInstallHandoffActive, - hasInstallableDownloadedVersion, - isLocalBuildCheck: () => activeUpdateSource === 'local', - // Why: pinned jumps are deliberate, so update-available/-downloaded must not - // reject them for being older than the running version. - isPinnedBuildCheck: () => isPinnedBuildActive, - shouldHandleUpdaterErrorEvent, - performQuitAndInstall, - clearUpdateAvailableEventPending, - isActiveUpdateCheckAttempt, - markUpdateCheckEventAttempt, - markUpdateAvailableEventPending, - sendCheckFailureStatus, - sendErrorStatus, - markMissingManifestPrereleaseFallbackChecking, - shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive', - shouldSuppressMissingManifestPrereleaseFallbackEvent, - suppressMissingManifestPrereleaseFallbackPromiseFailure, - recordCompletedUpdateCheck, - restoreReleaseUpdateSource, - sendStatus, - scheduleAutomaticUpdateCheck, - clearBackgroundCheckLaunchPending, - setAvailableReleaseUrl: (releaseUrl) => { - availableReleaseUrl = releaseUrl - }, - setAvailableVersion: (version) => { - availableVersion = version - }, - setUserInitiatedCheck: (value) => { - userInitiatedCheck = value - } - }) - - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - - const checkDailyOnWake = () => { - void checkForUpdateNudge() - if ( - backgroundCheckLaunchPending || - currentStatus.state === 'checking' || - currentStatus.state === 'downloading' - ) { - return - } - const lastCheck = _getLastUpdateCheckAt?.() ?? null - const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck - if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - } - - powerMonitor.on('resume', checkDailyOnWake) - app.on('browser-window-focus', checkDailyOnWake) - - const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null - const msSinceLastCheck = - lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt - - if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } else { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) - } + updater.checkForUpdatesFromMenu(options) } export function downloadUpdate(): void { - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) { - return - } - // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. - const canStart = - currentStatus.state === 'available' || - (currentStatus.state === 'error' && hasInstallableDownloadedVersion()) - if (!canStart) { - return - } - const version = currentStatus.state === 'available' ? currentStatus.version : availableVersion - if (!version) { - return - } - if (deferHeadlessServeInstall('download', version)) { - return - } - downloadInFlight = true - const localBuildDownload = activeUpdateSource === 'local' - beginMacUpdateDownload() - // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. - sendStatus({ state: 'downloading', percent: 0, version }) - getAutoUpdater() - .downloadUpdate() - .catch((err) => { - downloadInFlight = false - const message = String(err?.message ?? err) - if (localBuildDownload) { - sendLocalBuildErrorAndRestore(message) - } else { - sendErrorStatus(message) - } - }) + updater.downloadUpdate() +} + +export function quitAndInstall(): void { + updater.quitAndInstall() +} + +export function isQuittingForUpdate(): boolean { + return updater.isQuittingForUpdate() +} + +export async function getLinuxPackageInstallInstructions(): Promise { + return updater.getLinuxPackageInstallInstructions() +} + +export async function showLinuxPackage(): Promise { + return updater.showLinuxPackage() +} + +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return updater.listAvailableReleaseBuilds(channel) +} + +export function dismissNudge(): void { + updater.dismissNudge() +} + +export function dismissAvailableUpdate(): void { + updater.dismissAvailableUpdate() +} + +export function setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + updater.setupAutoUpdater(mainWindow, opts) } diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts new file mode 100644 index 00000000000..63222fb3101 --- /dev/null +++ b/src/main/updater/updater-build-selection.ts @@ -0,0 +1,152 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { + DEV_CHANNEL_PLATFORM_LABEL, + getVersionChannel, + hasDedicatedReleaseRepo, + isChannelSupportedOnPlatform, + RELEASE_CHANNEL_LABELS, + requiresManualDevChannelInstall, + type ReleaseBuild, + type ReleaseChannel +} from '../../shared/release-channel' +import { compareVersions } from '../updater-fallback' +import { listReleaseBuilds, resolveTargetBuild } from '../updater-release-builds' +import { UpdaterMenuChecks } from './updater-menu-checks' + +/** Handles local-build selection and exact release-channel/tag jumps. */ +export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { + protected async checkForLocalBuildFromMenu(): Promise { + if (process.platform !== 'darwin') { + this.sendLocalBuildErrorAndRestore( + 'Local build switching is currently available only on macOS.', + true + ) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress) { + return + } + this.localBuildSelectionInProgress = true + try { + const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ + import('../local-builds/local-build-switch'), + import('../local-builds/local-build-feed-server') + ]) + const candidate = await chooseLocalBuild(this.mainWindowRef) + if (!candidate) { + return + } + this.closeLocalBuildFeed() + const feed = await startLocalBuildFeed(candidate) + this.activeLocalBuildFeed = feed + this.activeUpdateSource = 'local' + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.setFeedURL({ provider: 'generic', url: feed.url }) + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) + } finally { + this.localBuildSelectionInProgress = false + } + } + + protected async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return listReleaseBuilds(channel) + } + + /** Pins the updater at one exact release tag and checks it, so a dev can move to any published build on any channel — including an older one. */ + protected async checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + // Why here as well as in the picker: the renderer disables the option, but IPC is reachable regardless, and there is no artifact to install on a platform the dev workflows do not build for. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, + userInitiated: true + }) + return + } + // Why: electron-updater would otherwise take this all the way to a download and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead — the installer is run by hand once, and in-app updates work from there on. + if ( + requiresManualDevChannelInstall({ + platform: process.platform, + runningChannel: getVersionChannel(app.getVersion()), + targetChannel: channel + }) + ) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, + userInitiated: true + }) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + this.pinnedBuildSelectionInProgress = true + try { + const target = resolveTargetBuild(channel, tag) + if (compareVersions(target.version, app.getVersion()) === 0) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + this.closeLocalBuildFeed() + this.activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' + this.isPinnedBuildActive = true + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + // Why: an intentional jump to an older tag must not be filtered out as "not newer". + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.allowPrerelease = true + console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) + updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) + this.availableReleaseUrl = target.feedUrl + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ + state: 'error', + message: String((error as Error)?.message ?? error), + userInitiated: true + }) + } finally { + this.pinnedBuildSelectionInProgress = false + } + } +} diff --git a/src/main/updater/updater-check-failure.ts b/src/main/updater/updater-check-failure.ts new file mode 100644 index 00000000000..8ee2500c6a9 --- /dev/null +++ b/src/main/updater/updater-check-failure.ts @@ -0,0 +1,118 @@ +import { isBenignCheckFailure } from '../updater-fallback' +import { ReleaseFeedPreflightError } from './updater-state' +import type { CheckFailureSource } from './updater-state' +import { UpdaterReleaseFeed } from './updater-release-feed' + +/** Normalizes check failures, retry policy, and release-feed preflight diagnostics. */ +export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { + protected isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') + ) + } + + protected isStableReleaseNotReadyFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + sourceError.reason === 'release-not-ready' && + sourceError.releaseChannel === 'default' + ) + } + + protected async sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source: CheckFailureSource = 'promise', + sourceError?: unknown + ): Promise { + if (this.activeUpdateSource === 'local') { + this.sendLocalBuildErrorAndRestore(message, userInitiated) + return + } + if (this.isPinnedBuildActive) { + // Why: a failed pinned jump must hand the feed back before surfacing the error, or the pin blocks background checks for the process lifetime. + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ state: 'error', message, userInitiated }) + return + } + const failureKey = this.getCheckFailureKey(message, userInitiated) + if ( + source === 'promise' && + this.pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + source === 'fallback-promise' && + this.pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + this.retryPrereleaseFallbackAfterMissingManifest( + message, + userInitiated, + source, + failureKey, + sourceError + ) + ) { + return + } + if (this.pendingCheckFailureKey === failureKey && this.pendingCheckFailurePromise) { + return this.pendingCheckFailurePromise + } + + const handleFailure = async (): Promise => { + if ( + isBenignCheckFailure(message) || + this.isRetryableReleaseFeedPreflightFailure(sourceError) + ) { + // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). + console.warn('[updater] benign check failure:', message) + this.clearAvailableUpdateContext() + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + if (userInitiated) { + // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. + this.sendErrorStatus( + this.isStableReleaseNotReadyFailure(sourceError) + ? "A newer release isn't available for this device yet. Check again later." + : "Couldn't reach the update server. Try again in a few minutes.", + true + ) + } else { + if (this.isRetryableReleaseFeedPreflightFailure(sourceError)) { + // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. + this.deferPendingUpdateNudgeUntilRetry() + } + this.sendStatus({ state: 'idle' }) + } + return + } + this.clearAvailableUpdateContext() + this.persistLastUpdateCheckAt?.(Date.now()) + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + } + this.sendErrorStatus(message, userInitiated) + } + + this.pendingCheckFailureKey = failureKey + this.pendingCheckFailurePromise = handleFailure().finally(() => { + if (this.pendingCheckFailureKey === failureKey) { + this.pendingCheckFailureKey = null + this.pendingCheckFailurePromise = null + } + }) + return this.pendingCheckFailurePromise + } + + /** Keeps retry interval access in one place for the scheduling layer. */ + protected abstract getAutomaticRetryInterval(): number +} diff --git a/src/main/updater/updater-check-state.ts b/src/main/updater/updater-check-state.ts new file mode 100644 index 00000000000..8905029c75d --- /dev/null +++ b/src/main/updater/updater-check-state.ts @@ -0,0 +1,298 @@ +import { writeMainThreadDiagnosticMarker } from '../diagnostics/main-thread-churn-probe' +import { isWindowsSignatureCheckUnavailableFailure } from '../../shared/updater-windows-signature-check' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterStatus } from './updater-status' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + UPDATE_CHECK_SILENT_SETTLE_DELAY_MS, + UPDATE_CHECK_STALL_TIMEOUT_MS +} from './updater-state' + +export abstract class UpdaterCheckState extends UpdaterStatus { + protected getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } + } + + protected getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + // Why: a persisted 'rc' override makes every routine check follow the RC series + // without the user re-holding shift; the dev channels need an explicit tag, so + // neither is a routine-check variant. + if (this.getReleaseChannelOverride?.() === 'rc') { + return 'prerelease' + } + return 'default' + } + + protected launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + this.pendingUserInitiatedCheckAfterInFlight = null + setTimeout(() => { + // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. + if (this.currentStatus.state === 'checking') { + this.currentStatus = { state: 'idle' } + } + this.checkForUpdatesFromMenu(this.getOptionsForUpdateCheckVariant(variant)) + }, 0) + } + + protected clearBackgroundCheckLaunchPending(): void { + this.backgroundCheckLaunchPending = false + } + + protected clearUpdateCheckStallTimer(): void { + if (!this.updateCheckStallTimer) { + return + } + clearTimeout(this.updateCheckStallTimer) + this.updateCheckStallTimer = null + } + + protected clearUpdateCheckSilentSettleTimer(): void { + if (!this.updateCheckSilentSettleTimer) { + return + } + clearTimeout(this.updateCheckSilentSettleTimer) + this.updateCheckSilentSettleTimer = null + } + + protected clearUpdateCheckTimers(): void { + this.clearUpdateCheckStallTimer() + this.clearUpdateCheckSilentSettleTimer() + } + + protected finishActiveUpdateCheckAttempt(): void { + this.activeUpdateCheckAttemptId = null + this.activeUpdateCheckLaunchAttemptId = null + this.activeUpdateCheckEventAttemptId = null + this.clearUpdateCheckTimers() + } + + protected getActiveUpdateCheckEventAttemptId(): number | null { + if (this.activeUpdateCheckAttemptId === null) { + return null + } + if (this.activeUpdateCheckEventAttemptId !== this.activeUpdateCheckAttemptId) { + return null + } + return this.activeUpdateCheckAttemptId + } + + protected isActiveUpdateCheckAttempt(attemptId: number): boolean { + return this.activeUpdateCheckAttemptId === attemptId + } + + protected markUpdateCheckEventAttempt(): boolean { + if (this.activeUpdateCheckAttemptId === null) { + return false + } + if (this.activeUpdateCheckLaunchAttemptId !== this.activeUpdateCheckAttemptId) { + return false + } + this.activeUpdateCheckEventAttemptId = this.activeUpdateCheckAttemptId + return true + } + + protected markUpdateCheckLaunched(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.activeUpdateCheckLaunchAttemptId = attemptId + } + + protected markUpdateAvailableEventPending(attemptId: number | null): void { + this.updateAvailableEventPendingAttemptId = attemptId + } + + protected clearUpdateAvailableEventPending(attemptId: number | null): void { + if (this.updateAvailableEventPendingAttemptId !== attemptId) { + return + } + this.updateAvailableEventPendingAttemptId = null + } + + protected armUpdateCheckStallTimer(attemptId: number): void { + this.clearUpdateCheckStallTimer() + this.updateCheckStallTimer = setTimeout(() => { + this.updateCheckStallTimer = null + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + if (this.currentStatus.state === 'checking') { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + void this.sendCheckFailureStatus( + 'Update check timed out. Try again in a few minutes.', + wasUserInitiated, + 'promise' + ) + return + } + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } + }, UPDATE_CHECK_STALL_TIMEOUT_MS) + } + + protected beginUpdateCheckAttempt(): number { + this.finishActiveUpdateCheckAttempt() + this.updateAvailableEventPendingAttemptId = null + this.updateCheckAttemptSequence += 1 + this.activeUpdateCheckAttemptId = this.updateCheckAttemptSequence + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. + writeMainThreadDiagnosticMarker('updater-check-attempt') + return this.activeUpdateCheckAttemptId + } + + protected rearmActiveUpdateCheckStallTimer(): void { + if (this.activeUpdateCheckAttemptId === null) { + return + } + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + } + + protected getSettledCheckUserInitiated(): boolean | undefined { + return this.userInitiatedCheck || this.backgroundCheckPromotedToUserInitiated || undefined + } + + protected isUpdateCheckResultState(state: UpdateStatus['state']): boolean { + return ( + state === 'idle' || + state === 'not-available' || + state === 'available' || + state === 'error' || + state === 'downloading' || + state === 'downloaded' + ) + } + + protected consumeSilentCheckShortRetryReason(): boolean { + if (this.publishingWindowLastGoodCheck !== null) { + return true + } + return this.consumeMissingManifestPrereleaseFallbackResult() !== null + } + + protected completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { + const shouldRetrySoon = this.consumeSilentCheckShortRetryReason() + this.clearAvailableUpdateContext() + if (shouldRetrySoon) { + // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + return true + } + this.recordCompletedUpdateCheck() + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + return false + } + + protected settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + if (this.updateAvailableEventPendingAttemptId === attemptId) { + return + } + if (this.currentStatus.state !== 'checking') { + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + const shouldRetrySoon = this.completeSilentUpdateCheck(userInitiated) + if (this.awaitingNudgeCheckOutcome) { + if (shouldRetrySoon) { + this.deferPendingUpdateNudgeUntilRetry() + return + } + this.sendStatus({ state: 'not-available', userInitiated }) + } + } + return + } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.completeSilentUpdateCheck(userInitiated) + this.sendStatus({ state: 'not-available', userInitiated }) + } + + protected handleSettledUpdateCheckPromise(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.clearUpdateCheckSilentSettleTimer() + // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. + this.updateCheckSilentSettleTimer = setTimeout(() => { + this.updateCheckSilentSettleTimer = null + this.settleSilentUpdateCheck(attemptId, this.getSettledCheckUserInitiated()) + }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) + } + + protected shouldHandleUpdaterErrorEvent(): boolean { + if (this.getActiveUpdateCheckEventAttemptId() !== null) { + return true + } + // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. + return ( + this.downloadInFlight || + this.currentStatus.state === 'downloading' || + this.currentStatus.state === 'downloaded' + ) + } + + protected sendErrorStatus(message: string, userInitiated?: boolean): void { + if ( + this.currentStatus.state === 'error' && + this.currentStatus.message === message && + this.currentStatus.userInitiated === userInitiated + ) { + return + } + // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. + if (isWindowsSignatureCheckUnavailableFailure(message)) { + recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { + level: 'warn', + message: 'Windows update signature check could not run' + }) + } + this.sendStatus({ state: 'error', message, userInitiated }) + } + + protected abstract consumeMissingManifestPrereleaseFallbackResult(): { + userInitiated: boolean + } | null + protected abstract recordCompletedUpdateCheck(): void + protected abstract sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source?: 'event' | 'promise' | 'fallback-promise', + sourceError?: unknown + ): Promise + protected abstract scheduleAutomaticUpdateCheck(delayMs: number): void +} diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts new file mode 100644 index 00000000000..a1627d3895c --- /dev/null +++ b/src/main/updater/updater-download-install.ts @@ -0,0 +1,93 @@ +import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { QUIT_AND_INSTALL_DELAY_MS } from './updater-state' +import { UpdaterRemoteStatus } from './updater-remote-status' + +/** Coordinates renderer-facing download/install actions and their duplicate guards. */ +export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { + protected quitAndInstall(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so without this a second click would schedule a parallel install of the same package. + this.linuxPackageRevalidationInFlight + ) { + return + } + + const retriedRecovery = this.getActiveLinuxPackageRecovery() + if (retriedRecovery) { + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'retry-automatic', + packageType: retriedRecovery.packageType, + version: retriedRecovery.version + }) + } + + if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { + return + } + if ( + deferMacQuitUntilInstallerReady( + this.currentStatus, + this.hasInstallableDownloadedVersion(), + () => this.getPendingInstallVersion(), + (status) => this.sendStatus(status) + ) + ) { + return + } + + // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. + this.pendingQuitAndInstallTimer = setTimeout(() => { + void this.performQuitAndInstall() + }, QUIT_AND_INSTALL_DELAY_MS) + } + + protected downloadUpdate(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.downloadInFlight + ) { + return + } + // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. + const canStart = + this.currentStatus.state === 'available' || + (this.currentStatus.state === 'error' && this.hasInstallableDownloadedVersion()) + if (!canStart) { + return + } + const version = + this.currentStatus.state === 'available' ? this.currentStatus.version : this.availableVersion + if (!version) { + return + } + if (this.deferHeadlessServeInstall('download', version)) { + return + } + this.downloadInFlight = true + const localBuildDownload = this.activeUpdateSource === 'local' + beginMacUpdateDownload() + // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. + this.sendStatus({ state: 'downloading', percent: 0, version }) + this.getAutoUpdater() + .downloadUpdate() + .catch((err) => { + this.downloadInFlight = false + const message = String(err?.message ?? err) + if (localBuildDownload) { + this.sendLocalBuildErrorAndRestore(message) + } else { + this.sendErrorStatus(message) + } + }) + } + + protected isQuittingForUpdate(): boolean { + return this.quittingForUpdate + } +} diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts new file mode 100644 index 00000000000..4522e155865 --- /dev/null +++ b/src/main/updater/updater-install-execution.ts @@ -0,0 +1,228 @@ +import { BrowserWindow } from 'electron' +import { killAllPty } from '../ipc/pty' +import { withUpdaterSpan } from '../observability/instrumentation' +import { runWithLaunchPath } from '../startup/hydrate-shell-path' +import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { + beginLinuxPackageInstallDiagnosticCapture, + endLinuxPackageInstallDiagnosticCapture +} from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff' +import { UpdaterPackageRecovery } from './updater-package-recovery' + +export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { + protected async performQuitAndInstall(): Promise { + if (this.quitAndInstallInProgress || this.linuxPackageRevalidationInFlight) { + recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) + return + } + + if (this.pendingQuitAndInstallTimer) { + clearTimeout(this.pendingQuitAndInstallTimer) + this.pendingQuitAndInstallTimer = null + } + + const pendingVersion = this.getPendingInstallVersion() + if (this.deferHeadlessServeInstall('install', pendingVersion)) { + return + } + // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about + // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a + // swapped or vanished package aborts instead of being installed as root. The synchronous guard + // keeps every non-Linux install on its existing timing. + if ( + getTrackedLinuxPackageArtifact() && + !(await this.proveRetainedLinuxPackage(pendingVersion)) + ) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + return + } + this.quitAndInstallInProgress = true + + markMacQuitAndInstallInFlight() + + // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. + this.quittingForUpdate = true + + try { + await withUpdaterSpan({ stage: 'install' }, async (span) => { + span.setAttribute('updater.version', pendingVersion || 'unknown') + span.setAttribute('updater.platform', process.platform) + span.setAttribute( + 'updater.macosInstallerReady', + process.platform === 'darwin' ? isMacInstallerReady() : true + ) + recordUpdaterLifecycle('quit_and_install_started', { + version: pendingVersion || null, + macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true + }) + span.addEvent('pre_quit_cleanup_start') + await this.runBeforeUpdateQuitCleanup() + span.addEvent('pre_quit_cleanup_done') + + if ( + this.updateInstallMode === 'supervised-headless-serve' && + !requestServeUpdateHandoff(pendingVersion) + ) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { version: pendingVersion || null }, + { + level: 'warn', + message: 'Could not persist supervised serve update handoff' + } + ) + this.sendErrorStatus( + 'Could not prepare the supervised server restart. Orca remains running.', + true + ) + this.resetQuitForUpdateState() + // Why: a bare return would exit this span Success and hide the aborted install from tracing. + span.fail('Could not persist the supervised serve update handoff') + return + } + + recordUpdaterLifecycle('quit_and_install_invoking_native', { + version: pendingVersion || null + }) + // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. + if (!this.quitAndInstallInProgress) { + return + } + // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + this.quitAndInstallNativeInvoked = true + // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. + const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' + // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. + beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) + try { + runWithLaunchPath(() => + this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) + } finally { + const diagnostic = endLinuxPackageInstallDiagnosticCapture() + // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. + this.lastInstallAttemptDiagnostic = this.quitAndInstallInProgress ? diagnostic : null + } + span.addEvent('native_quit_and_install_invoked') + + // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. + if (!this.quitAndInstallInProgress) { + // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. + span.fail( + this.currentStatus.state === 'error' + ? this.currentStatus.message + : 'quitAndInstall returned without invoking the installer' + ) + return + } + + // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the + // package is installed. Commit here or a throw in the cleanup below is reported as an install + // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. + if (getLinuxRootPackageType() !== null) { + this.updateInstallCommitted = true + armUpdateInstallExitWatchdog() + } + + killAllPty() + span.addEvent('local_pty_kill_all') + + for (const win of BrowserWindow.getAllWindows()) { + win.removeAllListeners('close') + } + span.addEvent('window_close_listeners_removed', { + windowCount: BrowserWindow.getAllWindows().length + }) + + // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. + if ( + !this.updateInstallCommitted && + (process.platform !== 'darwin' || isMacInstallerReady()) + ) { + this.updateInstallCommitted = true + // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). + armUpdateInstallExitWatchdog() + } + }) + } catch (error) { + // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is + // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear + // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. + if (this.updateInstallCommitted) { + recordUpdaterLifecycle( + 'post_commit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install cleanup failed after commit; install already applied' + } + ) + return + } + // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. + const quitAndInstallNativeInvokedBeforeReset = this.quitAndInstallNativeInvoked + const recoveryStatus = + quitAndInstallNativeInvokedBeforeReset && !this.updateInstallCommitted + ? this.buildLinuxPackageInstallFailureStatus(error) + : null + failServeUpdateHandoff('Could not invoke the native updater.') + this.resetQuitForUpdateState() + recordUpdaterLifecycle( + 'quit_and_install_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Could not start update install' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. + // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. + message: quitAndInstallNativeInvokedBeforeReset + ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + : 'Could not restart to install the update. Quit and reopen Orca, then try again.' + } + ) + } + } + + // Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. + protected handleQuitAndInstallFailure(error?: unknown): boolean { + if ( + !this.quitAndInstallInProgress || + !this.quitAndInstallNativeInvoked || + this.updateInstallCommitted + ) { + return false + } + const recoveryStatus = this.buildLinuxPackageInstallFailureStatus(error) + failServeUpdateHandoff('The native updater rejected the install request.') + this.resetQuitForUpdateState() + // Durable data carries classification only — the cause text stays on the status the user can read. + recordUpdaterLifecycle( + 'quit_and_install_failed_via_event', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install could not start; recovered app state' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + } + ) + return true + } +} diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts new file mode 100644 index 00000000000..ce6b4d3f398 --- /dev/null +++ b/src/main/updater/updater-install-support.ts @@ -0,0 +1,174 @@ +import { app } from 'electron' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../../shared/updater-windows-signature-check' +import { redactLinuxPackageInstallText } from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { resetMacInstallState } from '../updater-mac-install' +import type { + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { compareVersions } from '../updater-fallback' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { UpdaterCheckState } from './updater-check-state' + +export abstract class UpdaterInstallSupport extends UpdaterCheckState { + protected getKnownReleaseUrl(): string | undefined { + return this.availableReleaseUrl ?? undefined + } + + protected hasInstallableDownloadedVersion(): boolean { + return ( + this.availableVersion !== null && + // Why: local builds and pinned dev jumps may intentionally move backwards. + (this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + compareVersions(this.availableVersion, app.getVersion()) > 0) + ) + } + + protected getPendingInstallVersion(): string { + if (this.availableVersion) { + return this.availableVersion + } + if (this.currentStatus.state === 'downloading' || this.currentStatus.state === 'downloaded') { + return this.currentStatus.version + } + return '' + } + + protected deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { + if (this.updateInstallMode !== 'unsupported-headless-serve') { + return false + } + const diagnosticVersion = version || 'unknown' + if (this.lastInstallDeferralVersion[phase] !== diagnosticVersion) { + this.lastInstallDeferralVersion[phase] = diagnosticVersion + recordUpdaterLifecycle( + 'headless_serve_install_deferred', + { phase, version: version || null }, + { + level: 'warn', + message: 'Update install deferred while hosting orca serve' + } + ) + } + this.sendErrorStatus( + 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', + true + ) + return true + } + + protected getCheckFailureKey(message: string, userInitiated?: boolean): string { + return `${userInitiated ? 'user' : 'auto'}:${message}` + } + + protected resetQuitForUpdateState(): void { + this.quitAndInstallInProgress = false + this.quittingForUpdate = false + this.updateInstallCommitted = false + this.quitAndInstallNativeInvoked = false + this.lastInstallAttemptDiagnostic = null + disarmUpdateInstallExitWatchdog() + resetMacInstallState() + } + + /** + * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage + * it — so keep that advice there. Everywhere else a restart is not known to help. + */ + protected getPreCommitInstallFailureMessage(): string { + return process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' + } + + /** + * Sends an install-failure status even when it repeats the current one. "Try Automatic Install + * Again" usually fails identically, and a deduped status would never reach the preload abort relay, + * leaving the renderer stuck in its restart checkpoint. + */ + protected sendInstallFailureStatus(status: UpdateStatus): void { + this.sendStatus(status, { force: true }) + } + + /** + * Appends the updater's own text to the generic install-failure copy. Without it the only record of + * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` + * command the user has to run by hand, and remote clients get nothing but "it didn't come back". + */ + protected withInstallFailureCause(baseMessage: string, error: unknown): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + // Why: the retained-package card runs its text through this same sanitizer, so a home directory, + // user name, or terminal escape must not reach the card merely because no artifact was tracked. + const redacted = + redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' + const cause = redacted.slice(0, this.installFailureCauseMaxLength) + if (!cause || cause === 'Unknown error') { + return baseMessage + } + // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. + if ( + isWindowsSignatureCheckUnavailableFailure(cause) || + isWindowsSignatureMismatchFailure(cause) + ) { + return cause + } + return `${baseMessage} (${cause})` + } + + /** + * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can + * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. + */ + protected isQuitAndInstallHandoffActive(): boolean { + return this.quitAndInstallInProgress + } + + protected async runBeforeUpdateQuitCleanup(): Promise { + if (!this.onBeforeQuitCleanup) { + return + } + + let timeout: ReturnType | null = null + const cleanup = Promise.resolve() + .then(() => this.onBeforeQuitCleanup?.()) + .catch((error) => { + recordUpdaterLifecycle( + 'pre_quit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Pre-quit cleanup failed; continuing update install' + } + ) + }) + const timeoutResult = new Promise<'timeout'>((resolve) => { + timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + }) + + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { + level: 'warn', + message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + } + ) + return + } + + if (timeout) { + clearTimeout(timeout) + } + } + + protected abstract getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null +} diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts new file mode 100644 index 00000000000..b76d5c19710 --- /dev/null +++ b/src/main/updater/updater-menu-checks.ts @@ -0,0 +1,100 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import { UpdaterScheduling } from './updater-scheduling' + +/** Handles checks initiated from the desktop menu and modifier-key variants. */ +export abstract class UpdaterMenuChecks extends UpdaterScheduling { + protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + if (options?.localBuild) { + void this.checkForLocalBuildFromMenu() + return + } + if (options?.targetTag && options.channel) { + void this.checkForPinnedBuild(options.channel, options.targetTag) + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + if ( + this.activeUpdateSource !== 'release' && + (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') + ) { + return + } + this.restoreReleaseUpdateSource() + + const checkVariant = this.getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { + this.clearPrereleaseFallbackContext() + this.enableIncludePrerelease() + } else if (checkVariant === 'perf') { + this.clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. + this.enablePrereleaseManifestChecks() + } + + const checkAlreadyInFlight = + this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking' + this.userInitiatedCheck = true + // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. + this.activeUpdateNudgeId = null + // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. + this.sendStatus({ state: 'checking', userInitiated: true }) + if (checkAlreadyInFlight) { + this.backgroundCheckPromotedToUserInitiated = true + this.rearmActiveUpdateCheckStallTimer() + if (checkVariant !== 'default') { + // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. + this.pendingUserInitiatedCheckAfterInFlight = checkVariant + } + return + } + + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return false + } + this.userInitiatedCheck = false + this.finishActiveUpdateCheckAttempt() + this.recordCompletedUpdateCheck() + this.sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) + void Promise.resolve(run) + .then((launchResult) => { + if (launchResult === false) { + return + } + this.handleSettledUpdateCheckPromise(attemptId) + }) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.userInitiatedCheck = false + void this.sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) + }) + } + + protected abstract checkForLocalBuildFromMenu(): Promise + protected abstract checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise +} diff --git a/src/main/updater/updater-nudge.ts b/src/main/updater/updater-nudge.ts new file mode 100644 index 00000000000..765af67cd53 --- /dev/null +++ b/src/main/updater/updater-nudge.ts @@ -0,0 +1,86 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { fetchNudge, shouldApplyNudge } from '../updater-nudge' +import { NUDGE_ACTIVATION_COOLDOWN_MS, NUDGE_POLL_INTERVAL_MS } from './updater-state' +import { UpdaterBuildSelection } from './updater-build-selection' + +/** Polls update campaigns and exposes their dismissal actions. */ +export abstract class UpdaterNudge extends UpdaterBuildSelection { + protected async checkForUpdateNudge(): Promise { + if (!app.isPackaged || is.dev) { + return + } + if (this.nudgeCheckInFlight) { + return + } + const now = Date.now() + if (now - this.lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { + return + } + this.lastNudgeCheckAt = now + this.nudgeCheckInFlight = true + try { + const nudge = await fetchNudge() + if (!nudge) { + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + const appVersion = app.getVersion() + const pendingUpdateNudgeId = this._getPendingUpdateNudgeId?.() ?? null + const dismissedUpdateNudgeId = this._getDismissedUpdateNudgeId?.() ?? null + if ( + shouldApplyNudge({ + nudge, + appVersion, + pendingUpdateNudgeId, + dismissedUpdateNudgeId + }) + ) { + this.awaitingNudgeCheckOutcome = true + this._setPendingUpdateNudgeId?.(nudge.id) + this.mainWindowRef?.webContents.send('updater:clearDismissal') + this.runBackgroundUpdateCheck(nudge.id) + } + } finally { + this.nudgeCheckInFlight = false + } + } + + protected scheduleUpdateNudgeCheck(): void { + if (this.nudgeCheckTimer) { + clearTimeout(this.nudgeCheckTimer) + } + this.nudgeCheckTimer = setTimeout(() => { + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + }, NUDGE_POLL_INTERVAL_MS) + } + + protected dismissNudge(): void { + const pendingId = this.activeUpdateNudgeId ?? this._getPendingUpdateNudgeId?.() ?? null + if (pendingId) { + this._setDismissedUpdateNudgeId?.(pendingId) + this.clearPendingUpdateNudge() + } + } + + /** Abandons an un-acted local or pinned update and restores the release feed. */ + protected dismissAvailableUpdate(): void { + if (this.activeUpdateSource === 'release' && !this.isPinnedBuildActive) { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. + if (this.currentStatus.state !== 'available') { + return + } + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. + this.sendStatus({ state: 'idle' }) + } +} diff --git a/src/main/updater/updater-package-recovery.ts b/src/main/updater/updater-package-recovery.ts new file mode 100644 index 00000000000..870d601a5f3 --- /dev/null +++ b/src/main/updater/updater-package-recovery.ts @@ -0,0 +1,274 @@ +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { + getTrackedLinuxPackageArtifact, + clearTrackedLinuxPackageArtifact, + revalidateLinuxPackageForInstall, + resolveLinuxPackageInstallInstructions, + revealLinuxPackage, + type LinuxPackageArtifact, + type LinuxPackageRecoveryUnavailableReason +} from '../linux-package-update-recovery' +import { + getLinuxPackageInstallDiagnostic, + parseLinuxPackageInstallExitCode, + redactLinuxPackageInstallText +} from '../linux-package-install-diagnostic' +import type { + LinuxPackageInstallInstructions, + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { UpdaterInstallSupport } from './updater-install-support' + +const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { + missing: + 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', + // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. + 'not-regular': + 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', + 'hash-mismatch': + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', + 'read-failed': + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', + 'no-sudo': + 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + 'no-package-manager': + 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. + 'invalid-package-path': + 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' +} + +// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. +const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ + 'missing', + 'not-regular', + 'hash-mismatch' +] + +export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { + protected getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { + if (this.currentStatus.state !== 'error') { + return null + } + return this.currentStatus.recovery?.kind === 'linux-package-install' + ? this.currentStatus.recovery + : null + } + + protected recordLinuxPackageRecoveryUnavailable( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): void { + recordUpdaterLifecycle( + 'linux_package_recovery_unavailable', + { reason, packageType: recovery.packageType, version: recovery.version }, + { level: 'warn', message: 'Linux package recovery action unavailable' } + ) + } + + protected failLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): never { + this.recordLinuxPackageRecoveryUnavailable(recovery, reason) + const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] + // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would + // destroy the newer artifact and clobber whatever card replaced this one. + const active = this.getActiveLinuxPackageRecovery() + const stillCurrent = + active?.version === recovery.version && active?.packageType === recovery.packageType + if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { + clearTrackedLinuxPackageArtifact() + this.sendStatus({ state: 'error', message }) + } + throw new Error(message) + } + + /** + * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash + * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. + */ + protected getInstallCycleSignature(): string { + const recovery = this.getActiveLinuxPackageRecovery() + if (recovery) { + return `recovery:${recovery.packageType}:${recovery.version}` + } + return this.currentStatus.state === 'downloaded' + ? `downloaded:${this.currentStatus.version}` + : `state:${this.currentStatus.state}` + } + + /** + * Re-proves the retained package before the install starts. Returns false when the install must be + * abandoned; the artifact is only re-read here, so callers still own every teardown decision. + */ + protected async proveRetainedLinuxPackage(pendingVersion: string): Promise { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return true + } + // Why: an artifact retained from another cycle says nothing about the file electron-updater is + // about to install, so proving it would block a legitimate install on an unrelated digest. + if (pendingVersion && pendingVersion !== artifact.version) { + return true + } + const recovery = this.getActiveLinuxPackageRecovery() + const cycle = this.getInstallCycleSignature() + const reason = await this.revalidateRetainedLinuxPackage(artifact) + if (!reason) { + return true + } + this.reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) + return false + } + + /** The failing reason, or null when the retained package still matches its release digest. */ + protected async revalidateRetainedLinuxPackage( + artifact: LinuxPackageArtifact + ): Promise { + this.linuxPackageRevalidationInFlight = true + try { + const verdict = await revalidateLinuxPackageForInstall(artifact) + return verdict.ok ? null : verdict.reason + } catch (error) { + recordUpdaterLifecycle( + 'linux_package_revalidation_errored', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Could not re-verify the retained update package' } + ) + // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. + return 'read-failed' + } finally { + // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall + // early-return for the rest of the session. + this.linuxPackageRevalidationInFlight = false + } + } + + protected reportLinuxPackageRevalidationFailure({ + artifact, + recovery, + reason, + cycle + }: { + artifact: LinuxPackageArtifact + recovery: LinuxPackageInstallRecovery | null + reason: LinuxPackageRecoveryUnavailableReason + cycle: string + }): void { + recordUpdaterLifecycle( + 'linux_package_revalidation_failed', + { + action: recovery ? 'retry-automatic' : 'restart-to-install', + packageType: artifact.packageType, + version: artifact.version, + reason + }, + { level: 'warn', message: 'Retained update package failed its pre-install digest check' } + ) + // Why: a package proven bad must not stay tracked, but a download that landed during the hash + // owns the slot now and destroying it would force a needless 160 MB redownload. + const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) + if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { + clearTrackedLinuxPackageArtifact() + } + // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been + // replaced must not clobber whatever card the user is looking at now. + if (this.getInstallCycleSignature() !== cycle) { + return + } + this.sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], + // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its + // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. + ...(recovery && !clearsArtifact ? { recovery } : {}) + }) + } + + protected async getLinuxPackageInstallInstructions(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'copy-command', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await resolveLinuxPackageInstallInstructions(recovery) + if (!result.ok) { + // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote + // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). + if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { + this.recordLinuxPackageRecoveryUnavailable(recovery, result.reason) + return { + ok: false, + reason: result.reason, + message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] + } + } + this.failLinuxPackageRecovery(recovery, result.reason) + } + return { ok: true, command: result.command, packageFileName: result.packageFileName } + } + + protected async showLinuxPackage(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'show-package', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await revealLinuxPackage(recovery) + if (!result.ok) { + this.failLinuxPackageRecovery(recovery, result.reason) + } + } + + /** Builds a recoverable status when the native Linux package installer rejects a retained artifact. */ + protected buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return null + } + const pendingVersion = this.getPendingInstallVersion() + if (pendingVersion && pendingVersion !== artifact.version) { + return null + } + const diagnostic = getLinuxPackageInstallDiagnostic() ?? this.lastInstallAttemptDiagnostic + const reason = diagnostic?.reason ?? 'package-install-failed' + const exitCode = parseLinuxPackageInstallExitCode(error) + recordUpdaterLifecycle( + 'linux_package_install_failed', + { + packageType: artifact.packageType, + reason, + ...(exitCode === null ? {} : { exitCode }), + version: artifact.version, + errorType: error instanceof Error ? error.name : typeof error + }, + { level: 'warn', message: 'Linux package install failed; cached package retained' } + ) + const message = + diagnostic?.message ?? + (error instanceof Error + ? redactLinuxPackageInstallText(error.message, artifact.path) + : null) ?? + 'The system package installer did not start.' + return { + state: 'error', + message, + recovery: { + kind: 'linux-package-install', + packageType: artifact.packageType, + reason, + version: artifact.version + } + } + } +} diff --git a/src/main/updater/updater-release-feed.ts b/src/main/updater/updater-release-feed.ts new file mode 100644 index 00000000000..46a34eb49ed --- /dev/null +++ b/src/main/updater/updater-release-feed.ts @@ -0,0 +1,270 @@ +import { app } from 'electron' +import { + fetchNewerReleaseTagsWithReadiness, + getReleaseDownloadUrl +} from '../updater-prerelease-feed' +import { isMissingUpdateManifestFailure, isPrereleaseVersion } from '../updater-fallback' +import type { CheckFailureSource } from './updater-state' +import type { UpdateCheckVariant } from './updater-types' +import { ReleaseFeedPreflightError } from './updater-state' +import { UpdaterInstallExecution } from './updater-install-execution' + +/** Owns concrete release-feed pinning and the one-shot prerelease fallback. */ +export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { + protected clearPrereleaseFallbackContextIfSettled(): void { + if ( + this.pendingPrereleaseFallback?.fallbackResultHandled && + !this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedPrimaryEventFailure && + !this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey + ) { + this.clearPrereleaseFallbackContext() + } + } + + protected getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + return this.pendingPrereleaseFallback.userInitiated + } + + protected markMissingManifestPrereleaseFallbackChecking(): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true + } + + protected consumeMissingManifestPrereleaseFallbackResult(): { userInitiated: boolean } | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + const result = { userInitiated: this.pendingPrereleaseFallback.userInitiated } + this.pendingPrereleaseFallback.fallbackResultHandled = true + this.clearPrereleaseFallbackContextIfSettled() + return result + } + + protected suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected shouldSuppressMissingManifestPrereleaseFallbackEvent( + message: string, + error: unknown + ): boolean { + if (!this.pendingPrereleaseFallback?.retryLaunched) { + return false + } + const failureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + const primaryEventSuppression = this.pendingPrereleaseFallback.suppressedPrimaryEventFailure + if (primaryEventSuppression?.failureKey === failureKey) { + const isPrimaryPromisePair = primaryEventSuppression.error === error + // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. + if (isPrimaryPromisePair || !this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + } + if (this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + return false + } + + protected markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected async pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' + ): Promise<'ready' | 'not-available'> { + const autoUpdater = this.getAutoUpdater() + // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). + const currentVersion = app.getVersion() + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || this.includePrereleaseActive || isPrereleaseVersion(currentVersion) + const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( + currentVersion, + includePrerelease ? 2 : 1, + { + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) + } + ) + const newerTag = releaseTagsResult.tags[0] ?? null + const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null + this.pendingPrereleaseFallback = + includePrerelease && newerTag && fallbackTag + ? { + primaryTag: newerTag, + fallbackTag, + userInitiated: false, + suppressedPrimaryPromiseFailureKey: null, + suppressedPrimaryEventFailure: null, + suppressedFallbackPromiseFailureKey: null, + suppressedFallbackEventFailureKey: null, + fallbackResultHandled: false, + fallbackCheckingForUpdateSeen: false, + retryLaunched: false + } + : null + // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. + if (newerTag) { + this.clearPublishingWindowLastGoodCheck() + const url = getReleaseDownloadUrl(newerTag) + console.info( + `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + if (releaseTagsResult.state === 'not-ready') { + this.clearPrereleaseFallbackContext() + if (releaseTagsResult.lastGoodTag) { + // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. + const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) + console.info( + `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + this.publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + this.clearPublishingWindowLastGoodCheck() + console.info( + `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` + ) + throw new ReleaseFeedPreflightError( + 'release-not-ready', + isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', + 'Latest release artifacts are not ready' + ) + } + if ( + releaseTagsResult.state === 'unavailable' && + releaseTagsResult.unavailableReason === 'manifest' && + !includePrerelease + ) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + throw new ReleaseFeedPreflightError( + 'manifest-unavailable', + 'default', + 'Unable to find latest version on GitHub' + ) + } + if (isPerfCheck) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') + } + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + const url = 'https://github.com/stablyai/orca/releases/latest/download' + console.info( + `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + + protected retryPrereleaseFallbackAfterMissingManifest( + message: string, + userInitiated: boolean | undefined, + source: CheckFailureSource, + failureKey: string, + sourceError?: unknown + ): boolean { + if ( + !this.pendingPrereleaseFallback || + this.pendingPrereleaseFallback.retryLaunched || + !isMissingUpdateManifestFailure(message) + ) { + return false + } + const attemptId = this.activeUpdateCheckAttemptId + if (attemptId === null) { + return false + } + // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. + this.pendingPrereleaseFallback.retryLaunched = true + this.pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = + source === 'event' ? failureKey : null + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = + source === 'promise' ? { failureKey, error: sourceError } : null + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false + const { primaryTag, fallbackTag } = this.pendingPrereleaseFallback + const url = getReleaseDownloadUrl(fallbackTag) + console.info( + `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` + ) + const autoUpdater = this.getAutoUpdater() + autoUpdater.setFeedURL({ provider: 'generic', url }) + this.userInitiatedCheck = Boolean(userInitiated) + this.backgroundCheckLaunchPending = !userInitiated + this.armUpdateCheckStallTimer(attemptId) + this.markUpdateCheckLaunched(attemptId) + void autoUpdater + .checkForUpdates() + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const fallbackMessage = String(err?.message ?? err) + if (userInitiated) { + this.userInitiatedCheck = false + } else { + this.backgroundCheckLaunchPending = false + } + this.markMissingManifestPrereleaseFallbackPromiseHandled(fallbackMessage) + this.consumeMissingManifestPrereleaseFallbackResult() + void this.sendCheckFailureStatus(fallbackMessage, userInitiated, 'fallback-promise', err) + }) + return true + } +} diff --git a/src/main/updater/updater-remote-status.ts b/src/main/updater/updater-remote-status.ts new file mode 100644 index 00000000000..6e3db76b0c8 --- /dev/null +++ b/src/main/updater/updater-remote-status.ts @@ -0,0 +1,102 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { hasServeUpdateSupervisor } from '../serve-update-handoff' +import { UpdaterNudge } from './updater-nudge' +import type { UpdateInstallMode } from './updater-state' + +/** Exposes updater state to runtime RPC callers without leaking internal mutators. */ +export abstract class UpdaterRemoteStatus extends UpdaterNudge { + protected getUpdateStatus(): UpdateStatus { + return this.currentStatus + } + + protected getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + if (!app.isPackaged || is.dev) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'unpackaged-build' + } + } + if (!this.autoUpdaterInitialized) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'updater-unavailable' + } + } + if (this.updateInstallMode === 'unsupported-headless-serve') { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'manual-service-update-required' + } + } + return { installMode: this.updateInstallMode, automatic: true, reason: 'available' } + } + + protected getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return { + appVersion: app.getVersion(), + runtimeId, + support: this.getRemoteServerUpdateSupport(), + status: this.getUpdateStatus() + } + } + + protected assertRemoteServerUpdateAvailable(): void { + if (!this.getRemoteServerUpdateSupport().automatic) { + throw new Error('remote_update_manual_required') + } + } + + protected checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + this.checkForUpdatesFromMenu(options) + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'available') { + throw new Error('remote_update_not_available') + } + this.downloadUpdate() + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'downloaded') { + throw new Error('remote_update_not_downloaded') + } + const targetVersion = this.currentStatus.version + const result: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: app.getVersion(), + targetVersion, + runtimeId + } + this.quitAndInstall() + return result + } + + protected resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + if (!isServeMode) { + return 'interactive' + } + return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' + } + + protected abstract downloadUpdate(): void + protected abstract quitAndInstall(): void +} diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts new file mode 100644 index 00000000000..954431385d0 --- /dev/null +++ b/src/main/updater/updater-scheduling.ts @@ -0,0 +1,121 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { withUpdaterSpan } from '../observability/instrumentation' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS +} from './updater-state' +import { UpdaterCheckFailure } from './updater-check-failure' + +/** Owns timer-driven checks and the shared check-launch bookkeeping. */ +export abstract class UpdaterScheduling extends UpdaterCheckFailure { + protected getAutomaticRetryInterval(): number { + return AUTO_UPDATE_RETRY_INTERVAL_MS + } + + protected scheduleAutomaticUpdateCheck(delayMs: number): void { + let effectiveDelayMs = delayMs + // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. + if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { + effectiveDelayMs = Math.min( + AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** this.consecutiveAutomaticRetrySchedules, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS + ) + this.consecutiveAutomaticRetrySchedules += 1 + } + if (this.autoUpdateCheckTimer) { + clearTimeout(this.autoUpdateCheckTimer) + } + this.autoUpdateCheckTimer = setTimeout(() => { + // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. + if (!this.runBackgroundUpdateCheck()) { + // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + }, effectiveDelayMs) + } + + protected recordCompletedUpdateCheck(): void { + this.consecutiveAutomaticRetrySchedules = 0 + this.persistLastUpdateCheckAt?.(Date.now()) + } + + /** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ + protected runBackgroundUpdateCheck( + nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() + ): boolean { + // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. + if ( + this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress + ) { + return false + } + if (this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking') { + return false + } + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available' }) + return false + } + // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. + this.activeUpdateNudgeId = nudgeId + // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. + this.backgroundCheckLaunchPending = true + this.backgroundCheckPromotedToUserInitiated = false + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed().then(launch) + void Promise.resolve(run) + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + if (wasUserInitiated) { + this.userInitiatedCheck = false + } + void this.sendCheckFailureStatus( + String(err?.message ?? err), + wasUserInitiated, + 'promise', + err + ) + }) + return true + } + + protected checkForUpdatesInBackground(): void { + // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. + void withUpdaterSpan({ stage: 'check' }, async (span) => { + span.setAttribute('updater.outcome', 'launched') + this.runBackgroundUpdateCheck() + }) + } + + protected enablePrereleaseManifestChecks(): void { + this.getAutoUpdater().allowPrerelease = true + } + + protected enableIncludePrerelease(): void { + if (this.includePrereleaseActive) { + return + } + // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. + this.enablePrereleaseManifestChecks() + this.includePrereleaseActive = true + } +} diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts new file mode 100644 index 00000000000..21354f97af3 --- /dev/null +++ b/src/main/updater/updater-setup.ts @@ -0,0 +1,251 @@ +import { app, powerMonitor } from 'electron' +import type { BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { ReleaseBuild, ReleaseChannel } from '../../shared/release-channel' +import type { + LinuxPackageInstallInstructions, + UpdateCheckOptions, + UpdateStatus +} from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { createUpdaterDiagnosticLogger } from '../linux-package-install-diagnostic' +import { registerAutoUpdaterHandlers } from '../updater-events' +import { getServeUpdateHandoffFailure } from '../serve-update-handoff' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' +import { UpdaterDownloadInstall } from './updater-download-install' +import type { UpdateInstallMode } from './updater-state' + +export type UpdaterSetupOptions = { + getLastUpdateCheckAt?: () => number | null + onBeforeQuit?: () => void | Promise + setLastUpdateCheckAt?: (timestamp: number) => void + getPendingUpdateNudgeId?: () => string | null + getDismissedUpdateNudgeId?: () => string | null + setPendingUpdateNudgeId?: (id: string | null) => void + setDismissedUpdateNudgeId?: (id: string | null) => void + getReleaseChannelOverride?: () => ReleaseChannel | null + installMode?: UpdateInstallMode +} + +/** Initializes electron-updater and attaches lifecycle/event bridges. */ +export class UpdaterSetup extends UpdaterDownloadInstall { + checkForUpdates(): void { + this.checkForUpdatesInBackground() + } + + checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + super.checkForUpdatesFromMenu(options) + } + + downloadUpdate(): void { + super.downloadUpdate() + } + + quitAndInstall(): void { + super.quitAndInstall() + } + + isQuittingForUpdate(): boolean { + return super.isQuittingForUpdate() + } + + getUpdateStatus(): UpdateStatus { + return super.getUpdateStatus() + } + + getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + return super.getRemoteServerUpdateSupport() + } + + getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.getRemoteServerUpdaterSnapshot(runtimeId) + } + + checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + return super.checkForRemoteServerUpdate(runtimeId, options) + } + + downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.downloadRemoteServerUpdate(runtimeId) + } + + installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + return super.installRemoteServerUpdate(runtimeId) + } + + resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + return super.resolveUpdateInstallMode(isServeMode) + } + + async getLinuxPackageInstallInstructions(): Promise { + return super.getLinuxPackageInstallInstructions() + } + + async showLinuxPackage(): Promise { + return super.showLinuxPackage() + } + + async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return super.listAvailableReleaseBuilds(channel) + } + + dismissNudge(): void { + super.dismissNudge() + } + + dismissAvailableUpdate(): void { + super.dismissAvailableUpdate() + } + + setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + this.mainWindowRef = mainWindow + this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null + this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null + this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null + this._getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null + this._setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null + this._setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null + this.getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null + this.updateInstallMode = opts?.installMode ?? 'interactive' + this.lastInstallDeferralVersion = { download: null, install: null } + + const serveHandoffFailure = getServeUpdateHandoffFailure() + if (serveHandoffFailure) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { reason: serveHandoffFailure }, + { level: 'warn', message: 'Supervised serve update did not complete' } + ) + this.sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) + } + + if (!app.isPackaged && !is.dev) { + return + } + if (is.dev) { + return + } + + const autoUpdater = this.getAutoUpdater() + autoUpdater.autoDownload = false + if (this.activeUpdateSource === 'release') { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } + // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. + // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. + autoUpdater.autoInstallOnAppQuit = + this.updateInstallMode === 'interactive' && getLinuxRootPackageType() === null + // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. + autoUpdater.autoRunAppAfterInstall = this.updateInstallMode === 'interactive' + // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. + autoUpdater.logger = createUpdaterDiagnosticLogger() as never + + // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. + if (this.activeUpdateSource === 'release') { + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + if (this.autoUpdaterInitialized) { + return + } + this.autoUpdaterInitialized = true + + registerAutoUpdaterHandlers({ + autoUpdater, + clearBackgroundCheckLaunchPending: () => this.clearBackgroundCheckLaunchPending(), + clearAvailableUpdateContext: () => this.clearAvailableUpdateContext(), + consumeMissingManifestPrereleaseFallbackResult: () => + this.consumeMissingManifestPrereleaseFallbackResult(), + getPublishingWindowLastGoodCheck: () => this.getPublishingWindowLastGoodCheck(), + getMissingManifestPrereleaseFallbackUserInitiated: () => + this.getMissingManifestPrereleaseFallbackUserInitiated(), + getCurrentStatus: () => this.currentStatus, + getActiveUpdateCheckEventAttemptId: () => this.getActiveUpdateCheckEventAttemptId(), + getKnownReleaseUrl: () => this.getKnownReleaseUrl(), + getPendingInstallVersion: () => this.getPendingInstallVersion(), + getUserInitiatedCheck: () => this.userInitiatedCheck, + handleQuitAndInstallFailure: (error) => this.handleQuitAndInstallFailure(error), + isQuitAndInstallHandoffActive: () => this.isQuitAndInstallHandoffActive(), + hasInstallableDownloadedVersion: () => this.hasInstallableDownloadedVersion(), + isLocalBuildCheck: () => this.activeUpdateSource === 'local', + // Why: pinned jumps are deliberate, so update-available/-downloaded must not reject them for being older than the running version. + isPinnedBuildCheck: () => this.isPinnedBuildActive, + shouldHandleUpdaterErrorEvent: () => this.shouldHandleUpdaterErrorEvent(), + clearUpdateAvailableEventPending: (attemptId) => + this.clearUpdateAvailableEventPending(attemptId), + isActiveUpdateCheckAttempt: (attemptId) => this.isActiveUpdateCheckAttempt(attemptId), + markUpdateCheckEventAttempt: () => this.markUpdateCheckEventAttempt(), + markUpdateAvailableEventPending: (attemptId) => + this.markUpdateAvailableEventPending(attemptId), + markMissingManifestPrereleaseFallbackChecking: () => + this.markMissingManifestPrereleaseFallbackChecking(), + performQuitAndInstall: () => this.performQuitAndInstall(), + shouldDeferMacQuitForInstall: () => this.updateInstallMode === 'interactive', + recordCompletedUpdateCheck: () => this.recordCompletedUpdateCheck(), + restoreReleaseUpdateSource: () => this.restoreReleaseUpdateSource(), + sendCheckFailureStatus: (message, userInitiated, source, sourceError) => + this.sendCheckFailureStatus(message, userInitiated, source, sourceError), + sendErrorStatus: (message, userInitiated) => this.sendErrorStatus(message, userInitiated), + sendStatus: (status) => this.sendStatus(status), + scheduleAutomaticUpdateCheck: (delayMs) => this.scheduleAutomaticUpdateCheck(delayMs), + shouldSuppressMissingManifestPrereleaseFallbackEvent: (message, error) => + this.shouldSuppressMissingManifestPrereleaseFallbackEvent(message, error), + suppressMissingManifestPrereleaseFallbackPromiseFailure: (message) => + this.suppressMissingManifestPrereleaseFallbackPromiseFailure(message), + setAvailableReleaseUrl: (releaseUrl) => { + this.availableReleaseUrl = releaseUrl + }, + setAvailableVersion: (version) => { + this.availableVersion = version + }, + setUserInitiatedCheck: (value) => { + this.userInitiatedCheck = value + } + }) + + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + + const checkDailyOnWake = () => { + void this.checkForUpdateNudge() + if ( + this.backgroundCheckLaunchPending || + this.currentStatus.state === 'checking' || + this.currentStatus.state === 'downloading' + ) { + return + } + const lastCheck = this._getLastUpdateCheckAt?.() ?? null + const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck + if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + } + powerMonitor.on('resume', checkDailyOnWake) + app.on('browser-window-focus', checkDailyOnWake) + + const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null + const msSinceLastCheck = + lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt + if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } else { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) + } + } +} diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts new file mode 100644 index 00000000000..a410c5e10b8 --- /dev/null +++ b/src/main/updater/updater-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow } from 'electron' +import type { ElectronAutoUpdater } from '../electron-updater-loader' +import type { LinuxPackageInstallDiagnostic } from '../linux-package-install-diagnostic' +import type { LocalBuildFeed } from '../local-builds/local-build-feed-server' +import type { UpdateSource, UpdateStatus } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import type { PrimaryEventSuppression, UpdateCheckVariant } from './updater-types' + +export const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. +export const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 +export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 +export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 +export const QUIT_AND_INSTALL_DELAY_MS = 100 +export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 +export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 + +export type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' +export type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } +export type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' +export type ReleaseFeedPreflightResult = 'ready' | 'not-available' +export type UpdateInstallMode = + | 'interactive' + | 'supervised-headless-serve' + | 'unsupported-headless-serve' + +// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. +export class ReleaseFeedPreflightError extends Error { + constructor( + readonly reason: ReleaseFeedPreflightFailure, + readonly releaseChannel: UpdateCheckVariant, + message: string + ) { + super(message) + this.name = 'ReleaseFeedPreflightError' + } +} + +export abstract class UpdaterState { + protected mainWindowRef: BrowserWindow | null = null + protected currentStatus: UpdateStatus = { state: 'idle' } + protected userInitiatedCheck = false + protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected autoUpdaterInitialized = false + // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. + protected includePrereleaseActive = false + protected availableVersion: string | null = null + protected availableReleaseUrl: string | null = null + protected pendingCheckFailureKey: string | null = null + protected pendingCheckFailurePromise: Promise | null = null + protected autoUpdateCheckTimer: ReturnType | null = null + protected nudgeCheckTimer: ReturnType | null = null + protected pendingQuitAndInstallTimer: ReturnType | null = null + protected quitAndInstallInProgress = false + // Why: the pre-install digest re-proof streams the whole package, so a second install request can + // arrive while it runs — after the quit timer was cleared but before the handoff owns the process. + protected linuxPackageRevalidationInFlight = false + protected updateInstallMode: UpdateInstallMode = 'interactive' + protected lastInstallDeferralVersion = { + download: null as string | null, + install: null as string | null + } + // Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. + protected updateInstallCommitted = false + // Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. + protected quitAndInstallNativeInvoked = false + // Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. + protected lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null + protected persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null + protected _getLastUpdateCheckAt: (() => number | null) | null = null + protected backgroundCheckLaunchPending = false + // Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. + protected backgroundCheckPromotedToUserInitiated = false + protected updateCheckStallTimer: ReturnType | null = null + protected updateCheckSilentSettleTimer: ReturnType | null = null + protected updateCheckAttemptSequence = 0 + protected activeUpdateCheckAttemptId: number | null = null + protected activeUpdateCheckLaunchAttemptId: number | null = null + protected activeUpdateCheckEventAttemptId: number | null = null + protected updateAvailableEventPendingAttemptId: number | null = null + protected pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null + protected activeUpdateNudgeId: string | null = null + protected awaitingNudgeCheckOutcome = false + protected nudgeCheckInFlight = false + protected lastNudgeCheckAt = 0 + protected publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null + protected pendingPrereleaseFallback: { + primaryTag: string + fallbackTag: string + // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. + userInitiated: boolean + suppressedPrimaryPromiseFailureKey: string | null + suppressedPrimaryEventFailure: PrimaryEventSuppression | null + suppressedFallbackPromiseFailureKey: string | null + suppressedFallbackEventFailureKey: string | null + fallbackResultHandled: boolean + fallbackCheckingForUpdateSeen: boolean + retryLaunched: boolean + } | null = null + + protected _getPendingUpdateNudgeId: (() => string | null) | null = null + protected _getDismissedUpdateNudgeId: (() => string | null) | null = null + protected _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null + protected _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null + // Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. + protected downloadInFlight = false + /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ + protected quittingForUpdate = false + protected autoUpdater: ElectronAutoUpdater | null = null + protected activeUpdateSource: 'release' | UpdateSource = 'release' + protected activeLocalBuildFeed: LocalBuildFeed | null = null + protected localBuildSelectionInProgress = false + // Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade + // like local builds — but off a real release feed, not a loopback server. + protected pinnedBuildSelectionInProgress = false + // Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a + // deliberate downgrade, so newer-only gates must yield to it too. + protected isPinnedBuildActive = false + protected getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null + + protected consecutiveAutomaticRetrySchedules = 0 + protected readonly installFailureCauseMaxLength = 200 + + constructor() {} +} diff --git a/src/main/updater/updater-status.ts b/src/main/updater/updater-status.ts new file mode 100644 index 00000000000..26b8f605084 --- /dev/null +++ b/src/main/updater/updater-status.ts @@ -0,0 +1,184 @@ +import { loadElectronAutoUpdater, type ElectronAutoUpdater } from '../electron-updater-loader' +import { statusesEqual } from '../updater-fallback' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterState as BaseUpdaterState } from './updater-state' + +export abstract class UpdaterStatus extends BaseUpdaterState { + protected getAutoUpdater(): ElectronAutoUpdater { + if (!this.autoUpdater) { + this.autoUpdater = loadElectronAutoUpdater() + } + return this.autoUpdater + } + + protected clearAvailableUpdateContext(): void { + this.availableVersion = null + this.availableReleaseUrl = null + } + + protected closeLocalBuildFeed(): void { + const feed = this.activeLocalBuildFeed + this.activeLocalBuildFeed = null + if (feed) { + void feed.close() + } + } + + protected restoreReleaseUpdateSource(): void { + this.closeLocalBuildFeed() + this.activeUpdateSource = 'release' + this.isPinnedBuildActive = false + if (this.autoUpdater) { + this.autoUpdater.allowDowngrade = false + this.autoUpdater.disableDifferentialDownload = false + // Why: a pinned jump forces allowPrerelease on; leaving it set would opt + // every later background check into the RC channel behind the user's back. + this.autoUpdater.allowPrerelease = this.includePrereleaseActive + } + } + + protected sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { + this.clearAvailableUpdateContext() + if ( + this.currentStatus.state !== 'error' || + this.currentStatus.message !== message || + this.currentStatus.userInitiated !== userInitiated || + this.currentStatus.source !== 'local' + ) { + this.sendStatus({ state: 'error', message, userInitiated, source: 'local' }) + } + this.restoreReleaseUpdateSource() + } + + protected clearPrereleaseFallbackContext(): void { + this.pendingPrereleaseFallback = null + } + + protected clearPendingUpdateNudge(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + this._setPendingUpdateNudgeId?.(null) + } + + protected deferPendingUpdateNudgeUntilRetry(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + } + + protected clearPublishingWindowLastGoodCheck(): void { + this.publishingWindowLastGoodCheck = null + } + + protected getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { + return this.publishingWindowLastGoodCheck + } + + protected getPersistedPendingUpdateNudgeId(): string | null { + return this._getPendingUpdateNudgeId?.() ?? null + } + + protected decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { + // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. + if (!this.activeUpdateNudgeId) { + return status + } + if ( + status.state === 'idle' || + status.state === 'checking' || + status.state === 'not-available' + ) { + return status + } + return { ...status, activeNudgeId: this.activeUpdateNudgeId } + } + + /** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ + protected sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { + const pendingUserInitiatedCheckVariant = this.pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + const shouldPreserveNudgeForPublishingWindow = + this.publishingWindowLastGoodCheck !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + if (this.awaitingNudgeCheckOutcome) { + if (status.state === 'available') { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. + this.deferPendingUpdateNudgeUntilRetry() + } else { + this.awaitingNudgeCheckOutcome = false + } + } else if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'error' + ) { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. + this.deferPendingUpdateNudgeUntilRetry() + } else { + // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. + if (this.activeUpdateNudgeId) { + this._setDismissedUpdateNudgeId?.(this.activeUpdateNudgeId) + } + this.clearPendingUpdateNudge() + } + } + } + + const sourcedStatus: UpdateStatus = + this.activeUpdateSource === 'release' + ? status + : { ...status, source: this.activeUpdateSource } + const decoratedStatus = this.decorateStatusWithActiveNudge(sourcedStatus) + + if (this.isUpdateCheckResultState(status.state)) { + this.finishActiveUpdateCheckAttempt() + } + + if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error' + ) { + this.clearPublishingWindowLastGoodCheck() + } + + // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. + if ( + decoratedStatus.state === 'downloading' || + decoratedStatus.state === 'error' || + decoratedStatus.state === 'idle' + ) { + this.downloadInFlight = false + } + if (shouldLaunchPendingUserInitiatedCheck) { + // Why: a forced status must still land before the queued check restarts the cycle. + if (options?.force) { + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + this.launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) + return + } + if (!options?.force && statusesEqual(this.currentStatus, decoratedStatus)) { + return + } + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + + protected abstract finishActiveUpdateCheckAttempt(): void + protected abstract isUpdateCheckResultState(state: UpdateStatus['state']): boolean + protected abstract launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void + protected abstract checkForUpdatesFromMenu(options?: UpdateCheckOptions): void +} diff --git a/src/main/updater/updater-types.ts b/src/main/updater/updater-types.ts new file mode 100644 index 00000000000..aeb91bfdbd1 --- /dev/null +++ b/src/main/updater/updater-types.ts @@ -0,0 +1,2 @@ +export type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +export type PrimaryEventSuppression = { failureKey: string; error: unknown } From fbaa125a35000bb01167b639d4964f85bfb9fccc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:53:08 -0700 Subject: [PATCH 10/93] fix(main): merge split startup imports (cherry picked from commit c0f38db5de0967a094d22f5254f464362023fec0) --- .../codex-accounts/runtime-home-service-managed-home.ts | 6 ++++-- src/main/codex-accounts/runtime-home-service-wsl.ts | 3 +-- src/main/startup/main-process-preflight.ts | 4 ++-- src/main/startup/main-process-ready-foundation.ts | 6 ++++-- src/main/startup/main-window-actions.ts | 3 +-- src/main/startup/main-window-controller.ts | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts index 6324c0e2307..817b2f7adc4 100644 --- a/src/main/codex-accounts/runtime-home-service-managed-home.ts +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -6,7 +6,10 @@ import { } from '../codex/codex-home-paths' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' -import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from '../codex/codex-session-source-home' import { parseWslUncPath } from '../../shared/wsl-paths' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { normalizeCodexRuntimeSelection } from './runtime-selection' @@ -18,7 +21,6 @@ import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-pa import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { getDefaultWslDistro } from '../wsl' -import { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' import type { CodexAccountSelectionTarget } from './runtime-selection' import type { CodexManagedAccount } from '../../shared/managed-account-types' diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts index ea39e40d730..cf0ec32a23f 100644 --- a/src/main/codex-accounts/runtime-home-service-wsl.ts +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -1,5 +1,4 @@ -import { join } from 'node:path' -import { win32 as pathWin32 } from 'node:path' +import { join, win32 as pathWin32 } from 'node:path' import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' import { getCodexSelectionLaneKey, diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index a8a887bd896..eb2a51cb7ff 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -14,7 +14,8 @@ import { installDevParentDisconnectQuit, installDevParentSignalQuit, installDevParentWatchdog, - patchPackagedProcessPath + patchPackagedProcessPath, + optOutOfHiddenPageWakeUpThrottling } from './configure-process' import { installServeSupervisorDisconnectQuit } from '../serve-update-handoff' import { @@ -77,7 +78,6 @@ import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' -import { optOutOfHiddenPageWakeUpThrottling } from './configure-process' import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' import { mainProcessState as state } from './main-process-state' diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index ec7a48753a5..5f0e3c4a80e 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -32,11 +32,13 @@ import { shouldSuppressDevEducation, suppressDevEducationForStore } from './dev-education-suppression' -import { setBrowserNetworkProxySettingsResolver } from '../browser/browser-session-proxy' +import { + applyBrowserSessionProxies, + setBrowserNetworkProxySettingsResolver +} from '../browser/browser-session-proxy' import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' -import { applyBrowserSessionProxies } from '../browser/browser-session-proxy' import { browserSessionRegistry } from '../browser/browser-session-registry' import { logStartupMilestone } from './startup-diagnostics' import { mainProcessState as state } from './main-process-state' diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index c1009d22baa..96751d645db 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -2,13 +2,12 @@ import { app, clipboard, dialog, type BrowserWindow, type Tray } from 'electron' import type { UpdateCheckOptions } from '../../shared/update-status-types' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' -import { isQuittingForUpdate } from '../updater' +import { checkForUpdatesFromMenu, isQuittingForUpdate } from '../updater' import { createSystemTray, setMacMenuBarIconVisible, type SystemTrayOptions } from '../tray/system-tray' -import { checkForUpdatesFromMenu } from '../updater' import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' import { mainProcessState as state } from './main-process-state' diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 177f4231a65..57763b23ab1 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -18,6 +18,7 @@ import { createSystemTrayDeferred, getSystemTrayOptions, showMainWindowFromTray, + showRendererRecoveryPrompt, syncMacMenuBarIcon } from './main-window-actions' import { attachMainWindowCoreServices } from './main-window-core-services' @@ -33,7 +34,6 @@ import { getExpectedTeardownScope, recordProcessGoneCrash } from './main-window-lifecycle-flags' -import { showRendererRecoveryPrompt } from './main-window-actions' import { presentGpuFallbackRecoveredLaunchPrompt } from './gpu-lifecycle' import { maybeAutoRenameBranchOnFirstWorkFromHook } from './branch-rename-hook' import { From d462766cb0d1a60ff2a5c4b28808ea05b36fb41a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:46:19 -0700 Subject: [PATCH 11/93] refactor(main): split filesystem git remote handlers (cherry picked from commit 2146cff06aad7998ecfd5b65b853576e6757c866) --- .../filesystem-git-remote-handlers.ts | 324 +----------------- .../git-remote/branch-mutation-handlers.ts | 158 +++++++++ .../filesystem/git-remote/compare-handlers.ts | 78 +++++ .../filesystem/git-remote/sync-handlers.ts | 118 +++++++ .../child-process-import-allowlist.txt | 10 +- 5 files changed, 367 insertions(+), 321 deletions(-) create mode 100644 src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts create mode 100644 src/main/ipc/filesystem/git-remote/compare-handlers.ts create mode 100644 src/main/ipc/filesystem/git-remote/sync-handlers.ts diff --git a/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts index 83d1422e112..9450094109a 100644 --- a/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts @@ -1,322 +1,10 @@ -import { ipcMain } from 'electron' -import type { - GitBranchCompareResult, - GitCommitCompareResult -} from '../../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../../shared/git-fork-sync' -import type { GitUpstreamStatus } from '../../../shared/git-status-types' -import type { GitPushTarget } from '../../../shared/worktree/types' -import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' -import { getBranchCompare, getCommitCompare } from '../../git/status' -import { gitFetch, gitPush, gitPull, gitFastForward, gitPullRebaseFromBase } from '../../git/remote' -import { gitSyncForkDefaultBranch } from '../../git/fork-sync' -import { getUpstreamStatus } from '../../git/upstream' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../../providers/ssh-git-dispatch' -import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' -import { validateGitPushTarget } from '../../git/push-target-validation' -import { assertGitPushTargetShape } from '../../../shared/git-push-target-validation' -import { validateGitForkSyncExpectedUpstream } from '../../../shared/git-fork-sync' -import { validateFullGitObjectId } from './filesystem-worktree-helpers' import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { registerGitRemoteBranchMutationHandlers } from './git-remote/branch-mutation-handlers' +import { registerGitRemoteCompareHandlers } from './git-remote/compare-handlers' +import { registerGitRemoteSyncHandlers } from './git-remote/sync-handlers' export function registerFilesystemGitRemoteHandlers(context: FilesystemHandlerContext): void { - const { store } = context - ipcMain.handle( - 'git:branchCompare', - async ( - _event, - args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: GitAdmissionTier - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return args.admissionTier - ? provider.getBranchCompare(args.worktreePath, args.baseRef, { - admissionTier: args.admissionTier - }) - : provider.getBranchCompare(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchCompare(worktreePath, args.baseRef, { - ...gitOptions, - ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) - }) - } - ) - - ipcMain.handle( - 'git:commitCompare', - async ( - _event, - args: { worktreePath: string; commitId: string; connectionId?: string } - ): Promise => { - const commitId = validateFullGitObjectId(args.commitId, 'commitId') - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitCompare(args.worktreePath, commitId) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitCompare(worktreePath, commitId, gitOptions) - } - ) - - ipcMain.handle( - 'git:upstreamStatus', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) - } - ) - - ipcMain.handle( - 'git:fetch', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fetchRemote(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFetch(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:syncFork', - async ( - _event, - args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - } - ): Promise => { - const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { - required: true - }) - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:push', - async ( - _event, - args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: GitPushTarget - } - ): Promise => { - // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. - const publish = args.publish === true - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPush(worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true, - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:pull', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pullBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPull(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:fastForward', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFastForward(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:rebaseFromBase', - async ( - _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.rebaseFromBase(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await gitPullRebaseFromBase(worktreePath, args.baseRef, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) + registerGitRemoteCompareHandlers(context) + registerGitRemoteSyncHandlers(context) + registerGitRemoteBranchMutationHandlers(context) } diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts new file mode 100644 index 00000000000..53ba72d58a4 --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -0,0 +1,158 @@ +import { ipcMain } from 'electron' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { gitFastForward, gitPull, gitPullRebaseFromBase, gitPush } from '../../../git/remote' +import { validateGitPushTarget } from '../../../git/push-target-validation' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' + +export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:push', + async ( + _event, + args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: GitPushTarget + } + ): Promise => { + // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. + const publish = args.publish === true + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPush(worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true, + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:pull', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pullBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPull(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:fastForward', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFastForward(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:rebaseFromBase', + async ( + _event, + args: { worktreePath: string; baseRef: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.rebaseFromBase(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await gitPullRebaseFromBase(worktreePath, args.baseRef, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts new file mode 100644 index 00000000000..a522ae10af2 --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -0,0 +1,78 @@ +import { ipcMain } from 'electron' +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../../../shared/git-diff-compare-types' +import { getBranchCompare, getCommitCompare } from '../../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { validateFullGitObjectId } from '../filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' +import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' + +export function registerGitRemoteCompareHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:branchCompare', + async ( + _event, + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) + } + ) + + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitCompare(worktreePath, commitId, gitOptions) + } + ) +} diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts new file mode 100644 index 00000000000..eae79c918dd --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -0,0 +1,118 @@ +import { ipcMain } from 'electron' +import type { + GitForkSyncExpectedUpstream, + GitForkSyncResult +} from '../../../../shared/git-fork-sync' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' +import { gitFetch } from '../../../git/remote' +import { gitSyncForkDefaultBranch } from '../../../git/fork-sync' +import { getUpstreamStatus } from '../../../git/upstream' +import { validateGitPushTarget } from '../../../git/push-target-validation' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' + +export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:upstreamStatus', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) + } + ) + + ipcMain.handle( + 'git:fetch', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fetchRemote(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFetch(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:syncFork', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + } + ): Promise => { + const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { + required: true + }) + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index f634c5b8b56..fe1bccbf9ed 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,8 +41,11 @@ src/main/ai-vault/session-scanner-service-spawn.ts src/main/ai-vault/session-scanner-service-test-child.ts src/main/app-icon.ts src/main/automations/precheck-runner.ts -src/main/browser/agent-browser-bridge.ts -src/main/browser/browser-cookie-import.ts +src/main/browser/agent-browser-bridge-execution.ts +src/main/browser/agent-browser-bridge-raw-process.ts +src/main/browser/agent-browser-bridge-state.ts +src/main/browser/agent-browser-bridge-types.ts +src/main/browser/browser-cookie-key.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts @@ -88,7 +91,8 @@ src/main/ipc/developer-permissions.ts src/main/ipc/filesystem-list-files-git-fallback.ts src/main/ipc/filesystem-list-files.ts src/main/ipc/filesystem-watcher-wsl.ts -src/main/ipc/filesystem.ts +src/main/ipc/filesystem/filesystem-handler-context.ts +src/main/ipc/filesystem/filesystem-search-handlers.ts src/main/ipc/local-network-connection-test.ts src/main/ipc/macos-keyboard-layout-snapshot.ts src/main/ipc/notebook.ts From de84a9f999e7adfe8f2e3d949bd1cc31c73d788e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:47:30 -0700 Subject: [PATCH 12/93] fix(main): resolve runtime lazily in observers (cherry picked from commit 369496b27cb3789c8c8b10c9ec5a5794f84d1e3a) --- src/main/startup/main-process-observers.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index 0521f600605..aa2dd6ea421 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -22,7 +22,6 @@ import { mainProcessState as state } from './main-process-state' export function initializeMainProcessObservers(): void { const store = state.store - const runtime = state.runtime if (!store) { throw new Error('Store must be initialized before observers') } @@ -41,11 +40,11 @@ export function initializeMainProcessObservers(): void { ...identity, worktreeId: identity.worktreeId ?? - runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + state.runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? undefined })) for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) + state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) } } state.publishProviderSessionChanges = publishProviderSessionChanges @@ -58,7 +57,7 @@ export function initializeMainProcessObservers(): void { const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { if (hookStatusChangedSessionTabs(enriched)) { - runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) + state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) } }) const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { @@ -68,7 +67,7 @@ export function initializeMainProcessObservers(): void { : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) for (const paneKey of clearedPaneKeys) { hookStatusChangedSessionTabs.forgetPane(paneKey) - runtime?.touchMobileSessionTabsForPane(paneKey) + state.runtime?.touchMobileSessionTabsForPane(paneKey) } }) state.unsubscribeAgentAwakeStatusChanges = () => { From d5db0bedfc7a3895bf4a90a53cf24a199c6656cd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:03:32 -0700 Subject: [PATCH 13/93] fix(main): route browser cookie key commands through runner (cherry picked from commit e321eccb41e900c4a833389289786a85afaeeff7) --- src/main/browser/browser-cookie-key.ts | 26 +++++++++++-------- .../windows-console-visibility-allowlist.txt | 2 -- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/browser/browser-cookie-key.ts b/src/main/browser/browser-cookie-key.ts index 620b279c140..76fc14cf9e6 100644 --- a/src/main/browser/browser-cookie-key.ts +++ b/src/main/browser/browser-cookie-key.ts @@ -1,4 +1,3 @@ -import { execFileSync } from 'node:child_process' import { pbkdf2Sync } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' @@ -16,6 +15,14 @@ const PBKDF2_ITERATIONS = 1003 const PBKDF2_KEY_LENGTH = 16 const PBKDF2_SALT = 'saltysalt' +function runKeychainCommand(program: string, args: readonly string[], timeoutMs: number): string { + const result = runProcessSync({ program, args, timeoutMs }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`${program} exited with code ${result.code ?? 'unknown'}`) + } + return result.stdout.trim() +} + export function getEncryptionKey( keychainService: string, keychainAccount: string, @@ -38,11 +45,11 @@ export function getMacEncryptionKey( keychainAccount: string ): EncryptionKeyResult | null { try { - const raw = execFileSync( + const raw = runKeychainCommand( 'security', ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], - { encoding: 'utf-8', timeout: 30_000 } - ).trim() + 30_000 + ) return { mode: 'aes-128-cbc', keysByVersion: { @@ -65,19 +72,16 @@ export function getLinuxEncryptionKey( let keyringPassword = '' try { // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. - keyringPassword = execFileSync( + keyringPassword = runKeychainCommand( 'secret-tool', ['lookup', 'service', keychainService, 'account', keychainAccount], - { encoding: 'utf-8', timeout: 5_000 } - ).trim() + 5_000 + ) } catch { // Why: fall back to application-based lookup used by newer Chromium versions. try { const app = keychainAccount.toLowerCase().replaceAll(' ', '') - keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { - encoding: 'utf-8', - timeout: 5_000 - }).trim() + keyringPassword = runKeychainCommand('secret-tool', ['lookup', 'application', app], 5_000) } catch { diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') } diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index cd34d52f61c..752a762370a 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -9,7 +9,6 @@ main/agent-hooks/managed-hook-owner-identity.ts main/agent-hooks/managed-hook-runtime.ts main/app-icon.ts main/automations/precheck-runner.ts -main/browser/browser-cookie-import.ts main/claude-accounts/keychain.ts main/computer/macos-computer-use-permission-status.ts main/computer/macos-computer-use-permissions.ts @@ -27,7 +26,6 @@ main/external-editor-launch.ts main/hooks.ts main/ipc/app.ts main/ipc/developer-permissions.ts -main/ipc/filesystem.ts main/ipc/macos-keyboard-layout-snapshot.ts main/ipc/notification-authorization-status.ts main/ipc/worktree-apfs-clone.ts From 83513b82d1a724145ea00595fe7fd6b4cc9b03f0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:05:47 -0700 Subject: [PATCH 14/93] test(terminal): follow Hangul lifecycle extraction (cherry picked from commit b62361fad271809819aa01711e437197065e3f75) --- .../browser/browser-cookie-import.test.ts | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index f36efb3fc4f..52662b2366d 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -4,7 +4,7 @@ import type * as NodeFs from 'node:fs' const { appGetPathMock, copyFileSyncMock, - execFileSyncMock, + runProcessSyncMock, sessionFromPartitionMock, dialogShowOpenDialogMock, setPendingCookieImportMock, @@ -13,7 +13,7 @@ const { } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), copyFileSyncMock: vi.fn(), - execFileSyncMock: vi.fn(), + runProcessSyncMock: vi.fn(), sessionFromPartitionMock: vi.fn(), dialogShowOpenDialogMock: vi.fn(), setPendingCookieImportMock: vi.fn(), @@ -28,7 +28,11 @@ vi.mock('./browser-session-registry', () => ({ } })) -vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +// Why mock the chokepoint: command timeouts and hidden-console handling belong to +// runProcessSync, while this suite only needs to control the credential output. +vi.mock('../../shared/child-process/run-process', () => ({ + runProcessSync: runProcessSyncMock +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { @@ -478,8 +482,8 @@ describe('importCookiesFromBrowser Chromium', () => { copyFileSyncMock.mockClear() setPendingCookieImportMock.mockClear() clearPendingCookieImportMock.mockClear() - execFileSyncMock.mockReset() - execFileSyncMock.mockImplementation(() => { + runProcessSyncMock.mockReset() + runProcessSyncMock.mockImplementation(() => { throw new Error('OS credential commands are unavailable in this test') }) sessionFromPartitionMock.mockReset() @@ -514,12 +518,6 @@ describe('importCookiesFromBrowser Chromium', () => { ]).close() const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'defaults') { - return '120.0.6099.71\n' - } - throw new Error(`Unexpected command: ${command}`) - }) try { expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true) const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) => @@ -539,8 +537,7 @@ describe('importCookiesFromBrowser Chromium', () => { value: 'source-value' }) ) - expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false) - expect(execFileSyncMock.mock.calls.some(([command]) => command === 'defaults')).toBe(false) + expect(runProcessSyncMock).not.toHaveBeenCalled() expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe( true ) @@ -573,11 +570,12 @@ describe('importCookiesFromBrowser Chromium', () => { } ]).close() createChromiumCookieTestDatabase(targetCookiesPath, []).close() - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'security') { - return `${password}\n` - } - throw new Error(`Unexpected command: ${command}`) + runProcessSyncMock.mockReturnValue({ + code: 0, + signal: null, + stdout: `${password}\n`, + stderr: '', + timedOut: false }) const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') @@ -588,11 +586,11 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(execFileSyncMock).toHaveBeenCalledWith( - 'security', - expect.any(Array), - expect.any(Object) - ) + expect(runProcessSyncMock).toHaveBeenCalledWith({ + program: 'security', + args: ['find-generic-password', '-s', 'Chrome Safe Storage', '-a', 'Chrome', '-w'], + timeoutMs: 30_000 + }) expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'encrypted-value' }) ) From 1d9b5306acf7ea9832a958a2265656fae941327b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:27:20 -0700 Subject: [PATCH 15/93] chore(child-process): prune stale import allowlist (cherry picked from commit 92d39fc9845e476f7bf166a55dd98221660fad1f) --- .../__fixtures__/child-process-import-allowlist.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index fe1bccbf9ed..fead2c8cde7 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -45,7 +45,6 @@ src/main/browser/agent-browser-bridge-execution.ts src/main/browser/agent-browser-bridge-raw-process.ts src/main/browser/agent-browser-bridge-state.ts src/main/browser/agent-browser-bridge-types.ts -src/main/browser/browser-cookie-key.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts From 15c4f1c4475be42d90742b7e795510fe674e1d15 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:46:35 -0700 Subject: [PATCH 16/93] fix(runtime): defer process incarnation lookup for plain sends (cherry picked from commit d522a3e15a56c0d6d1b92b67414d51f6bf78e987) --- .../rpc/methods/orchestration-send-methods.ts | 2 +- .../methods/orchestration-send-point-to-point.ts | 5 +++-- .../rpc/methods/orchestration-send.test.ts | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration-send-methods.ts b/src/main/runtime/rpc/methods/orchestration-send-methods.ts index d780d6a5a5e..c94e0f22165 100644 --- a/src/main/runtime/rpc/methods/orchestration-send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration-send-methods.ts @@ -161,7 +161,7 @@ export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ senderPaneKey, legacyCoordinatorRunId, orchestrationCapability, - processIncarnation: + resolveProcessIncarnation: () => attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) ?? undefined, diff --git a/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts index 3988dc90af8..827b90b8976 100644 --- a/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts +++ b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts @@ -22,7 +22,7 @@ export function sendPointToPointMessage(args: { senderPaneKey: string | undefined legacyCoordinatorRunId: string | undefined orchestrationCapability: string | undefined - processIncarnation: string | undefined + resolveProcessIncarnation: () => string | undefined revalidateLegacyCoordinator: (() => string) | undefined withSendWarnings: SendReceipt }): unknown { @@ -37,7 +37,7 @@ export function sendPointToPointMessage(args: { senderPaneKey, legacyCoordinatorRunId, orchestrationCapability, - processIncarnation, + resolveProcessIncarnation, revalidateLegacyCoordinator, withSendWarnings } = args @@ -65,6 +65,7 @@ export function sendPointToPointMessage(args: { ) }) if (isDispatchMutationMessageType(msg.type)) { + const processIncarnation = resolveProcessIncarnation() const taskId = parseMessageTaskId(params.payload) const capabilityBacked = Boolean(dispatch?.capability_hash) const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index e4312c34a3a..7b84cad90c6 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -46,6 +46,21 @@ describe('orchestration RPC methods', () => { } describe('orchestration.send', () => { + it('does not resolve process incarnation for ordinary messages', async () => { + setup() + const resolveProcessIncarnation = vi.mocked(runtime.getTerminalProcessIncarnation) + resolveProcessIncarnation.mockClear() + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: `run:${activeRunId}`, + subject: 'hello' + })) as { message: { type: string } } + + expect(result.message.type).toBe('status') + expect(resolveProcessIncarnation).not.toHaveBeenCalled() + }) + it('sends a message', async () => { setup() // Why: send notifies arrival so already-idle recipients get push-on-idle From c31e7b0d9a7c0332c43d06fe4b0f26575bf78dca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:04:47 -0700 Subject: [PATCH 17/93] docs(main): restore rationale comments lost in the startup and cookie splits --- .../browser-cookie-chromium-finalize.ts | 34 ++++++++++++ .../browser/browser-cookie-chromium-import.ts | 9 ++++ .../browser-cookie-chromium-prepare.ts | 41 +++++++++++++- .../browser/browser-cookie-chromium-scan.ts | 28 ++++++++-- src/main/index.ts | 2 + src/main/startup/branch-rename-hook.ts | 7 +++ .../startup/codex-session-resume-launch.ts | 22 ++++++++ src/main/startup/gpu-lifecycle.ts | 8 ++- .../startup/main-process-account-services.ts | 14 ++++- src/main/startup/main-process-automations.ts | 1 + src/main/startup/main-process-i18n-menu.ts | 5 ++ .../startup/main-process-ipc-bootstrap.ts | 1 + src/main/startup/main-process-observers.ts | 27 ++++++++++ src/main/startup/main-process-plugins.ts | 10 ++++ src/main/startup/main-process-pty-startup.ts | 20 +++++++ src/main/startup/main-process-quit.ts | 53 ++++++++++++++++++- .../startup/main-process-ready-foundation.ts | 36 +++++++++++++ .../startup/main-process-ready-runtime.ts | 15 +++++- .../startup/main-process-runtime-launch.ts | 30 ++++++++++- .../startup/main-process-runtime-service.ts | 13 +++++ src/main/startup/main-process-serve.ts | 1 + src/main/startup/main-process-state.ts | 5 ++ src/main/startup/main-window-agent-status.ts | 4 ++ src/main/startup/main-window-core-services.ts | 3 ++ .../startup/main-window-lifecycle-flags.ts | 1 + src/main/startup/synthetic-title-runtime.ts | 16 +++++- src/main/startup/web-contents-timed-flag.ts | 2 +- src/main/updater/updater-install-support.ts | 5 +- 28 files changed, 396 insertions(+), 17 deletions(-) diff --git a/src/main/browser/browser-cookie-chromium-finalize.ts b/src/main/browser/browser-cookie-chromium-finalize.ts index 46203d0d151..ba630a3c0fa 100644 --- a/src/main/browser/browser-cookie-chromium-finalize.ts +++ b/src/main/browser/browser-cookie-chromium-finalize.ts @@ -28,10 +28,14 @@ export async function finalizeChromiumCookieImport( ...(context.googleCookiesSkipped > 0 ? { googleCookiesSkipped: context.googleCookiesSkipped } : {}), + // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so + // totalCookies === importedCookies + skippedCookies keeps holding on this path too. ...(context.partitionSkipped > 0 ? { partitionSkippedCookies: context.partitionSkipped } : {}), domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) } } @@ -51,14 +55,27 @@ export async function finalizeChromiumCookieImport( diag(` staging skipped: ${context.imported} cookies will load in-memory only`) } + // Why: clear stale cookies for the domains being imported first; mixing them with the imported + // set makes sites reject the session. Non-transplantable families are exempt — nothing was + // imported for them, and their live session is the only one that works. + // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches + // only as far as the domains this import writes; beyond them a clear has nothing to reconcile + // and only signs the user out of sessions the import was never about. + // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak + // the same CDP identities — cookies.set() cannot express the partition either one reads. const cookieClearStore = openCookieClearStore(context.targetSession) try { + // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a + // second import cannot clear between them and write on top of a newer import's jar. await removeTransplantableCookies( { cookies: cookieClearStore, snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) }, + // Why (STA-4300): the families this import declined to write must not be removed either. + // Passing them here keeps their coordinates out of the removal plan AND out of the CDP + // snapshot taken from it, so they are never submitted to any mutation. context.nativePlan.skippedFamilies, context.importScope ) @@ -75,6 +92,8 @@ export async function finalizeChromiumCookieImport( } writable.push({ ...cookie, url }) } + // Why: a rejected cookie here falls back to the staged cold-start replay rather than + // unwinding the import, so one failure must not stop the rest from loading. const phase = await writeImportedCookies(cookieClearStore, writable, { stopOnFailure: false, log: diag @@ -91,6 +110,7 @@ export async function finalizeChromiumCookieImport( let warning: BrowserCookieImportSummary['warning'] if (context.memoryFailed > 0 && context.stagingAvailable) { + // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. browserSessionRegistry.setPendingCookieImport( context.targetPartition, context.stagingCookiesPath @@ -99,20 +119,34 @@ export async function finalizeChromiumCookieImport( ` staged at ${context.stagingCookiesPath} for ${context.memoryFailed} cookies that need restart` ) } else if (context.memoryFailed > 0) { + // Why: never register a path that was never written or can never be replayed — cold start + // would replay a missing or partial DB over the live partition. browserSessionRegistry.clearPendingCookieImport(context.targetPartition) context.discardStagingFile() diag(` ${context.memoryFailed} cookies need a restart but staging is unavailable — skipped`) + // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. warning = { code: 'restart-fallback-unavailable', loadedCookies: context.memoryLoaded, failedCookies: context.memoryFailed } } else { + // Why: this import already rewrote the live session, so an older staged DB must not replay over it. browserSessionRegistry.clearPendingCookieImport(context.targetPartition) context.discardStagingFile() diag(' all cookies loaded in-memory — no restart needed') } + // Why: the session keeps the UA the registry set at startup (clean or native). + // Imports must not impersonate the source browser — the synthesized UA read a + // fork's marketing version as a Chromium version (STA-3514), and Google binds + // sessions to the re-import, not the UA (#12884), so it bought nothing. + // Google-bound integrity cookies are already excluded by + // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. if (!warning && context.undecryptableWarning) { warning = context.undecryptableWarning } diff --git a/src/main/browser/browser-cookie-chromium-import.ts b/src/main/browser/browser-cookie-chromium-import.ts index e9ee2ebe386..7066952c58b 100644 --- a/src/main/browser/browser-cookie-chromium-import.ts +++ b/src/main/browser/browser-cookie-chromium-import.ts @@ -25,7 +25,15 @@ export async function importChromiumCookies( return { ok: false, reason: `${browser.label} cookies database not found.` } } + // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. + + // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose + // imported-domain rows can be merged into the live DB on the next cold start. const targetSession = session.fromPartition(targetPartition) + // Why (STA-4601): native imports mutate the live jar and their staged image before the old + // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, + // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer + // import on the same partition. return withCookieMutationLock(targetSession, async () => { let context: ChromiumImportContext | null = null try { @@ -52,6 +60,7 @@ export async function importChromiumCookies( /* may already be closed */ } context.closeStagingDb() + // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. context.discardStagingFile() } diag(` SQLite import failed: ${String(err)}`) diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts index d9cf4d30ba3..8b71b102f13 100644 --- a/src/main/browser/browser-cookie-chromium-prepare.ts +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -33,6 +33,10 @@ export async function prepareChromiumCookieImport( targetSession: Session ): Promise { await targetSession.cookies.flushStore() + // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from + // the caller's partition string. String surgery on a caller-supplied name is what let a value like + // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement + // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. const partitionDir = targetSession.getStoragePath() if (!partitionDir) { return { @@ -42,14 +46,21 @@ export async function prepareChromiumCookieImport( const partitionName = targetPartition.replace('persist:', '') let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - // Why: initialize an unused profile so Chromium creates its Cookies database. + // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. + // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as + // the import itself. An earlier revision left it outside on the argument that no import writes + // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, + // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write + // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had + // just written and reported as imported. The cost is negligible: the probe only runs for a + // partition that has never stored a cookie, so it is at most a one-time wait per profile. if (!liveCookiesPath) { try { await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) await targetSession.cookies.remove('https://localhost', '__init') await targetSession.cookies.flushStore() } catch { - // ignore — flushStore still creates the file on supported Electron versions + // ignore — the set/remove may fail but flushStore should still create the file } liveCookiesPath = resolveChromiumCookiesPath(partitionDir) } @@ -65,7 +76,11 @@ export async function prepareChromiumCookieImport( stagingDir, `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` ) + // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory + // import rejects, so losing it must degrade that fallback rather than abort the import. let stagingAvailable = false + // Why: a client-hosted route partition is derived at runtime and never reaches the startup + // replay, so staging it would only leave a plaintext cookie DB nothing ever consumes. if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { diag(` restart fallback unsupported for partition "${targetPartition}" — not staging cookies`) } else { @@ -78,6 +93,7 @@ export async function prepareChromiumCookieImport( diag( ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` ) + // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. try { unlinkSync(stagingCookiesPath) } catch { @@ -116,6 +132,7 @@ export async function prepareChromiumCookieImport( stagingDb = null } const discardStagingFile = (): void => { + // Why: the staged copy holds plaintext cookie values, and SQLite may have left sidecars beside it. for (const suffix of ['', '-wal', '-shm']) { try { unlinkSync(stagingCookiesPath + suffix) @@ -125,13 +142,19 @@ export async function prepareChromiumCookieImport( } } + // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null let colList: string | null = null let placeholders: string | null = null if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. try { stagingDb = new DatabaseSync(stagingCookiesPath) + // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL + // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image + // import, restoring the unrelated-cookie data loss this format is meant to prevent. stagingDb.exec('PRAGMA journal_mode = DELETE') targetColumnInfo = stagingDb .prepare('PRAGMA table_info(cookies)') @@ -146,10 +169,13 @@ export async function prepareChromiumCookieImport( colList = null placeholders = null closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. discardStagingFile() } } + // Why (STA-4300): the partition columns drift across Chromium versions, so read the source + // schema rather than assuming a row's missing column means "unpartitioned". const sourceColumns = new Set( (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( (column) => column.name @@ -168,6 +194,8 @@ export async function prepareChromiumCookieImport( return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } } + // Why (STA-4300): partition fidelity is a property of the source row, even when its value + // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. const partitionCandidates = sourceRows.flatMap((sourceRow) => { const domain = sourceRow.host_key as string const name = sourceRow.name as string @@ -180,6 +208,8 @@ export async function prepareChromiumCookieImport( const partitionBySourceRow = new Map( partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) ) + // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a + // family we cannot protect is the P0. Refuse before the jar is touched. if (nativePlan.hasUnrepresentableSkip) { closeStagingDb() discardStagingFile() @@ -207,6 +237,7 @@ export async function prepareChromiumCookieImport( : null if (needsSourceKey && !sourceKey) { closeStagingDb() + // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. discardStagingFile() return { result: { @@ -216,6 +247,8 @@ export async function prepareChromiumCookieImport( } } + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. let insertStmt: ChromiumImportContext['insertStmt'] = null const context: ChromiumImportContext = { browser, @@ -249,6 +282,8 @@ export async function prepareChromiumCookieImport( memoryFailed: 0, domainSet: new Set(), decryptedCookies: [], + // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. + // A plan record holding only the derived fields compiles fine and then cannot stage. scanned: [], sourceDomainValidity: new Map(), insertStmt, @@ -280,6 +315,8 @@ export async function prepareChromiumCookieImport( } else if (context.stagingAvailable) { context.disableStaging('staged database exposed no cookies columns') } + // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding + // partial-import restart behavior is separate from narrowing what a staged replay may replace. if (context.nativePlan.skippedFamilies.size > 0) { context.disableStaging( `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts index dd7e061bebc..e3bdc6a6409 100644 --- a/src/main/browser/browser-cookie-chromium-scan.ts +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -34,12 +34,14 @@ export function scanChromiumCookieRows( context.integritySkipped++ continue } + // Why: transplanting these replaces a working sign-in with a session the site rejects. if (isNonTransplantableCookieDomain(domain)) { context.nonTransplantableSkipped++ continue } const encRaw = sourceRow.encrypted_value + // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null const plainRaw = sourceRow.value let decryptedValue: Buffer @@ -55,7 +57,9 @@ export function scanChromiumCookieRows( ? decryptCookieValueRaw(encBuf, sourceKey) : null if (!raw) { - // Why: retain the prefix while it is available so diagnostics identify the failure cause. + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. context.decryptFailed++ if (appBoundIneligible) { context.appBoundFailed++ @@ -83,7 +87,8 @@ export function scanChromiumCookieRows( context.skipped++ continue } - // Decryption failures are counted above; planned family omissions are counted once here. + // Decryption failures are already counted above. Every other row suppressed by the + // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. if (!plannedSourceRows.has(sourceRow)) { context.skipped++ continue @@ -95,7 +100,12 @@ export function scanChromiumCookieRows( const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) const partition = partitionBySourceRow.get(sourceRow)! + // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. const value = decryptedValue.toString('latin1') + // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not + // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and + // THEN applied the unreadable guard, so an unreadable row discovered late could not retract + // a sibling already emitted, and the jar-wide clear then removed more than was written back. context.scanned.push({ entry: { decryptedValue, @@ -116,8 +126,14 @@ export function scanChromiumCookieRows( for (const { entry } of context.scanned) { context.domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) } + // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the + // plan — the same rows the writes come from — is what keeps the removal set from drifting past + // the write set, and it is derived here rather than at the clear because the staged image below + // has to be cleared to the identical scope. context.importScope = importedDomainScope([...context.domainSet]) + // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. + // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. if (context.stagingDb && context.insertStmt) { try { prepareStagedCookiesForImport(context.stagingDb, context.importScope) @@ -126,7 +142,8 @@ export function scanChromiumCookieRows( } } - // EMIT: all downstream writes derive from the one scan, so no row can leak into the jar. + // EMIT: everything downstream derives from the plan, so there is no second place a row can + // leak in. for (const { entry, sourceRow } of context.scanned) { context.decryptedCookies.push(entry) if (context.insertStmt && targetColumnInfo) { @@ -141,6 +158,8 @@ export function scanChromiumCookieRows( context.disableStaging(String(err)) } } + // Why: counts importable cookies, not staged rows — the summary must stay truthful when + // the optional staging DB is unavailable. context.imported++ } @@ -154,6 +173,9 @@ export function scanChromiumCookieRows( keyringUnavailableFailed: context.keyringUnavailableFailed }) + // Why: an older remote client ignores the new counter and would present this loss as success. + // Placed before the early return and before any jar mutation, so a client that cannot render + // the skip fails the import outright rather than reporting a partial import as complete. if (context.partitionSkipped > 0 && context.options.canReportPartitionSkippedCookies === false) { context.closeStagingDb() context.discardStagingFile() diff --git a/src/main/index.ts b/src/main/index.ts index c66191c82b3..acf913b2c7a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.skillShareDeepLinks.capture(argv, (shareId) => { state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) + // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return } @@ -43,6 +44,7 @@ const preflightReady = runMainProcessPreflight({ requestDesktopActivation }) +// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. if (preflightReady) { app.on('open-url', (event, url) => { if (!parseSkillShareId(url)) { diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts index ce33ab0b451..578935132fb 100644 --- a/src/main/startup/branch-rename-hook.ts +++ b/src/main/startup/branch-rename-hook.ts @@ -7,8 +7,10 @@ import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folde import { moveWorktree } from '../git/worktree' import { mainProcessState as state } from './main-process-state' +// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. const ENABLE_FIRST_WORK_FOLDER_RENAME = false +// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { paneKey: string tabId: string | undefined @@ -56,6 +58,7 @@ export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { }, canRenameOrcaCreatedBranch: (worktreeId) => { const meta = store.getWorktreeMeta(worktreeId) + // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true }, setDisplayName: (worktreeId, displayName) => { @@ -76,6 +79,7 @@ export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { // generated branch is renamed and across subsequent catalog refreshes. displayNameIsPinned: true, pendingFirstAgentMessageRename: false, + // Success clears the failure badge (redundant with the explicit setRenameError(null)). firstAgentMessageRenameError: null }) }, @@ -93,7 +97,9 @@ export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { }) : undefined, setRenameError: (worktreeId, error, failureOutput) => { + // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + // Skip the write + push when unchanged — most settled worktrees never had an error to clear. const scope = parseWorkspaceKey(worktreeId) if (scope?.type === 'folder') { const current = store.getFolderWorkspace( @@ -113,6 +119,7 @@ export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { return } store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) }, resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index 65a2c6f6781..c537c4f75ef 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -25,14 +25,28 @@ export async function prepareCodexSessionResumeForLaunch(args: { return null } const systemHomePath = getSystemCodexHomePath() + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. const trustedHomes = [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] + // Why: resolved eagerly, once, before any ranking or provenance match. The + // marker read used to be deferred into the ranking thunk so a + // provenance-present resume never paid for it, but that optimisation let an + // unreadable selected home reach the PTY as "no selection": the provenance + // branch simply omits the account from `trustedHomes` and another account's + // readable alias wins. A throw here refuses the whole resume instead + // (#STA-4422). const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is + // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). const preparation = await prepareCodexSessionResume({ sessionId: args.providerSession.id, transcriptPath: args.providerSession.transcriptPath, trustedCodexHomes: trustedHomes, + // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; + // rank it by the current selection so settings insertion order can never decide the account. getSelectedAccountCodexHome: () => selectedAccountCodexHome, systemCodexHomePath: systemHomePath, + // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must + // outrank the path-sorted account homes or a system-default selection resumes as an account. sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), resolveVerifiedResumeHome: async (sessionSource) => { let migrated = { useRealCodexHome: false } @@ -50,9 +64,16 @@ export async function prepareCodexSessionResumeForLaunch(args: { } ) } catch (error) { + // Why: this launch path pins CODEX_HOME to the account that OWNS the + // rollout and deliberately refuses to repin onto whichever account is + // selected now (#10793), so it does not wire + // getSelectedHostAccountCodexHomePath and this branch cannot fire today. + // It stays as a contract guard: the blanket catch below must never + // silently swallow a typed refusal if that ever changes. if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { throw error } + // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. console.warn( '[codex-session-resume] Legacy rollout migration failed; using origin home:', error @@ -82,6 +103,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) } } catch (error) { + // Why: hook repair is best-effort; session provenance must still win over the currently selected home. console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) } return resumeHome diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts index 45f825ba141..da852bcb491 100644 --- a/src/main/startup/gpu-lifecycle.ts +++ b/src/main/startup/gpu-lifecycle.ts @@ -36,6 +36,7 @@ function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | nul return environment.platform === 'win32' ? { ...environment, platform: 'win32' } : null } +// Writes both crash-time and post-recovery consent states through one build-scoped path. function persistGpuFallbackMarker( userDataPath: string, info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } @@ -53,7 +54,7 @@ function persistGpuFallbackMarker( } } -/** Apply persisted software-rendering switches before Electron consumes its command line. */ +// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. export function maybeApplyGpuFallbackForThisLaunch(): void { if (state.isServeMode || process.platform !== 'win32') { return @@ -66,6 +67,8 @@ export function maybeApplyGpuFallbackForThisLaunch(): void { app.disableHardwareAcceleration() const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) state.gpuFallbackActiveThisLaunch = true + // Why: with no GPU child left, child-process-gone can't report a GPU fault, so + // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { crashesInWindow: marker.crashesInWindow, switches: appliedSwitches.join(',') @@ -79,6 +82,7 @@ export async function presentGpuFallbackRecoveredLaunchPrompt( if (!marker || marker.userConfirmed || window.isDestroyed() || state.isQuitting) { return } + // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. state.activeGpuFallbackMarker = null const userDataPath = app.getPath('userData') await handleGpuFallbackRecoveredLaunch({ @@ -110,11 +114,13 @@ export async function presentGpuFallbackRecoveredLaunchPrompt( }) } +// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. export async function handleGpuChildCrash( reason: string, exitCode: number | null, crashedAt: number ): Promise { + // Software rendering already active or shutting down: nothing more to do. if (state.gpuFallbackActiveThisLaunch || state.isQuitting || state.isServeMode) { return } diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 4a47d2c2ec2..c4575c7a0a3 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -33,7 +33,13 @@ export function initializeMainProcessAccountServices(): void { state.rateLimits = new RateLimitService() state.codexRuntimeHome = new CodexRuntimeHomeService(store) void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) + // Why: an incapable trust-grant host must fall back to the managed home for + // every consumer (PTY env, rate limits, commit messages) in one place. state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) + // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy + // system-home sweep inside managed installs would delete the entry the + // real-home installer just appended. Flag OFF, hooks off, or an incapable + // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. setSystemCodexHomeHookSweepSuppressed( () => state.codexRuntimeHome !== null && @@ -55,6 +61,8 @@ export function initializeMainProcessAccountServices(): void { state.codexAccounts = new CodexAccountService(store, state.rateLimits, state.codexRuntimeHome, { onHostSystemDefaultSelected: state.codexSessionMigration.requestRun }) + // Why: migrate historical shared-home sessions after startup; compatibility + // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). state.codexSessionMigration.scheduleInitialRun() state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) @@ -62,6 +70,8 @@ export function initializeMainProcessAccountServices(): void { state.codexRuntimeHome!.prepareForRateLimitFetch(target) ) state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the + // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). state.rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store.getSettings())) ) @@ -71,6 +81,7 @@ export function initializeMainProcessAccountServices(): void { store.getSettings() ) store.onSettingsChanged((updates, settings) => { + // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) ) @@ -78,8 +89,7 @@ export function initializeMainProcessAccountServices(): void { state.rateLimits.setClaudeAuthPreparationResolver((target) => state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) ) - // Live Claude sessions publish quota windows through their status-line hook; - // consume those snapshots before falling back to OAuth polling. + // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). agentHookServer.setClaudeStatusLineListener((event) => { state.rateLimits!.ingestLiveClaudeRateLimits(event) }) diff --git a/src/main/startup/main-process-automations.ts b/src/main/startup/main-process-automations.ts index 77092ed66b8..a980c272070 100644 --- a/src/main/startup/main-process-automations.ts +++ b/src/main/startup/main-process-automations.ts @@ -17,6 +17,7 @@ export function initializeMainProcessAutomations(): AutomationService { codexUsage, terminalObserver: createRuntimeAutomationRunTerminalObserver(runtime), onAutomationsChanged: (payload) => runtime.notifyAutomationsChanged(payload), + // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. allowRemoteHostScheduling: state.isServeMode, headlessDispatcher: state.isServeMode ? async ({ automation, run, target }) => { diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts index b6e494c1ad0..0de7780d9db 100644 --- a/src/main/startup/main-process-i18n-menu.ts +++ b/src/main/startup/main-process-i18n-menu.ts @@ -49,8 +49,11 @@ export async function initializeMainProcessI18nAndMenu(): Promise { }, onOpenFeatureTour: (targetWindow) => { recordCrashBreadcrumb('feature_tour_opened') + // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. sendOpenFeatureTour(targetWindow instanceof BrowserWindow ? targetWindow : null) }, + // Why: menu zoom must act on the window the user is looking at — routing to + // the main window while the dashboard pop-out is focused zooms behind it. onZoomIn: () => { if (!zoomDashboardPopoutIfFocused('in')) { state.mainWindow?.webContents.send('terminal:zoom', 'in') @@ -70,10 +73,12 @@ export async function initializeMainProcessI18nAndMenu(): Promise { onToggleRightSidebar: () => state.mainWindow?.webContents.send('ui:toggleRightSidebar'), onToggleAppearance: (key) => { if (key === 'statusBarVisible') { + // Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store. state.mainWindow?.webContents.send('ui:toggleStatusBar') return } const current = store.getSettings() + // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. const next = getNextDefaultOnAppearanceSettingValue(current[key]) store.updateSettings({ [key]: next }, { notifyListeners: true }) rebuildAppMenu() diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index a948e4fb13f..3f364a9335c 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -31,6 +31,7 @@ export function registerMainProcessIpcHandlers(): void { } }) ) + // Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. ipcMain.handle('ui:consumePendingOpenSettings', (event) => state.pendingOpenSettings.matches(event.sender.id, { consume: true }) ) diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index aa2dd6ea421..c3d83450b7c 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -33,6 +33,7 @@ export function initializeMainProcessObservers(): void { store.getSettings().keepComputerAwakeWhileAgentsRun ) ) + // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. state.agentAwakeService.setStatuses([]) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { @@ -44,6 +45,8 @@ export function initializeMainProcessObservers(): void { undefined })) for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged + // `snapshotVersion`, which every client drops on its monotonic gate. state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) } } @@ -51,15 +54,21 @@ export function initializeMainProcessObservers(): void { const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { state.agentAwakeService?.setStatuses(statuses) }) + // Healthy session.tabs streams need a push when transcript identity changes. const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( (sessions) => publishProviderSessionChanges(sessions) ) + // Why: hook rows are the only carrier of live agent state on a headless host, and + // nothing else republishes `session.tabs` when one changes — so a paired client + // would keep the pane's last projection until an unrelated PTY touch came along. const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { if (hookStatusChangedSessionTabs(enriched)) { state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) } }) + // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land + // here. Without it the live state published above becomes a zombie question card. const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { const clearedPaneKeys = 'paneKey' in clear @@ -76,13 +85,21 @@ export function initializeMainProcessObservers(): void { unsubscribeHookStatusSessionTabs() unsubscribeHookStatusClear() } + // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) + // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not + // a crash (the app is force-quit, so no report is ever generated). Without this the incidence + // number the watchdog exists to produce would sit unread on the user's disk. Must run after + // initTelemetry: track() drops silently until the client and store are wired. if (state.hangDetection) { track('main_thread_hang_detected', { unresponsive_ms: Math.round(state.hangDetection.unresponsiveMs), self_recovered: state.hangDetection.selfRecovered }) } + // Why: the trust-grant module is bundled into plain-node CLI entries where + // the telemetry client cannot load, so the tracker is injected here instead + // of imported there. setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { track('codex_trust_grant', { outcome, @@ -93,6 +110,12 @@ export function initializeMainProcessObservers(): void { ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) }) }) + // Why: the error-tracking lane (telemetry-error-tracking.md) is its own + // composition root — independent of product telemetry — and must + // initialize before any IPC handler / runtime span is created so the + // tracer's active sink is populated at the moment the first span fires. + // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED + // / CI internally; those gates do not need to be re-checked here. initObservability() recordDurableCrashBreadcrumb('main_process_lifecycle_started', { packaged: app.isPackaged, @@ -119,9 +142,13 @@ export function initializeMainProcessObservers(): void { } }) .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) + // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). initCohortClassifier(store) initOnboardingCohortClassifier(store) state.stats = new StatsCollector() + // Agent-session stats come from hook status transitions, the same truth the + // sidebar and dashboard read — never from OSC terminal titles, which miss + // hook-only agents and count any spinner TUI as an agent (#10201). const agentSessionRecorder = new AgentSessionTransitionRecorder(state.stats) agentHookServer.subscribeEnrichedStatus((enriched) => agentSessionRecorder.onStatus(enriched)) agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear)) diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts index 5a71cc6f8b3..8c3aeeed10c 100644 --- a/src/main/startup/main-process-plugins.ts +++ b/src/main/startup/main-process-plugins.ts @@ -56,6 +56,8 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): state.pluginService = new PluginService({ userDataPath: app.getPath('userData'), hostVersion: app.getVersion(), + // Feature flag: with the setting off, discovery returns nothing and no + // plugin code path runs at all. isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins), getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents), @@ -106,12 +108,17 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): ) } }) + // Why: headless `orca serve` clients reach plugins through the runtime RPC + // methods, which resolve the service via this module-level setter. Consent + // over RPC uses the same hash-keyed write path as the desktop dialog. setPluginServiceForRpc(state.pluginService, { applyConsent: (request) => applyPluginConsent({ store, pluginService: state.pluginService!, ...request }), applyEnablement: (pluginKey, enabled) => applyPluginEnablement({ store, pluginService: state.pluginService!, pluginKey, enabled }) }) + // Lazy kernel: initialize() only discovers manifests — no worker forks, no + // panel reads. Zero plugin code runs before an explicit trigger. void state.pluginService .initialize() .then(() => { @@ -143,7 +150,10 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): }) requestBundledPluginBootstrap() requestOfficialMarketplaceSeed() + // v0 plugin event seams: agent status (hook pipeline tap) + worktree + // lifecycle (runtime tap). Server-side filtered per plugin subscription. agentHookServer.subscribeEnrichedStatus((enriched) => { + // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. if (enriched.restoredUnconfirmed) { return } diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index e847646da51..162faf66b2f 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -49,6 +49,7 @@ export function handleCodexHomePtySpawned(args: { startedAt?: Date startedSequence?: number }): void { + // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. if (args.reattached && args.startedSequence !== undefined) { const paneAccount = getCodexPaneAccount(args.id) const homeRoute = @@ -79,6 +80,11 @@ export function handlePtyExit(id: string, exitSequence: number): void { state.codexSessionMigration?.finishLaunch(id, exitSequence) } +/** A PTY that dies while Orca is down never runs the teardown that clears pane + * state, so hydrate can rebuild a Claude subagent roster that no later hook can + * retire — pinning the pane 'working' and locking its agent out of hibernation + * for good. Once provider and hook hydration settle, targeted PTY liveness can + * retire only rows whose local owner is proven gone. */ export async function reapRestoredSubagentsWithoutLiveAgent(): Promise { const store = state.store if (!store) { @@ -119,9 +125,12 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) + // Why: only GUI-spawned macOS daemons watch for login-session death; a headless + // serve daemon must survive its spawning session ending (SSH disconnect). await initDaemonPtyProvider(signal, { macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode }) + // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() if ( state.codexRuntimeHome && @@ -131,6 +140,9 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ if (livePtyIds) { reconcileCodexPaneAccountsWithLivePtys(livePtyIds) const settings = state.store?.getSettings() + // Why (#16441): each retained home can run a codex app-server grant + // session. Awaiting them here delayed the first window by N sessions; + // a retained shell cannot invoke Codex before this provider serves. if (hasRetainedManagedHostPane) { void reconcileRetainedCodexHookHomes({ hookService: codexHookService, @@ -144,26 +156,33 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ } } } + // Why: retained shells can invoke Codex immediately after the startup gate. state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) }, + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() if (!isAgentStatusHooksEnabled(settings)) { return } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) + // Why (#11217): the hook listener fails open on every request error, so an IDS resetting + // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. + // Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture. agentHookServer.setTransportInterferenceListener((report) => { track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', + // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), endpointNamespace: state.devAgentHookEndpointNamespace }) logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) }, onDaemonError: (error) => { + // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). const reason = error instanceof Error ? error.message : String(error) console.error( `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` @@ -171,6 +190,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('daemon_start_failed', classifyError(error)) }, onAgentHookServerError: (error) => { + // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. console.error('[agent-hooks] Failed to start local hook server:', error) } }) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 4505e51ba93..ec893456c5a 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -28,8 +28,10 @@ import { mainProcessState as state } from './main-process-state' import { isDevParentShutdownRequested } from './configure-process' import { getCanonicalUserDataPath } from '../persistence' +// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. let daemonDisconnectDone = false let watcherShutdownPromise: Promise | null = null +// Why 2s: a config delete is best-effort, not durable state. const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 function shutdownWatchersOnce(): Promise { @@ -37,6 +39,7 @@ function shutdownWatchersOnce(): Promise { return Promise.resolve() } if (!watcherShutdownPromise) { + // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. stopFolderRepoGitUpgradeWatch() watcherShutdownPromise = Promise.allSettled([ closeAllWatchers(), @@ -70,20 +73,30 @@ function installBeforeQuitHandler(): void { state.unsubscribeAgentAwakeStatusChanges = null state.agentAwakeService?.dispose() state.agentAwakeService = null + // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). state.rateLimits?.stop() }) } function installWillQuitHandler(): void { app.on('will-quit', (event: Event) => { + // Why return instead of re-running teardown: the second pass is Electron re-firing after + // our own app.quit(), so every step below already ran and every durable write already + // landed. Re-entering would start a fresh unawaited write that the exit then tears down. if (daemonDisconnectDone) { return } + // Why preventDefault before any work: everything below must be free to await, and a + // synchronous durable write here parks the main thread — uninterruptibly, on a stalled + // network profile mount. The teardown deadline cannot rescue that, because its timer + // lives on the same thread it would need to bound (#9447 covers the wedged-transport + // half; this covers the blocked-syscall half). if (!quitTeardownStartGate.tryStart(event)) { return } state.unsubscribeSystemResumeBroadcast?.() state.unsubscribeSystemResumeBroadcast = null + // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. stopTccPromptNotice() const updateQuitInProgress = isQuittingForUpdate() if (updateQuitInProgress) { @@ -93,9 +106,15 @@ function installWillQuitHandler(): void { { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } ) } + // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. destroySystemTray() + // Why: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost. state.starNag?.stop() state.automations?.stop() + // Why: plugin hosts are forked children; dispose sends shutdown and + // escalates to SIGKILL so they cannot outlive the app. The promise joins + // the teardown barrier below — quitting before it resolves would let + // Electron exit first and orphan the hosts. state.pluginKillListService = null state.pluginMarketplaceService = null state.pluginMarketplaceInstaller = null @@ -105,6 +124,11 @@ function installWillQuitHandler(): void { state.pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() + // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a + // bare script path that cannot express the guard and would otherwise keep spawning after quit. + // Why bounded here: every other teardown member carries its own ceiling, and this one reaches + // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the + // shared 20s deadline becomes the only thing ending the quit. const grokHookCleanup = process.platform === 'win32' ? settleWithinMs( @@ -119,27 +143,39 @@ function installWillQuitHandler(): void { console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) return } + // Why: removers report failures as statuses, so inspect details even after fulfillment. for (const status of settled.value.filter((entry) => entry.detail)) { console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) } }) : Promise.resolve() + // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. wslHookRelayManager.disposeAll() const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() + // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. + // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, + // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). + // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. const browserShutdown = (async (): Promise => { await state.runtime?.getOffscreenBrowserBackend()?.destroyAll?.() await state.runtime?.getAgentBrowserBridge()?.destroyAllSessions() })() + // Why (review P2-4): local SSH browser routes own loopback listeners and, on the + // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. const localSshRouteShutdown = import('../browser/local-ssh-browser-route') .then((routes) => routes.closeAllLocalSshBrowserRoutes()) .catch(() => {}) browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() + // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every + // active SSH lease detached in memory synchronously, and that flush is what persists it. const sshShutdown = beginSshShutdown() killAllPty() const watcherShutdown = shutdownWatchersOnce() const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a + // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. const usageCacheFlush = Promise.all([ state.claudeUsage?.flush(), state.codexUsage?.flush(), @@ -147,6 +183,7 @@ function installWillQuitHandler(): void { ]).then(() => {}) const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() const skillUploadShutdown = state.runtime?.disposeSkillUploadSessions() ?? Promise.resolve() + // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. const ownedPid = process.pid const ownedRuntimeId = state.runtime?.getRuntimeId() const rpcStopAndClear = state.runtimeRpc @@ -155,12 +192,21 @@ function installWillQuitHandler(): void { .then(() => awaitRuntimeFileWatcherUnsubscribes()) .then(() => { if (ownedRuntimeId) { + // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) } }) .catch((error) => console.error('[runtime] Failed to stop local RPC transport:', error)) : Promise.resolve() + // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. + // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. + // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + // Why: a wedged transport (half-open post-sleep socket) can leave one + // member unsettled forever and block app.quit() until Force Quit (#9447). + // Why stats/state join here: their writes are durable but not worth hanging the app for. + // Losing at most the last debounce interval beats a quit that never completes, and the + // temp+rename swap means a write cut short by the deadline leaves the old file intact. settleTeardownWithinDeadline([ { name: 'daemon', promise: daemonTeardown }, { name: 'browser', promise: browserShutdown }, @@ -186,7 +232,9 @@ function installWillQuitHandler(): void { }) .then(() => shutdownTelemetry()) .then(() => shutdownObservability()) - .catch(() => {}) + .catch(() => { + /* swallow — telemetry must never prevent app.quit() */ + }) .then(() => { daemonDisconnectDone = true app.quit() @@ -196,6 +244,8 @@ function installWillQuitHandler(): void { function installWindowAllClosedHandler(): void { app.on('window-all-closed', () => { + // Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive. + // Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits. if ( shouldQuitWhenAllWindowsClosed({ platform: process.platform, @@ -210,6 +260,7 @@ function installWindowAllClosedHandler(): void { /** Installs the process-level shutdown listeners once during bootstrap. */ export function installMainProcessQuitHandlers(): void { + // Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. process.once('exit', stopTccPromptNotice) installBeforeQuitHandler() installWillQuitHandler() diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 5f0e3c4a80e..f1860a9ae63 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -51,6 +51,7 @@ import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-con export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') + // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. applyBackgroundActivationPolicy({ warn: console.warn }) installElectronProxyRequestGuard(session.defaultSession) app.on('login', (event, webContents, details, authInfo, callback) => { @@ -73,6 +74,7 @@ export async function initializeReadyFoundation(): Promise { selfRecovered: state.hangDetection.selfRecovered }) } + // Why: install certificate decisions before any webview or headless window issues its first TLS request. app.on( 'certificate-error', (event, webContents, url, error, certificate, callback, isMainFrame) => { @@ -92,8 +94,12 @@ export async function initializeReadyFoundation(): Promise { throw new Error('Development identity is unavailable') } electronApp.setAppUserModelId(identity.appUserModelId) + // Why: names the app menu/About panel. Dev already applied this pre-ready (see the + // safeStorage note above); this call stays unconditional so packaged builds keep their + // existing post-ready rename, which lands after the Keychain name is already resolved. app.setName(identity.appName) updateGpuAccelerationAboutPanel() + // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. state.managedWslCliReconciliationStatus = 'pending' state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ isPackaged: app.isPackaged, @@ -124,20 +130,31 @@ export async function initializeReadyFoundation(): Promise { ) const profile = ensureActiveOrcaProfile() state.activeOrcaProfile = profile + // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable + // read has to have happened by then or the renderer and the browser-host lease disagree. initializeBrowserClientHostId(profile.profileDirectory) const store = new Store({ dataFile: profile.dataFile, storageAuthority: state.isServeMode ? 'runtime' : 'desktop' }) state.store = store + // Why: create pending readiness before the guard can observe the default session. const initialProxyApplication = applyElectronProxySettings(store.getSettings()) installElectronProxyRequestGuard(session.defaultSession) + // Why armed here and not at install time: the report remembers what it last said, and + // that state lives beside the profile data file, which does not exist until now. + // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux + // and must not gate the first window (STA-5765). scheduleSecretProtectionGapReport({ dataFile: profile.dataFile, force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', deferUntilFirstWindow: !state.isServeMode }) + // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults + // it. Left unbound it reports nothing trusted, which is safe but silently discards our own + // accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. neutralizeLegacyTerminalShimDir(app.getPath('userData')) const windowsShellPathHydration = createWindowsShellPathHydration() state.windowsShellPathHydration = windowsShellPathHydration @@ -160,9 +177,11 @@ export async function initializeReadyFoundation(): Promise { } wslHookRelayManager.setManagedHookSettingsResolver(() => state.store?.getSettings() ?? null) logStartupMilestone('store-loaded') + // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) store.onSettingsChanged((updates, settings) => { if ('terminalWindowsWslDistro' in updates) { + // Why: synchronize fallback WSL distro updates to runner. setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) } if ( @@ -182,9 +201,13 @@ export async function initializeReadyFoundation(): Promise { } } if ('showMenuBarIcon' in updates) { + // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running + // guest process and timers, and on must restart them — otherwise open WSL panes report no + // status until their next spawn. if (isAgentStatusHooksEnabled(settings)) { wslHookRelayManager.resumeStoppedRelays() } else { @@ -192,7 +215,11 @@ export async function initializeReadyFoundation(): Promise { } } }) + // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. attachClaudeLivePtyPersistence(store) + // Why: while a live claude defers the managed OAuth refresh, usage shows + // "Waiting for Claude session"; refetch when the last live PTY exits so the + // error clears immediately instead of after the failure backoff. onLiveClaudePtysDrained(() => { void state.rateLimits?.refreshAfterClaudeLivePtysDrained() }) @@ -208,28 +235,37 @@ export async function initializeReadyFoundation(): Promise { suppressDevEducationForStore(store) } try { + // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. const proxyApplyResult = await initialProxyApplication if (proxyApplyResult.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. console.warn('[proxy] persisted proxy settings are invalid; using direct networking') } } catch { console.warn('[proxy] Failed to apply network proxy settings') } + // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) + // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. installDocPreviewProtocolHandler() registerDocPreviewGrantHandlers() + // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. initializeBrowserSessionsForApp({ orcaProfileId: profile.profile.id, profileDirectory: profile.profileDirectory, + // Why: local direct-SSH partitions are scoped to targets, and the orphan + // sweep must see the live target list or it would clear their cookie jars. listLocalSshTargetIds: () => { const currentStore = state.store if (!currentStore) { + // Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep. throw new Error('ssh target store unavailable at partition sweep') } return currentStore.getSshTargets().map((target) => target.id) } }) try { + // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) } catch { console.warn('[proxy] Failed to apply network proxy settings to browser sessions') diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index c2e92607f2e..f89f63186a0 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -52,6 +52,7 @@ export async function initializeReadyRuntimeServices(): Promise { onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId) }) runtime.setAgentBrowserBridge(state.agentBrowserBridge) + // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. void state.agentBrowserBridge.sweepOrphanedSessions() const browserClientAutomationDispatcher = new RpcDispatcher({ runtime }) configureBrowserClientPageAutomationRuntime({ @@ -68,15 +69,22 @@ export async function initializeReadyRuntimeServices(): Promise { return response.result } }) + // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. + // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. state.emulatorBridge = new EmulatorBridge() runtime.setEmulatorBridge(state.emulatorBridge) - // Remove directories left behind by an interrupted worktree deletion. + // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or + // crash mid-delete can leave the moved directory on disk. void sweepStaleWorktreeTrash( collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) ).catch((error) => { console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) }) nativeTheme.themeSource = store.getSettings().theme ?? 'system' + // Why (#16441): the real-home grant runs a codex app-server session. It stays + // ordered before managed-hook reconciliation — an incapable host must re-arm + // and complete the legacy real-home sweep first — but awaiting it inline + // stalled app init behind that session, so chain instead of blocking. const startupManagedHookSettings = store.getSettings() const shouldReconcileStartupManagedHooks = shouldInstallManagedHooks(is.dev) && @@ -92,6 +100,9 @@ export async function initializeReadyRuntimeServices(): Promise { console.warn('[codex-real-home-hooks] startup ensure failed:', error) }) : Promise.resolve() + // Why skip rather than remove when the off switch is set: the hook files are user-global but this + // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca + // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. if (shouldReconcileStartupManagedHooks) { const managedHookStore = store void realHomeCodexHookState @@ -111,6 +122,8 @@ export async function initializeReadyRuntimeServices(): Promise { console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) ) } + // Why: process-gone metrics only see survivors; retain a recent whole-app + // snapshot for comparison in crash reports. startPreGoneProcessMetricsSampling() app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 64160f3d65c..78061fb439c 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -58,23 +58,33 @@ function installRuntimeRpc( runtime: RuntimeService, serveOptions: ReturnType | null ): OrcaRuntimeRpcServer { + // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) + // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) } + // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). const devWsPort = is.dev && !isE2E ? 6769 : undefined const runtimeRpc = new OrcaRuntimeRpcServer({ runtime, + // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. userDataPath: getCanonicalUserDataPath(), enableWebSocket: true, + // Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device; + // `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over. exposeNetworkByDefault: Boolean(serveOptions) || isE2E, ...(isE2E ? { wsPort: e2eWsPort } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), ...(serveOptions?.wsPort !== undefined - ? { wsPort: serveOptions.wsPort, preferPinnedWsPort: true } + ? { + wsPort: serveOptions.wsPort, + // Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability. + preferPinnedWsPort: true + } : {}), webClientRoot: getBundledWebClientRoot() }) @@ -94,7 +104,9 @@ function installRuntimeRpc( return true } }) + // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { + // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. state.pendingUnpairedDeviceAuthFailure = true if (state.mainWindow && !state.mainWindow.isDestroyed()) { state.mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') @@ -108,11 +120,13 @@ async function launchServeMode( runtimeRpc: OrcaRuntimeRpcServer, serveOptions: NonNullable> ): Promise { + // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. logStartupMilestone('wsl-cli-barrier-start') await state.managedWslCliStartupBarrierReady logStartupMilestone('wsl-cli-barrier-resolved', { reconciliation: state.managedWslCliReconciliationStatus }) + // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. await state.localPtyStartupReady await state.localPtyProviderStartupReady await registerHeadlessPtyRuntime( @@ -126,6 +140,7 @@ async function launchServeMode( ) await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (state.headlessBrowserDisplayAvailable) { runtime.setOffscreenBrowserBackend( new OffscreenBrowserBackend(browserManager, { @@ -133,15 +148,19 @@ async function launchServeMode( }) ) } + // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) settleDesktopActivation() + // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. registerServeSignalHandlers(process, () => app.quit()) + // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). if (process.platform === 'darwin' || process.platform === 'linux') { try { + // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. const cliStatus = await new CliInstaller({ privilegedRunner: async () => { throw new Error('serve CLI auto-install must not request administrator privileges') @@ -157,6 +176,7 @@ async function launchServeMode( ) } } + // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { try { const dispatcher = await installLinuxBareOrcaDispatcher({ @@ -173,7 +193,10 @@ async function launchServeMode( ) } } + // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. state.automations?.start() + // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is + // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. scheduleAllPendingHistoryTreeRemovals() await printServeReady(serveOptions) } @@ -189,6 +212,7 @@ async function launchDesktopMode( if (!runtimeRpc) { throw new Error('runtime_rpc_unavailable') } + // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. const [win, runtimeRpcStartResult] = await Promise.all([ Promise.resolve(desktopWindow ?? openMainWindow()), shellPathReady @@ -226,6 +250,8 @@ async function launchDesktopMode( provisionRelay: (context, params) => relayService.provisionRelay(context, params) }) relayService.start() + // Why: sleeping past relay-token expiry kills the broker with no retry + // timer; resume is the moment that state becomes recoverable. powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) } catch (error) { console.warn( @@ -234,7 +260,9 @@ async function launchDesktopMode( ) } } + // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. win.once('show', () => { + // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. const store = state.store if (store && store.getOnboarding().closedAt !== null) { triggerStartupNotificationRegistration(store) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 46e65c8c01f..77a073614da 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -63,18 +63,25 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { getProfileUserDataPath(), getProfileUserDataPath() ), + // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). getLocalProvider: () => getLocalPtyProvider(), + // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. getSshProvider: (connectionId) => getSshPtyProvider(connectionId), onPtyStopped: clearProviderPtyState, onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), + // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { if (state.mainWindow && !state.mainWindow.isDestroyed()) { state.mainWindow.webContents.send('pty:sideEffect', batch) } }, getDesktopWindowStatus, + // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + // Why: the filter above hides resume-identity rows from the live-agent views, but + // those rows carry the provider session mobile native chat addresses transcripts + // by — Pi publishes identity that way and would otherwise be unreachable. getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), getAgentProviderSessionRowsForPane: (paneKey) => agentHookServer.getStatusSnapshotForPane(paneKey), @@ -85,8 +92,11 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, + // Why: evaluated per call, not captured — the RPC server that owns the device registry is + // constructed with this runtime and does not exist yet at this point. getPairedDeviceName: (pairedDeviceId) => state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, + // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], prepareAiVaultSessionResume: (args) => @@ -106,6 +116,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { }) state.runtime = runtime runtime.prepareLegacyWorkerTerminalRecovery() + // Why before anything can attach: a client host that reattaches to a restarted runtime is only + // handed its pages back if the runtime found them first. runtime.rehydrateClientHostedBrowserPages() state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { @@ -130,6 +142,7 @@ export function configureRuntimeServices(runtime: OrcaRuntimeService): void { runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) runtime.setCommitMessageAgentEnvironmentResolvers({ + // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target) }) diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts index b38d7b3ac62..367bdf6d033 100644 --- a/src/main/startup/main-process-serve.ts +++ b/src/main/startup/main-process-serve.ts @@ -110,6 +110,7 @@ export async function printServeReady(options: ServeOptions): Promise { runtimeId: runtime.getRuntimeId(), boundEndpoint, advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + // Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair. managedWslCliReconciliation: state.managedWslCliReconciliationStatus, pairing: pairing.available ? { diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 330f690d268..ea1e0a33299 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -47,6 +47,7 @@ import { createWebContentsTimedFlag } from './web-contents-timed-flag' /** Mutable composition-root state shared by startup, window, serve, and quit phases. */ export const mainProcessState = { mainWindow: null as BrowserWindow | null, + /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ isQuitting: false, store: null as Store | null, stats: null as StatsCollector | null, @@ -65,6 +66,7 @@ export const mainProcessState = { desktopRelayService: null as DesktopRelayService | null, desktopRelayStatus: 'offline' as RelayBrokerStatus, pendingUnpairedDeviceAuthFailure: false, + // Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). headlessBrowserDisplayAvailable: false, starNag: null as StarNagService | null, agentAwakeService: null as AgentAwakeService | null, @@ -82,13 +84,16 @@ export const mainProcessState = { pluginMarketplaceService: null as PluginMarketplaceService | null, pluginMarketplaceInstaller: null as PluginMarketplaceInstaller | null, keybindings: null as KeybindingService | null, + // Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). expectedRendererReload: createWebContentsTimedFlag(), recoveryReloadInFlight: createWebContentsTimedFlag(), + // Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. pendingOpenSettings: createWebContentsTimedFlag(), skillShareDeepLinks: new SkillShareDeepLinkState(), firstWindowStartupServicesReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), + // Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). managedWslCliReconciliationStatus: 'settled' as 'pending' | 'settled' | 'failed', gpuCrashFallbackTracker: new GpuCrashFallbackTracker({ windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 629c0550581..2e583830a48 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -48,6 +48,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt return } if (providerSessionOnly) { + // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. state.mainWindow?.webContents.send('agentStatus:set', { ...payload, paneKey, @@ -99,6 +100,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) } options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. const profile = getSyntheticAgentTitleProfile(payload.agentType) if ( profile && @@ -131,8 +133,10 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt } export function clearMainWindowAgentStatusListeners(): void { + // Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations. agentHookServer.setListener(null) agentHookServer.setPaneStatusClearListener(null) setMigrationUnsupportedPtyListener(null) + // Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes. stopAllSyntheticTitleSpinners() } diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index f6f51e221ec..759a4b2b100 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -115,6 +115,7 @@ export function attachMainWindowCoreServices( } deps.recordRendererReload(ignoreCache) }, + // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). isRecoveryReloadInFlight, onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit, @@ -124,7 +125,9 @@ export function attachMainWindowCoreServices( onWorktreeLifecycle: emitPluginWorktreeLifecycle } ) + // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) rateLimits.attach(window) + // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. rateLimits.start({ fetchImmediately: false }) } diff --git a/src/main/startup/main-window-lifecycle-flags.ts b/src/main/startup/main-window-lifecycle-flags.ts index 2a4964c8f4e..fcb10fcec7c 100644 --- a/src/main/startup/main-window-lifecycle-flags.ts +++ b/src/main/startup/main-window-lifecycle-flags.ts @@ -30,6 +30,7 @@ export function markRecoveryReloadInFlight(webContentsId: number, durationMs = 1 } export function isRecoveryReloadInFlight(webContentsId: number): boolean { + // Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs. return state.recoveryReloadInFlight.matches(webContentsId, { consume: true }) } diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts index dde50f4d2c8..f8b02860bef 100644 --- a/src/main/startup/synthetic-title-runtime.ts +++ b/src/main/startup/synthetic-title-runtime.ts @@ -16,6 +16,8 @@ import { shouldCopySyntheticTitleFrameToPtyData } from '../synthetic-title-frame import { resolveTuiAgentPermissionMode } from '../../shared/tui-agent-permissions' import { mainProcessState as state } from './main-process-state' +// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. +// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX. const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const SPINNER_INTERVAL_MS = 80 const syntheticTitleSpinnerByPaneKey = new Map< @@ -34,6 +36,7 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool if (!window || window.isDestroyed()) { return } + // Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL. if ( !shouldSendSyntheticTitleFrame({ force: options.force === true, @@ -42,7 +45,9 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool ) { return } + // Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes. state.runtime?.ingestSyntheticTitleFrame(ptyId, data) + // Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts). if (shouldCopySyntheticTitleFrameToPtyData(state.store?.getSettings())) { window.webContents.send('pty:data', { id: ptyId, data }) } @@ -106,6 +111,7 @@ function ensureSyntheticTitleSpinnerTimer(): void { ) { return } + // Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working. syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) } @@ -123,10 +129,12 @@ export function driveSyntheticTitleFromHook( return } if (agentState === 'working') { + // Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick. const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) const frame = existing ? existing.frame : 0 sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) if (existing) { + // Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state. existing.profile = profile return } @@ -134,6 +142,8 @@ export function driveSyntheticTitleFromHook( ensureSyntheticTitleSpinnerTimer() return } + // Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame. + // Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention). stopSyntheticTitleSpinner(paneKey) const needsUserInput = agentState === 'blocked' || agentState === 'waiting' const label = needsUserInput ? profile.permissionLabel : profile.idleLabel @@ -164,8 +174,12 @@ export function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { } export function initializeSyntheticTitleRuntime(): void { + // Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) - // Retire synthetic titles with either pane-scoped clears or explicit status drops. + // Why: the spinner is a stand-in for a live hook status, so it must retire with the row it + // stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a + // working title long after the agent finished (#13890). Both paths are covered: the + // pane-scoped clear fan-out, and user dismissal, which never routes through it. agentHookServer.subscribePaneStatusClear((clear) => { const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) if (paneKey) { diff --git a/src/main/startup/web-contents-timed-flag.ts b/src/main/startup/web-contents-timed-flag.ts index 4e281c29a02..b3ac602f98e 100644 --- a/src/main/startup/web-contents-timed-flag.ts +++ b/src/main/startup/web-contents-timed-flag.ts @@ -1,4 +1,4 @@ -/** A short-lived renderer intent scoped to one WebContents instance. */ +// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. export function createWebContentsTimedFlag(defaultDurationMs = 10_000): { mark: (webContentsId: number, durationMs?: number) => void clear: (webContentsId?: number) => void diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts index ce6b4d3f398..048f293f01e 100644 --- a/src/main/updater/updater-install-support.ts +++ b/src/main/updater/updater-install-support.ts @@ -8,10 +8,7 @@ import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' import { resetMacInstallState } from '../updater-mac-install' -import type { - LinuxPackageInstallRecovery, - UpdateStatus -} from '../../shared/update-status-types' +import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../shared/update-status-types' import { compareVersions } from '../updater-fallback' import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' import { UpdaterCheckState } from './updater-check-state' From a573c5706bff8fbc8473c4dac80b3e0528280241 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:04:50 -0700 Subject: [PATCH 18/93] docs(child-process): note split-relocated allowlist entries --- .../__fixtures__/child-process-import-allowlist.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index fead2c8cde7..213b9ebdd96 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -23,6 +23,11 @@ # # daemon-launched-child.ts holds daemon-init.ts's fork() site; splitting that # file moved the import, it did not add one. +# +# Likewise the agent-browser-bridge-* and ipc/filesystem/* entries: splitting +# agent-browser-bridge.ts and ipc/filesystem.ts moved these imports, it did not +# add any. Only -execution.ts and -raw-process.ts spawn, and both already pass +# windowsHide; the other four import ChildProcess as a type only. src/cli/handlers/account.ts src/cli/handlers/core.ts src/cli/handlers/interactive-login-interruption.ts From 997d3e1b55d3b68f86cb44e01673892a1ee86f74 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:31:29 -0700 Subject: [PATCH 19/93] Import browser cookie search to tab (#17627) * Add browser history search to the new-tab omnibox - Display matching pages from browser history in the tab entry panel - Extract address-bar history scoring into reusable `browser-history-match` module - Rank by match tier (host prefix > substring > title > tail) then frecency * Replace History icon with ExternalLink for browser search results * Replace ExternalLink with Globe icon for browser search results * Fix browser history matching for workspace docs and recency rankings Promote path-prefix matches to top tier for entries without a host (workspace docs), and clamp the recency bonus so future timestamps cannot outrank fresh visits. Includes tests for both path-prefix promotion and recency bonus clamping behavior. * Cache browser history by identity and snapshot omnibox entries - Use WeakMap to cache prepared browser history entries by identity, so re-parsing is skipped for the same snapshot - Change omnibox to read a history snapshot at menu open (via getState) instead of subscribing to live updates, preventing background navigations from reshuffling results mid-keystroke - Support fully-qualified URL prefix matching (e.g., `https://github.com`) to preserve address-bar behavior - Fix percentile calculation in performance tests (nearest-rank method) * Break browser history ties with URL for stable snapshot ordering When browser history entries tie on tier, score, and recency, the sort order can become non-deterministic, especially when combining browser and document history that may be reordered in snapshots. Add normalizedUrl as the final tie-breaker to guarantee consistent ordering. --- .../browser-address-bar-suggestions.test.ts | 189 ++++++++++++- .../browser-address-bar-suggestions.ts | 68 +++-- .../TabBarCreateEntry.history.test.tsx | 255 ++++++++++++++++++ .../TabBarCreateEntry.keyboard.test.tsx | 2 +- .../components/tab-bar/TabBarCreateEntry.tsx | 63 ++--- .../tab-bar/TabBarCreateEntryRow.tsx | 13 + .../tab-bar/open-tab-entry-dedupe.test.ts | 1 + .../tab-bar/open-tab-entry-dedupe.ts | 43 +++ .../src/components/tab-bar/open-tab-search.ts | 4 +- .../open-tab-selection-routing.test.ts | 3 +- .../tab-bar/tab-create-entry-active-option.ts | 16 +- .../tab-create-entry-classifier.test.ts | 2 +- .../tab-bar/tab-create-entry-classifier.ts | 11 +- .../tab-bar/tab-create-entry-copy.ts | 4 +- ...tab-create-entry-history-placement.test.ts | 128 +++++++++ .../tab-create-entry-history-placement.ts | 48 ++++ .../tab-create-entry-network-selection.ts | 7 +- .../tab-bar/use-omnibox-browser-history.ts | 114 ++++++++ .../use-tab-entry-menu-return-focus.ts | 35 +++ src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- .../src/lib/browser-history-match-budget.ts | 16 ++ .../browser-history-match.performance.test.ts | 68 +++++ .../src/lib/browser-history-match.test.ts | 214 +++++++++++++++ src/renderer/src/lib/browser-history-match.ts | 143 ++++++++++ .../src/lib/browser-palette-search.ts | 3 + 29 files changed, 1361 insertions(+), 111 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-history-placement.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-history-placement.ts create mode 100644 src/renderer/src/components/tab-bar/use-omnibox-browser-history.ts create mode 100644 src/renderer/src/components/tab-bar/use-tab-entry-menu-return-focus.ts create mode 100644 src/renderer/src/lib/browser-history-match-budget.ts create mode 100644 src/renderer/src/lib/browser-history-match.performance.test.ts create mode 100644 src/renderer/src/lib/browser-history-match.test.ts create mode 100644 src/renderer/src/lib/browser-history-match.ts diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts index 597bc7b990f..40d8314a7a2 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types' import { BROWSER_ADDRESS_BAR_QUERY_MAX_BYTES, buildBrowserAddressBarSuggestions, - isBrowserAddressBarQueryTooLarge + isBrowserAddressBarQueryTooLarge, + MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS } from './browser-address-bar-suggestions' function historyEntry(overrides: Partial): BrowserHistoryEntry { @@ -121,6 +122,172 @@ describe('browser address bar suggestions', () => { }) ).toEqual([]) }) + + describe('history scoring', () => { + const now = 1_700_000_000_000 + const hoursAgo = (hours: number): number => now - hours * 60 * 60 * 1000 + // The synthetic top row is composition, not scoring; ranking asserts on history only. + const historyUrls = (suggestions: readonly { isSearch: boolean; url: string }[]): string[] => + suggestions.filter((suggestion) => !suggestion.isSearch).map((suggestion) => suggestion.url) + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('ranks a host-prefix match above a host-substring match above a title match', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'git', + browserUrlHistory: [ + historyEntry({ + url: 'https://example.com/setup', + normalizedUrl: 'https://example.com/setup', + title: 'Configure git hooks', + lastVisitedAt: now, + visitCount: 50 + }), + historyEntry({ + url: 'https://docs.github.com/actions', + normalizedUrl: 'https://docs.github.com/actions', + title: 'Actions', + lastVisitedAt: hoursAgo(20), + visitCount: 1 + }), + historyEntry({ + url: 'https://github.com/acme/orca', + normalizedUrl: 'https://github.com/acme/orca', + title: 'acme/orca', + lastVisitedAt: hoursAgo(20), + visitCount: 1 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual([ + 'https://github.com/acme/orca', + 'https://docs.github.com/actions', + 'https://example.com/setup' + ]) + }) + + it('sorts a path-only match last even when it is frequent and fresh', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'git', + browserUrlHistory: [ + historyEntry({ + url: 'https://example.com/?ref=git', + normalizedUrl: 'https://example.com/?ref=git', + title: 'Example', + lastVisitedAt: now, + visitCount: 500 + }), + historyEntry({ + url: 'https://example.org/notes', + normalizedUrl: 'https://example.org/notes', + title: 'Stale git notes', + lastVisitedAt: hoursAgo(400), + visitCount: 1 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual([ + 'https://example.org/notes', + 'https://example.com/?ref=git' + ]) + }) + + it('clamps the visit-count bonus at 50 so recency still breaks the tie', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme', + browserUrlHistory: [ + historyEntry({ + url: 'https://acme.dev/a', + normalizedUrl: 'https://acme.dev/a', + title: 'A', + lastVisitedAt: hoursAgo(23), + visitCount: 50 + }), + historyEntry({ + url: 'https://acme.dev/b', + normalizedUrl: 'https://acme.dev/b', + title: 'B', + lastVisitedAt: hoursAgo(1), + visitCount: 5000 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual(['https://acme.dev/b', 'https://acme.dev/a']) + }) + + it('floors the recency bonus at zero for entries older than a day', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme', + browserUrlHistory: [ + historyEntry({ + url: 'https://acme.dev/ancient', + normalizedUrl: 'https://acme.dev/ancient', + title: 'Ancient', + lastVisitedAt: hoursAgo(10_000), + visitCount: 3 + }), + historyEntry({ + url: 'https://acme.dev/old', + normalizedUrl: 'https://acme.dev/old', + title: 'Old', + lastVisitedAt: hoursAgo(100), + visitCount: 2 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual(['https://acme.dev/ancient', 'https://acme.dev/old']) + }) + + it('leaves one slot for the synthetic top row when history overflows', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme', + browserUrlHistory: Array.from({ length: 20 }, (_, index) => + historyEntry({ + url: `https://acme.dev/page-${index}`, + normalizedUrl: `https://acme.dev/page-${index}`, + title: `Page ${index}`, + lastVisitedAt: hoursAgo(index), + visitCount: 1 + }) + ) + }) + + expect(suggestions).toHaveLength(MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) + expect(suggestions[0]).toMatchObject({ isSearch: true }) + expect(suggestions.slice(1).every((suggestion) => suggestion.url.includes('/page-'))).toBe( + true + ) + }) + + it('folds the synthetic row away when history already offers the same url', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme.dev', + browserUrlHistory: [ + historyEntry({ + url: 'https://acme.dev/', + normalizedUrl: 'https://acme.dev', + title: 'Acme', + lastVisitedAt: now, + visitCount: 4 + }) + ] + }) + + expect(suggestions).toEqual([ + expect.objectContaining({ url: 'https://acme.dev/', title: 'Acme', isSearch: false }) + ]) + }) + }) }) describe('workspace document suggestions', () => { @@ -171,4 +338,22 @@ describe('workspace document suggestions', () => { }) expect(byPath.some((row) => row.docLocation)).toBe(true) }) + + it('ranks a path-prefix document above a heavily visited url-tail history match', () => { + const rows = buildBrowserAddressBarSuggestions({ + value: '/repo/docs', + browserUrlHistory: [ + { + url: 'https://example.com/repo/docs/index.html', + normalizedUrl: 'https://example.com/repo/docs/index.html', + title: 'Docs Index', + lastVisitedAt: Date.now(), + visitCount: 500 + } + ], + workspaceDocHistory: [DOC_ENTRY] + }) + + expect(rows.find((row) => !row.isSearch)?.docLocation).toEqual(DOC_ENTRY.docLocation) + }) }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts index a3122ceb8c6..898b6189407 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts @@ -11,6 +11,7 @@ import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types' import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' +import { matchBrowserHistory, prepareBrowserHistoryEntries } from '@/lib/browser-history-match' import { isClipboardTextByteLengthOverLimit } from '../../../../../shared/clipboard-text' import { translate } from '@/i18n/i18n' @@ -49,28 +50,6 @@ export function isBrowserAddressBarQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -function scoreBrowserAddressBarSuggestion( - entry: { url: string; title: string; lastVisitedAt: number; visitCount: number }, - query: string -): number { - const lowerQuery = query.toLowerCase() - const lowerUrl = entry.url.toLowerCase() - const lowerTitle = entry.title.toLowerCase() - - if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) { - return -1 - } - - let score = 0 - if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) { - score += 100 - } - score += Math.min(entry.visitCount, 50) - const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60) - score += Math.max(0, 24 - ageHours) - return score -} - export function buildBrowserAddressBarSuggestions({ browserUrlHistory, workspaceDocHistory = [], @@ -97,22 +76,35 @@ export function buildBrowserAddressBarSuggestions({ .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) } - const scoredRows: { row: BrowserAddressBarSuggestion; score: number }[] = [ - ...browserUrlHistory.map((entry) => ({ - row: { ...entry, subtitle: entry.url, isSearch: false }, - score: scoreBrowserAddressBarSuggestion(entry, trimmed) - })), - // The scorer only reads url/title/recency/visits, and a doc row's url is its path. - ...workspaceDocHistory.map(toWorkspaceDocSuggestion).map((row) => ({ - row, - score: scoreBrowserAddressBarSuggestion(row, trimmed) - })) - ] - const historySuggestions: BrowserAddressBarSuggestion[] = scoredRows - .filter((item) => item.score >= 0) - .sort((a, b) => b.score - a.score) - .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1) - .map((item) => item.row) + const documentRows = workspaceDocHistory.map(toWorkspaceDocSuggestion) + const documentEntries: BrowserHistoryEntry[] = documentRows.map((row) => ({ + url: row.url, + normalizedUrl: row.url, + title: row.title, + lastVisitedAt: row.lastVisitedAt, + visitCount: row.visitCount + })) + const rowByEntry = new Map() + for (const entry of browserUrlHistory) { + rowByEntry.set(entry, { ...entry, subtitle: entry.url, isSearch: false }) + } + documentEntries.forEach((entry, index) => rowByEntry.set(entry, documentRows[index])) + // Why prepare the caller's array as-is: it is the stable `browserUrlHistory` + // identity, so the prepare cache hits instead of re-lowercasing every keystroke. + const preparedHistory = prepareBrowserHistoryEntries(browserUrlHistory) + const prepared = + documentEntries.length === 0 + ? preparedHistory + : [...preparedHistory, ...prepareBrowserHistoryEntries(documentEntries)] + // Why url-tail is kept here: the address bar is a navigation surface, so a + // path-only recall is still a destination — it just never outranks a real one. + const historySuggestions: BrowserAddressBarSuggestion[] = matchBrowserHistory({ + prepared, + query: trimmed, + limit: MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1 + }) + .map(({ entry }) => rowByEntry.get(entry)) + .filter((row): row is BrowserAddressBarSuggestion => row !== undefined) const isQuery = looksLikeSearchQuery(trimmed) let topAction: BrowserAddressBarSuggestion | null diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx new file mode 100644 index 00000000000..a1ec5fad293 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx @@ -0,0 +1,255 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { BrowserHistoryEntry } from '../../../../shared/browser-workspace-types' +import type { OpenTabSearchResult } from './open-tab-search' +import type * as ReactModule from 'react' +import type { TabCreateEntryArgs, TabEntryOption } from './tab-create-entry-action' + +const entryOptionsMock = vi.hoisted(() => ({ options: [] as TabEntryOption[] })) +const pathLikeMock = vi.hoisted(() => ({ value: false })) +vi.mock('./tab-create-entry-action', () => ({ + getTabEntryOptions: () => entryOptionsMock.options, + createTabEntryAllowAbsolutePathsSelector: () => () => true, + isTabEntryAbsolutePathLike: () => pathLikeMock.value +})) +vi.mock('../quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: () => ({ + files: [], + loading: false, + loadError: null, + truncated: false + }) +})) +vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [], AgentIcon: () => null })) + +// `hold` pins deferred rows to the query they were built from, standing in for +// the hook's useDeferredValue so later keystrokes leave them stale. +const historyStoreMock = vi.hoisted(() => ({ + entries: [] as BrowserHistoryEntry[], + hold: null as string | null, + listeners: new Set<() => void>() +})) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => + selector({ + browserUrlHistory: historyStoreMock.entries, + browserDefaultSearchEngine: 'google', + getKnownWorktreeById: () => ({ path: '/tmp/wt' }) + }), + { getState: () => ({ browserUrlHistory: historyStoreMock.entries }) } + ) +})) +vi.mock('react', async () => { + const react = await vi.importActual('react') + return { + ...react, + useDeferredValue: (value: string) => historyStoreMock.hold ?? value + } +}) + +const tabResultsMock = vi.hoisted(() => ({ results: [] as OpenTabSearchResult[] })) +vi.mock('./use-tab-create-entry-search-results', () => ({ + useTabCreateEntrySearchResults: ({ enabled }: { enabled: boolean }) => + enabled ? tabResultsMock.results : [] +})) + +import TabBarCreateEntry from './TabBarCreateEntry' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function historyEntry(overrides: Partial & { url: string }) { + return { + normalizedUrl: overrides.url.replace(/\/$/, ''), + title: 'Linear', + lastVisitedAt: Date.now(), + visitCount: 4, + ...overrides + } +} + +const linear = historyEntry({ + url: 'https://linear.app/acme/team/ORC/active', + title: 'ORC · Active issues' +}) + +let container: HTMLDivElement +let root: Root +let onOpenEntry: Mock<(args: TabCreateEntryArgs) => Promise> + +function mount(): void { + act(() => { + root.render( + + + + ) + }) +} + +function setQuery(value: string): void { + const input = container.querySelector('input') + if (!input) { + throw new Error('input not found') + } + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + act(() => { + nativeSetter?.call(input, value) + input.dispatchEvent(new window.Event('input', { bubbles: true })) + }) +} + +function pressKey(key: string): void { + const form = container.querySelector('form') + if (!form) { + throw new Error('form not found') + } + act(() => { + form.dispatchEvent( + new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + ) + }) +} + +function submitForm(): void { + const form = container.querySelector('form') + if (!form) { + throw new Error('form not found') + } + act(() => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })) + }) +} + +function rowTexts(): string[] { + return [...container.querySelectorAll('[role="option"]')].map((row) => row.textContent ?? '') +} + +beforeEach(() => { + vi.clearAllMocks() + entryOptionsMock.options = [] + pathLikeMock.value = false + tabResultsMock.results = [] + historyStoreMock.entries = [linear] + historyStoreMock.hold = null + onOpenEntry = vi.fn().mockResolvedValue(undefined) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('TabBarCreateEntry browser history rows', () => { + it('holds history back until the query is at least two characters', () => { + mount() + + setQuery('l') + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + + setQuery('li') + expect(rowTexts()[0]).toContain('Open page') + expect(rowTexts()[0]).toContain('ORC · Active issues') + expect(rowTexts()[0]).toContain('linear.app/acme/team/ORC/active') + }) + + it('shows no history for an empty omnibox', () => { + mount() + + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + }) + + it('skips history for a path-shaped query and for a forced search', () => { + pathLikeMock.value = true + mount() + setQuery('/Users/jane/linear') + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + + pathLikeMock.value = false + setQuery('?linear') + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + }) + + it('yields to the switch row when the page is already open in a browser tab', () => { + tabResultsMock.results = [ + { + executionHostId: 'local', + source: 'browser', + id: 'open-tab:browser:page-1', + title: 'ORC · Active issues', + matchedText: null, + worktreeId: 'wt', + contentType: 'browser', + pageId: 'page-1', + workspaceId: 'ws-1', + url: 'https://linear.app/acme/team/ORC/active' + } + ] + mount() + + setQuery('linear') + + const rows = rowTexts() + expect(rows[0]).toContain('Switch to tab') + expect(rows.some((text) => text.includes('Open page'))).toBe(false) + }) + + it('opens the visited url through the entry path when the row is submitted', () => { + mount() + setQuery('linear') + pressKey('ArrowDown') + + submitForm() + + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'explicit-url', url: 'https://linear.app/acme/team/ORC/active' }, + worktreeId: 'wt', + groupId: 'g' + }) + ) + }) + + it('keeps arrow-key selection across a background history write', () => { + historyStoreMock.entries = [linear, historyEntry({ url: 'https://linear.app/acme/inbox' })] + mount() + setQuery('linear') + pressKey('ArrowDown') + pressKey('ArrowDown') + const selectedBefore = container.querySelector('[aria-selected="true"]')?.textContent + + // A committed navigation elsewhere replaces the store array mid-session. + act(() => { + historyStoreMock.entries = [ + historyEntry({ url: 'https://unrelated.example/page' }), + ...historyStoreMock.entries + ] + }) + setQuery('linear ') + + expect(container.querySelector('[aria-selected="true"]')?.textContent).toBe(selectedBefore) + }) + + it('refuses to submit a row the visible query no longer matches', () => { + mount() + setQuery('linear') + pressKey('ArrowDown') + // The deferred pass is still describing "linear" while the input reads "zzz". + historyStoreMock.hold = 'linear' + setQuery('zzz') + + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + submitForm() + expect(onOpenEntry).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index 2f02c7901d9..035cba0c0c1 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -306,7 +306,7 @@ describe('TabBarCreateEntry keyboard navigation', () => { const input = container.querySelector('input')! const placeholder = input.getAttribute('placeholder') - expect(placeholder).toBe('Search open tabs, files, URLs, agents\u2026') + expect(placeholder).toBe('Search open tabs, history, files, URLs, agents\u2026') expect(input.getAttribute('aria-label')).toBe(placeholder) }) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index c22b3ce77f9..ccc985379d7 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -8,11 +8,7 @@ import { } from './tab-create-entry-action' import { findMatchingTabAgentLaunchOptions } from './tab-agent-launch-options' import { findMatchingTabCreateMenuOptions } from './tab-create-menu-options' -import { - getActiveOptionId, - isActiveEntryOption, - type ActiveOption -} from './tab-create-entry-active-option' +import { getActiveOptionId, type ActiveOption } from './tab-create-entry-active-option' import { EntryActionRow, EntryStatusRow, @@ -20,6 +16,9 @@ import { resultOptionDomId } from './TabBarCreateEntryRow' import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe' +import { insertHistoryRowsBelowFileMatches } from './tab-create-entry-history-placement' +import { useOmniboxBrowserHistory } from './use-omnibox-browser-history' +import { useTabEntryMenuReturnFocus } from './use-tab-entry-menu-return-focus' import { activateOpenTabSearchResult } from './open-tab-selection-routing' import { useTabCreateEntrySearchResults } from './use-tab-create-entry-search-results' import { DEFAULT_SEARCH_ENGINE } from '../../../../shared/browser-url' @@ -34,6 +33,7 @@ import { getTabEntryOmniboxPlaceholder } from './tab-create-entry-copy' import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options' +import type { TabEntryActionClassification } from './tab-create-entry-classifier' import type { TabBarCreateEntryProps } from './tab-create-entry-props' export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.JSX.Element { @@ -98,34 +98,7 @@ function TabBarCreateEntrySession({ (state) => state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE ) - // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the - // first item should return to the search box so the keyboard trip isn't - // one-way. Capture phase beats Radix's roving-focus handler. - useEffect(() => { - if (!menuOpen) { - return - } - const input = inputRef.current - const menu = input?.closest('[role="menu"]') - if (!input || !menu) { - return - } - const handleMenuKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'ArrowUp') { - return - } - const firstItem = menu.querySelector( - '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' - ) - if (firstItem && document.activeElement === firstItem) { - event.preventDefault() - event.stopPropagation() - input.focus() - } - } - menu.addEventListener('keydown', handleMenuKeyDown, true) - return () => menu.removeEventListener('keydown', handleMenuKeyDown, true) - }, [menuOpen]) + useTabEntryMenuReturnFocus(inputRef, menuOpen) useEffect(() => { if (!menuOpen) { @@ -165,6 +138,11 @@ function TabBarCreateEntrySession({ tabResults, worktreePath ]) + const historyRows = useOmniboxBrowserHistory({ + enabled: menuOpen && !terminalQueryMode, + query, + tabResults + }) const matchingAgentOptions = useMemo( () => terminalQueryMode @@ -188,10 +166,7 @@ function TabBarCreateEntrySession({ kind: 'agent' as const, option })), - ...options.filter(isActiveEntryOption).map((option) => ({ - kind: 'entry' as const, - option - })) + ...insertHistoryRowsBelowFileMatches(options, historyRows) ] const { activeSelectedIndex, selectedActiveOption } = useNetworkSafeTabEntrySelection({ activeOptions, @@ -255,16 +230,16 @@ function TabBarCreateEntrySession({ onDidOpenEntry?.() return } + // A history row is a navigation, so it rides the entry-open path the typed-URL + // row already uses — routing, worktree targeting and SSH resolution included. + const classification: TabEntryActionClassification = + selectedOption.kind === 'history' + ? { kind: 'explicit-url', url: selectedOption.option.entry.url } + : selectedOption.option.classification setPending(true) setError(null) const submissionId = ++submissionIdRef.current - void onOpenEntry({ - query, - worktreeId, - groupId, - fileList, - classification: selectedOption.option.classification - }) + void onOpenEntry({ query, worktreeId, groupId, fileList, classification }) .then(() => { if (submissionIdRef.current === submissionId) { onDidOpenEntry?.() diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 3137162d96e..a55b9d82974 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils' import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url' +import { formatBrowserHistoryUrl } from '@/lib/browser-history-match' import type { ActiveOption } from './tab-create-entry-active-option' export const RESULT_LISTBOX_ID = 'tab-create-entry-results' @@ -183,6 +184,18 @@ function getActionPresentation(option: ActiveOption): { showDetail: true } } + if (option.kind === 'history') { + const { entry } = option.option + const url = formatBrowserHistoryUrl(entry.url) + return { + // Why the title is detail, not label: the label span is shrink-0 whenever a + // detail shows, so a variable-length title there would refuse to truncate. + detail: entry.title ? `${entry.title} · ${url}` : url, + icon: