diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index d9f19dcc1b8..2f91c946bc7 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -69,6 +69,14 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
+ # Why: oxlint fails any file over max-lines that is NOT suppressed, so this
+ # ratchet forbids ADDING a new suppression (inline disable or mobile max
+ # bump). Existing oversized files are grandfathered in
+ # config/max-lines-baseline.txt, which may only shrink — new bypasses fail
+ # here with a clear message instead of silently growing the debt.
+ - name: Enforce max-lines ratchet (no new bypasses)
+ run: pnpm run check:max-lines-ratchet
+
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
diff --git a/AGENTS.md b/AGENTS.md
index 647c598a25e..b10035e9b17 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
## Lint Rules: Do Not Disable Max Lines
-Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
+Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
+
+This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
## File and Module Naming
diff --git a/README.md b/README.md
index 1043bc9d670..8e9bc05d3f3 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
+- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
---
diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
new file mode 100644
index 00000000000..543ff2e6a60
--- /dev/null
+++ b/config/max-lines-baseline.txt
@@ -0,0 +1,359 @@
+# Files/globs currently allowed to exceed the oxlint `max-lines` budget.
+# 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 mobile/src/constants/marine-creatures.ts
+inline src/cli/handlers/automations.ts
+inline src/cli/handlers/orchestration.ts
+inline src/cli/help.ts
+inline src/cli/index.test.ts
+inline src/main/agent-hooks/server.test.ts
+inline src/main/agent-hooks/server.ts
+inline src/main/amp/hook-service.ts
+inline src/main/antigravity/hook-service.ts
+inline src/main/attribution/terminal-attribution.ts
+inline src/main/automations/external-manager.ts
+inline src/main/automations/hermes-cron-output.ts
+inline src/main/browser/agent-browser-bridge.test.ts
+inline src/main/browser/agent-browser-bridge.ts
+inline src/main/browser/browser-cookie-import.ts
+inline src/main/browser/browser-guest-ui.ts
+inline src/main/browser/browser-manager-grab.test.ts
+inline src/main/browser/browser-manager.test.ts
+inline src/main/browser/browser-manager.ts
+inline src/main/browser/browser-screencast-stream.ts
+inline src/main/browser/browser-session-registry.ts
+inline src/main/browser/cdp-bridge.ts
+inline src/main/browser/cdp-ws-proxy.ts
+inline src/main/browser/grab-guest-script.ts
+inline src/main/browser/snapshot-engine.ts
+inline src/main/claude-accounts/runtime-auth-service.test.ts
+inline src/main/claude-accounts/runtime-auth-service.ts
+inline src/main/claude-accounts/service.test.ts
+inline src/main/claude-accounts/service.ts
+inline src/main/claude-usage/scanner.ts
+inline src/main/claude-usage/store.ts
+inline src/main/cli/cli-installer.test.ts
+inline src/main/cli/cli-installer.ts
+inline src/main/cli/wsl-cli-installer.ts
+inline src/main/codex-accounts/runtime-home-service.test.ts
+inline src/main/codex-accounts/runtime-home-service.ts
+inline src/main/codex-accounts/service.test.ts
+inline src/main/codex-accounts/service.ts
+inline src/main/codex-usage/scanner.ts
+inline src/main/codex-usage/store.test.ts
+inline src/main/codex-usage/store.ts
+inline src/main/codex/config-toml-trust.test.ts
+inline src/main/codex/config-toml-trust.ts
+inline src/main/codex/hook-service.test.ts
+inline src/main/codex/hook-service.ts
+inline src/main/copilot/hook-service.ts
+inline src/main/daemon/client.ts
+inline src/main/daemon/daemon-health.ts
+inline src/main/daemon/daemon-init.test.ts
+inline src/main/daemon/daemon-init.ts
+inline src/main/daemon/daemon-pty-adapter.test.ts
+inline src/main/daemon/daemon-pty-adapter.ts
+inline src/main/daemon/daemon-server.ts
+inline src/main/daemon/pty-subprocess.test.ts
+inline src/main/daemon/pty-subprocess.ts
+inline src/main/daemon/session.ts
+inline src/main/daemon/shell-ready.ts
+inline src/main/git/remove-worktree.test.ts
+inline src/main/git/repo.ts
+inline src/main/git/runner.ts
+inline src/main/git/status.test.ts
+inline src/main/git/status.ts
+inline src/main/git/worktree.test.ts
+inline src/main/git/worktree.ts
+inline src/main/github/client.test.ts
+inline src/main/github/client.ts
+inline src/main/github/issues.ts
+inline src/main/github/pr-refresh-coordinator.test.ts
+inline src/main/github/pr-refresh-coordinator.ts
+inline src/main/github/project-view.ts
+inline src/main/github/project-view/mutations.ts
+inline src/main/github/work-item-details.ts
+inline src/main/gitlab/client-mr.test.ts
+inline src/main/gitlab/client.ts
+inline src/main/gitlab/issues.ts
+inline src/main/gitlab/work-item-details.ts
+inline src/main/hermes/hook-service.ts
+inline src/main/hooks.test.ts
+inline src/main/hooks.ts
+inline src/main/index.ts
+inline src/main/ipc/browser.ts
+inline src/main/ipc/crash-reporting.ts
+inline src/main/ipc/filesystem-auth.ts
+inline src/main/ipc/filesystem-mutations.ts
+inline src/main/ipc/filesystem-watcher.ts
+inline src/main/ipc/filesystem.test.ts
+inline src/main/ipc/filesystem.ts
+inline src/main/ipc/github.test.ts
+inline src/main/ipc/github.ts
+inline src/main/ipc/gitlab.ts
+inline src/main/ipc/linear.ts
+inline src/main/ipc/notifications.test.ts
+inline src/main/ipc/notifications.ts
+inline src/main/ipc/pet.ts
+inline src/main/ipc/preflight.test.ts
+inline src/main/ipc/pty.test.ts
+inline src/main/ipc/pty.ts
+inline src/main/ipc/remote-workspace.ts
+inline src/main/ipc/repos-remote.test.ts
+inline src/main/ipc/repos.ts
+inline src/main/ipc/runtime-environments.test.ts
+inline src/main/ipc/ssh.test.ts
+inline src/main/ipc/ssh.ts
+inline src/main/ipc/worktree-remote.ts
+inline src/main/ipc/worktrees.test.ts
+inline src/main/ipc/worktrees.ts
+inline src/main/jira/client.ts
+inline src/main/jira/issues.ts
+inline src/main/keybindings/keybinding-file.ts
+inline src/main/linear/client.ts
+inline src/main/linear/issues.ts
+inline src/main/linear/projects.ts
+inline src/main/memory/collector.ts
+inline src/main/opencode-usage/scanner.ts
+inline src/main/opencode-usage/store.ts
+inline src/main/opencode/hook-service.ts
+inline src/main/persistence.test.ts
+inline src/main/persistence.ts
+inline src/main/ports/advertised-url-watcher.ts
+inline src/main/ports/local-workspace-port-scanner.ts
+inline src/main/project-groups/nested-repo-discovery.ts
+inline src/main/providers/local-pty-provider.test.ts
+inline src/main/providers/local-pty-provider.ts
+inline src/main/providers/local-pty-shell-ready.test.ts
+inline src/main/providers/local-pty-shell-ready.ts
+inline src/main/providers/ssh-git-provider.test.ts
+inline src/main/providers/ssh-git-provider.ts
+inline src/main/rate-limits/claude-fetcher.test.ts
+inline src/main/rate-limits/claude-fetcher.ts
+inline src/main/rate-limits/claude-pty.ts
+inline src/main/rate-limits/codex-fetcher.ts
+inline src/main/rate-limits/service.test.ts
+inline src/main/rate-limits/service.ts
+inline src/main/runtime/orca-runtime-browser.ts
+inline src/main/runtime/orca-runtime-files.test.ts
+inline src/main/runtime/orca-runtime-files.ts
+inline src/main/runtime/orca-runtime-git.ts
+inline src/main/runtime/orca-runtime.test.ts
+inline src/main/runtime/orca-runtime.ts
+inline src/main/runtime/orchestration/coordinator.ts
+inline src/main/runtime/orchestration/db.ts
+inline src/main/runtime/rpc/methods/files.ts
+inline src/main/runtime/rpc/methods/git.ts
+inline src/main/runtime/rpc/methods/github.ts
+inline src/main/runtime/rpc/methods/orchestration.test.ts
+inline src/main/runtime/rpc/methods/orchestration.ts
+inline src/main/runtime/rpc/methods/terminal.ts
+inline src/main/runtime/rpc/terminal-multiplex.test.ts
+inline src/main/runtime/runtime-rpc.test.ts
+inline src/main/runtime/runtime-rpc.ts
+inline src/main/source-control/hosted-review-creation.ts
+inline src/main/speech/model-manager.ts
+inline src/main/speech/stt-service.ts
+inline src/main/ssh/ssh-channel-multiplexer.ts
+inline src/main/ssh/ssh-connection.test.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/star-nag/service.test.ts
+inline src/main/text-generation/commit-message-text-generation.test.ts
+inline src/main/text-generation/commit-message-text-generation.ts
+inline src/main/updater.test.ts
+inline src/main/updater.ts
+inline src/main/window/attach-main-window-services.ts
+inline src/main/window/createMainWindow.test.ts
+inline src/main/window/createMainWindow.ts
+inline src/main/workspace-space-analysis.ts
+inline src/preload/api-types.ts
+inline src/preload/index.ts
+inline src/relay/agent-hook-server.ts
+inline src/relay/dispatcher.ts
+inline src/relay/external-automations-handler.ts
+inline src/relay/fs-handler.ts
+inline src/relay/git-handler.test.ts
+inline src/relay/git-handler.ts
+inline src/relay/pty-handler.test.ts
+inline src/relay/pty-handler.ts
+inline src/relay/relay.ts
+inline src/relay/workspace-space-scan.ts
+inline src/renderer/src/App.tsx
+inline src/renderer/src/components/GitHubItemDialog.tsx
+inline src/renderer/src/components/GitLabItemDialog.tsx
+inline src/renderer/src/components/JiraIssueWorkspace.tsx
+inline src/renderer/src/components/LinearIssueWorkspace.tsx
+inline src/renderer/src/components/LinearItemDrawer.tsx
+inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
+inline src/renderer/src/components/PullRequestPage.tsx
+inline src/renderer/src/components/TaskPage.tsx
+inline src/renderer/src/components/Terminal.tsx
+inline src/renderer/src/components/UpdateCard.tsx
+inline src/renderer/src/components/WorktreeJumpPalette.tsx
+inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
+inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
+inline src/renderer/src/components/automations/AutomationsPage.tsx
+inline src/renderer/src/components/browser-pane/BrowserPane.tsx
+inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
+inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
+inline src/renderer/src/components/editor/EditorContent.tsx
+inline src/renderer/src/components/editor/IpynbViewer.tsx
+inline src/renderer/src/components/editor/MarkdownPreview.tsx
+inline src/renderer/src/components/editor/MonacoEditor.tsx
+inline src/renderer/src/components/editor/editor-autosave-controller.ts
+inline src/renderer/src/components/editor/ipynb-parse.ts
+inline src/renderer/src/components/editor/useEditorPanelContentState.ts
+inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
+inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
+inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
+inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
+inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
+inline src/renderer/src/components/github-project/ProjectCell.tsx
+inline src/renderer/src/components/github-project/ProjectPicker.tsx
+inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
+inline src/renderer/src/components/linear-project-view-surfaces.tsx
+inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
+inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
+inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
+inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
+inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
+inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
+inline src/renderer/src/components/right-sidebar/SourceControl.tsx
+inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+inline src/renderer/src/components/right-sidebar/index.tsx
+inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
+inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
+inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
+inline src/renderer/src/components/settings/AccountsPane.tsx
+inline src/renderer/src/components/settings/AgentsPane.tsx
+inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
+inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
+inline src/renderer/src/components/settings/Settings.tsx
+inline src/renderer/src/components/settings/SettingsFormControls.tsx
+inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
+inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
+inline src/renderer/src/components/sidebar/WorktreeCard.tsx
+inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
+inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
+inline src/renderer/src/components/sidebar/WorktreeList.tsx
+inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
+inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
+inline src/renderer/src/components/sidebar/worktree-list-groups.ts
+inline src/renderer/src/components/stats/usage-overview-model.ts
+inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
+inline src/renderer/src/components/status-bar/StatusBar.tsx
+inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
+inline src/renderer/src/components/tab-bar/TabBar.tsx
+inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
+inline src/renderer/src/components/tab-group/useTabDragSplit.ts
+inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
+inline src/renderer/src/components/task-page-cache-selectors.ts
+inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
+inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
+inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
+inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
+inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
+inline src/renderer/src/components/terminal-pane/pty-connection.ts
+inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
+inline src/renderer/src/components/terminal-pane/pty-transport.ts
+inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
+inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
+inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
+inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
+inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
+inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
+inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
+inline src/renderer/src/hooks/useComposerState.ts
+inline src/renderer/src/hooks/useEditorExternalWatch.ts
+inline src/renderer/src/hooks/useIpcEvents.test.ts
+inline src/renderer/src/hooks/useIpcEvents.ts
+inline src/renderer/src/hooks/useIssueMetadata.ts
+inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
+inline src/renderer/src/lib/active-agent-note-send.test.ts
+inline src/renderer/src/lib/file-type-icons.ts
+inline src/renderer/src/lib/pane-manager/pane-manager.ts
+inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
+inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
+inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
+inline src/renderer/src/lib/terminal-links.ts
+inline src/renderer/src/lib/worktree-activation.test.ts
+inline src/renderer/src/lib/worktree-activation.ts
+inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
+inline src/renderer/src/runtime/runtime-file-client.test.ts
+inline src/renderer/src/runtime/runtime-file-client.ts
+inline src/renderer/src/runtime/runtime-git-client.ts
+inline src/renderer/src/runtime/runtime-linear-client.ts
+inline src/renderer/src/runtime/sync-runtime-graph.test.ts
+inline src/renderer/src/runtime/sync-runtime-graph.ts
+inline src/renderer/src/runtime/web-runtime-session.test.ts
+inline src/renderer/src/runtime/web-runtime-session.ts
+inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
+inline src/renderer/src/runtime/web-session-tabs-sync.ts
+inline src/renderer/src/store/slices/agent-status.test.ts
+inline src/renderer/src/store/slices/agent-status.ts
+inline src/renderer/src/store/slices/browser.test.ts
+inline src/renderer/src/store/slices/browser.ts
+inline src/renderer/src/store/slices/diffComments.ts
+inline src/renderer/src/store/slices/editor.test.ts
+inline src/renderer/src/store/slices/editor.ts
+inline src/renderer/src/store/slices/github.test.ts
+inline src/renderer/src/store/slices/github.ts
+inline src/renderer/src/store/slices/hosted-review.ts
+inline src/renderer/src/store/slices/jira.ts
+inline src/renderer/src/store/slices/linear.test.ts
+inline src/renderer/src/store/slices/linear.ts
+inline src/renderer/src/store/slices/repos.ts
+inline src/renderer/src/store/slices/store-cascades.test.ts
+inline src/renderer/src/store/slices/store-session-cascades.test.ts
+inline src/renderer/src/store/slices/tabs.test.ts
+inline src/renderer/src/store/slices/tabs.ts
+inline src/renderer/src/store/slices/terminals.ts
+inline src/renderer/src/store/slices/ui.test.ts
+inline src/renderer/src/store/slices/ui.ts
+inline src/renderer/src/store/slices/workspace-cleanup.ts
+inline src/renderer/src/store/slices/worktrees.test.ts
+inline src/renderer/src/store/slices/worktrees.ts
+inline src/renderer/src/web/web-preload-api.test.ts
+inline src/renderer/src/web/web-preload-api.ts
+inline src/renderer/src/web/web-runtime-client.ts
+inline src/shared/agent-hook-listener.test.ts
+inline src/shared/agent-hook-listener.ts
+inline src/shared/automation-schedules.ts
+inline src/shared/commit-message-agent-spec.ts
+inline src/shared/constants.ts
+inline src/shared/github-project-types.ts
+inline src/shared/keybindings.test.ts
+inline src/shared/keybindings.ts
+inline src/shared/marine-creatures.ts
+inline src/shared/remote-runtime-client.ts
+inline src/shared/runtime-types.ts
+inline src/shared/source-control-ai.ts
+inline src/shared/telemetry-events.ts
+inline src/shared/text-search.ts
+inline src/shared/types.ts
+inline tests/e2e/helpers/terminal.ts
+inline tests/e2e/terminal-panes.spec.ts
+mobile-config app/h/*/files/*.tsx
+mobile-config app/h/*/index.tsx
+mobile-config app/h/*/session/*.tsx
+mobile-config app/h/*/source-control/*.tsx
+mobile-config app/h/*/tasks.tsx
+mobile-config app/index.tsx
+mobile-config app/pair-scan.tsx
+mobile-config app/terminal-settings.tsx
+mobile-config app/troubleshoot.tsx
+mobile-config scripts/mock-server.ts
+mobile-config scripts/repro-terminal-colors.ts
+mobile-config scripts/repro-worktree-startup-stream.ts
+mobile-config src/browser/MobileBrowserPane.tsx
+mobile-config src/components/CustomKeyModal.tsx
+mobile-config src/components/NewWorktreeModal.tsx
+mobile-config src/components/mobile-rich-markdown-editor-html.ts
+mobile-config src/terminal/terminal-accessory-keys.ts
+mobile-config src/terminal/terminal-webview-html.ts
+mobile-config src/transport/rpc-client.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index bdabd2a7b79..1975f41c7f9 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
- "updatedAt": "2026-07-03",
+ "updatedAt": "2026-07-07",
"policy": {
"maturityLevels": [
"experimental",
@@ -1273,7 +1273,7 @@
"macos"
],
"coveredProviders": [],
- "coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
+ "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
@@ -1281,14 +1281,20 @@
"https://github.com/stablyai/orca/pull/6999"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
- "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
+ "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
- "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
+ "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
- "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
+ "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
+ "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
+ "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
+ "tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"assertionRefs": [
{
@@ -1309,34 +1315,80 @@
"assertions": [
"paste/runtime forwarding avoids duplicate terminal payloads"
]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
+ "assertions": [
+ "empty Sogou-style compositionupdate keeps composition active",
+ "stale composition state expires so editing keys cannot stay suppressed indefinitely",
+ "post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
+ "assertions": [
+ "suppressed candidate keydown arms only the matching keypress/keyup release guard",
+ "held-key repeat keydowns stay guarded until their keyup, even past expiry",
+ "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
+ "assertions": [
+ "standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
+ "candidate Space/digit selectors are suppressed only while candidate guards are active"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
+ "assertions": [
+ "macOS standalone Process key behavior and composition-owned key suppression stay intact"
+ ]
+ },
+ {
+ "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
+ "assertions": [
+ "Sogou-style Space candidate selection submits only the committed Chinese character",
+ "Sogou-style digit candidate selection submits only the committed Chinese phrase",
+ "Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
+ ]
}
],
"evidenceRuns": [
{
- "date": "2026-07-03",
+ "date": "2026-07-07",
"runner": "local",
"platform": "macos",
- "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
- "durationSeconds": 0.9,
- "summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout."
+ "durationSeconds": 0.7,
+ "summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
+ },
+ {
+ "date": "2026-07-07",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
+ "result": "passed",
+ "durationSeconds": 72.0,
+ "summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
}
],
"runtimeBudget": {
- "p95Seconds": 30,
- "scope": "renderer unit plus platform soak"
+ "p95Seconds": 90,
+ "scope": "renderer unit plus focused Electron/CDP IME repro"
},
"flakeHistory": {
"status": "unknown",
- "evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion."
+ "evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
},
"redGreenEvidence": {
"status": "partial",
- "evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
+ "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
},
"performanceBudget": {
- "required": false,
- "evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work."
+ "required": true,
+ "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
@@ -1344,7 +1396,7 @@
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
],
"knownGaps": [
- "Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.",
+ "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
],
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
diff --git a/config/scripts/check-max-lines-ratchet.mjs b/config/scripts/check-max-lines-ratchet.mjs
new file mode 100644
index 00000000000..d4e8b9fa072
--- /dev/null
+++ b/config/scripts/check-max-lines-ratchet.mjs
@@ -0,0 +1,254 @@
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { pathToFileURL } from 'node:url'
+
+// Ratchet gate for the oxlint `max-lines` rule.
+//
+// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so
+// the only way a file grows past the budget is by adding an `eslint/oxlint-disable
+// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This
+// check freezes the set of files currently allowed to do that (the baseline) and
+// fails CI when a NEW bypass appears — the existing over-limit files are
+// grandfathered; new ones must split instead. The baseline may only shrink.
+
+const BASELINE_PATH = 'config/max-lines-baseline.txt'
+const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json'
+// These two files legitimately contain the directive text as data (regex, fixtures),
+// so scanning them would self-flag. The ratchet does not police itself.
+const SELF_FILES = new Set([
+ 'config/scripts/check-max-lines-ratchet.mjs',
+ 'config/scripts/check-max-lines-ratchet.test.mjs'
+])
+
+// Default max-lines budgets from .oxlintrc.json (counted lines).
+export function defaultLimitForPath(p) {
+ if (/\.(test|spec)\.(ts|tsx)$/.test(p)) {
+ return 800
+ }
+ if (p.endsWith('.tsx')) {
+ return 400
+ }
+ if (p.endsWith('.mjs')) {
+ return 600
+ }
+ return 300
+}
+
+// True if the source contains an eslint/oxlint disable directive listing `max-lines`
+// (block or line, bare or compound, with or without a `-- Why:` reason).
+export function hasMaxLinesDisable(sourceText) {
+ const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g
+ let m
+ while ((m = re.exec(sourceText)) !== null) {
+ let rules = m[1]
+ rules = rules.split('--')[0] // strip the reason
+ const close = rules.indexOf('*/')
+ if (close !== -1) {
+ rules = rules.slice(0, close) // strip block-comment tail
+ }
+ if (/\bmax-lines\b/.test(rules)) {
+ return true
+ }
+ }
+ return false
+}
+
+// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the
+// default for that glob (a lower `max` is stricter, not a bypass).
+export function collectMobileBumps(configText) {
+ const cfg = JSON.parse(configText)
+ const bumps = []
+ for (const override of cfg.overrides ?? []) {
+ const rule = override.rules?.['max-lines']
+ if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') {
+ continue
+ }
+ for (const glob of override.files ?? []) {
+ if (rule[1].max > defaultLimitForPath(glob)) {
+ bumps.push(`mobile-config ${glob}`)
+ }
+ }
+ }
+ return bumps
+}
+
+export function parseBaseline(text) {
+ return new Set(
+ text
+ .split('\n')
+ .map((l) => l.trim())
+ .filter((l) => l && !l.startsWith('#'))
+ )
+}
+
+export function diffBaseline(current, baseline) {
+ const cur = new Set(current)
+ const base = baseline instanceof Set ? baseline : new Set(baseline)
+ const added = [...cur].filter((e) => !base.has(e)).sort()
+ const stale = [...base].filter((e) => !cur.has(e)).sort()
+ return { added, stale }
+}
+
+// Collect every current suppression entry from the tracked tree.
+export function collectCurrentSuppressions(root = process.cwd()) {
+ const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], {
+ cwd: root,
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024
+ })
+ .split('\n')
+ .filter(Boolean)
+ .filter((f) => !SELF_FILES.has(f))
+
+ const entries = []
+ for (const rel of tracked) {
+ let src
+ try {
+ src = fs.readFileSync(path.join(root, rel), 'utf8')
+ } catch {
+ continue
+ }
+ if (hasMaxLinesDisable(src)) {
+ entries.push(`inline ${rel}`)
+ }
+ }
+
+ const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH)
+ if (fs.existsSync(mobileCfgPath)) {
+ entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8')))
+ }
+
+ return entries.sort()
+}
+
+function printAddedFailure(added) {
+ for (const entry of added) {
+ console.error(`::error::New max-lines bypass not allowed: ${entry}`)
+ }
+ console.error('')
+ console.error('╭────────────────────────────────────────────────────────────────────────────╮')
+ console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │')
+ console.error('╰────────────────────────────────────────────────────────────────────────────╯')
+ console.error('')
+ console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`)
+ console.error('')
+ for (const entry of added) {
+ const [kind, ...rest] = entry.split(' ')
+ const target = rest.join(' ')
+ const how =
+ kind === 'inline'
+ ? 'added an eslint/oxlint-disable max-lines comment'
+ : 'added a per-file max-lines bump in mobile/.oxlintrc.json'
+ console.error(` • ${target}\n ↳ ${how}`)
+ }
+ console.error('')
+ console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,')
+ console.error(
+ ' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.'
+ )
+ console.error('')
+ console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.')
+ console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".')
+ console.error('')
+ console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
+ console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
+ console.error('')
+}
+
+function printStaleFailure(stale) {
+ for (const entry of stale) {
+ console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`)
+ }
+ console.error('')
+ console.error('╭────────────────────────────────────────────────────────────────────────────╮')
+ console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │')
+ console.error('╰────────────────────────────────────────────────────────────────────────────╯')
+ console.error('')
+ console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`)
+ console.error(
+ ' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
+ )
+ console.error('')
+ for (const entry of stale) {
+ console.error(` • ${entry}`)
+ }
+ console.error('')
+ console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`)
+ console.error('')
+}
+
+export function main(root = process.cwd()) {
+ const baselineFile = path.join(root, BASELINE_PATH)
+ if (!fs.existsSync(baselineFile)) {
+ console.error(
+ `::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init`
+ )
+ return 1
+ }
+ const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
+ const current = collectCurrentSuppressions(root)
+ const { added, stale } = diffBaseline(current, baseline)
+
+ if (added.length > 0) {
+ printAddedFailure(added)
+ if (stale.length > 0) {
+ console.error(
+ ` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
+ )
+ printStaleFailure(stale)
+ }
+ return 1
+ }
+ if (stale.length > 0) {
+ printStaleFailure(stale)
+ return 1
+ }
+ console.log(
+ `max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.`
+ )
+ return 0
+}
+
+function writeBaseline(root, entries) {
+ const header = [
+ '# Files/globs currently allowed to exceed the oxlint `max-lines` budget.',
+ '# 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)',
+ ''
+ ].join('\n')
+ fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const root = process.cwd()
+ const arg = process.argv[2]
+ if (arg === '--init') {
+ // One-time bootstrap: capture the current suppression set as the baseline.
+ const entries = collectCurrentSuppressions(root)
+ writeBaseline(root, entries)
+ console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
+ process.exit(0)
+ }
+ if (arg === '--prune') {
+ // Remove baseline entries whose suppression is gone (shrink only; never adds).
+ const current = new Set(collectCurrentSuppressions(root))
+ const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
+ const kept = [...baseline].filter((e) => current.has(e)).sort()
+ const newlyAdded = [...current].filter((e) => !baseline.has(e))
+ writeBaseline(root, kept)
+ console.log(
+ `Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
+ )
+ if (newlyAdded.length > 0) {
+ console.error(
+ `::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.`
+ )
+ process.exit(1)
+ }
+ process.exit(0)
+ }
+ process.exit(main(root))
+}
diff --git a/config/scripts/check-max-lines-ratchet.test.mjs b/config/scripts/check-max-lines-ratchet.test.mjs
new file mode 100644
index 00000000000..f1eb4183454
--- /dev/null
+++ b/config/scripts/check-max-lines-ratchet.test.mjs
@@ -0,0 +1,115 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ collectMobileBumps,
+ defaultLimitForPath,
+ diffBaseline,
+ hasMaxLinesDisable,
+ parseBaseline
+} from './check-max-lines-ratchet.mjs'
+
+describe('hasMaxLinesDisable', () => {
+ it('detects a bare block disable', () => {
+ expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true)
+ })
+
+ it('detects the oxlint spelling', () => {
+ expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true)
+ })
+
+ it('detects a disable with a -- Why reason', () => {
+ expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true)
+ })
+
+ it('detects a multi-line block where the reason wraps', () => {
+ const src =
+ '/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n'
+ expect(hasMaxLinesDisable(src)).toBe(true)
+ })
+
+ it('detects max-lines inside a compound rule list', () => {
+ expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe(
+ true
+ )
+ expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true)
+ })
+
+ it('detects a line-scoped disable', () => {
+ expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true)
+ })
+
+ it('ignores a disable for an unrelated rule', () => {
+ expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false)
+ })
+
+ it('does not treat "max-lines" appearing only in the reason text as a suppression', () => {
+ // max-lines is after the `--`, so it is prose, not a suppressed rule.
+ expect(
+ hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n')
+ ).toBe(false)
+ })
+
+ it('returns false for ordinary source', () => {
+ expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false)
+ })
+})
+
+describe('defaultLimitForPath', () => {
+ it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => {
+ expect(defaultLimitForPath('a/b.test.ts')).toBe(800)
+ expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800)
+ expect(defaultLimitForPath('a/b.tsx')).toBe(400)
+ expect(defaultLimitForPath('a/b.mjs')).toBe(600)
+ expect(defaultLimitForPath('a/b.ts')).toBe(300)
+ })
+})
+
+describe('collectMobileBumps', () => {
+ it('captures only overrides whose max exceeds the default for the glob', () => {
+ const cfg = JSON.stringify({
+ overrides: [
+ { files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400)
+ {
+ files: ['src/terminal/TerminalWebView.tsx'],
+ rules: { 'max-lines': ['error', { max: 379 }] }
+ }, // stricter (<400), skip
+ { files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300)
+ ]
+ })
+ expect(collectMobileBumps(cfg)).toEqual([
+ 'mobile-config app/h/*/tasks.tsx',
+ 'mobile-config scripts/mock-server.ts'
+ ])
+ })
+
+ it('ignores overrides without a max-lines rule', () => {
+ const cfg = JSON.stringify({
+ overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }]
+ })
+ expect(collectMobileBumps(cfg)).toEqual([])
+ })
+})
+
+describe('parseBaseline', () => {
+ it('drops comments and blank lines', () => {
+ const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n')
+ expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx']))
+ })
+})
+
+describe('diffBaseline', () => {
+ it('reports added and stale entries', () => {
+ const { added, stale } = diffBaseline(
+ ['inline b.ts', 'inline c.ts'],
+ new Set(['inline a.ts', 'inline b.ts'])
+ )
+ expect(added).toEqual(['inline c.ts']) // new bypass
+ expect(stale).toEqual(['inline a.ts']) // suppression removed
+ })
+
+ it('is clean when current matches baseline', () => {
+ const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts']))
+ expect(added).toEqual([])
+ expect(stale).toEqual([])
+ })
+})
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index 3f124029e89..4afaca9293c 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
- downloads: 3.7m
+
+ downloads: 3.9m
@@ -15,7 +15,7 @@
downloads
downloads
- 3.7m
- 3.7m
+ 3.9m
+ 3.9m
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 6c3fc52354c..3ba8d351bf9 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
-- **WeChat:** Escaneie o QR code para entrar na comunidade.
-
-
-
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 4c538b08f22..d17ae0a8df8 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -235,6 +235,11 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
+- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。
+
+
+
+
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
diff --git a/docs/reference/ssh-typing-latency-under-relay-load.md b/docs/reference/ssh-typing-latency-under-relay-load.md
new file mode 100644
index 00000000000..f7ae8e0b3a7
--- /dev/null
+++ b/docs/reference/ssh-typing-latency-under-relay-load.md
@@ -0,0 +1,115 @@
+# SSH Typing Latency Under Relay Load
+
+Date: 2026-07-06
+
+## Symptom
+
+Typing in an SSH-host terminal can become extremely slow (hundreds of ms to
+seconds per echoed keystroke) while other SSH work is active — file previews,
+source-control refresh, search. A quiet SSH shell stays snappy, which made the
+reports look unreproducible.
+
+This is distinct from the local warm-switch lag documented in
+`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()`
+snapshot cost — already fixed; `TerminalHost.listSessions()` is now
+metadata-only via `getAppliedSize()`).
+
+## Root Cause
+
+The relay and the Electron client share ONE ordered SSH channel for all
+JSON-RPC traffic: PTY input/output, file streams, git responses, search, port
+scans. Two mechanisms turned bulk traffic into typing latency:
+
+1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream`
+ pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB
+ framed) into the relay's stdout as fast as local disk reads completed,
+ ignoring the `write() === false` backpressure signal. A 10MB preview
+ enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted
+ mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds
+ of echo delay per open file. The reproduced measurement: 4,195,592 bytes
+ (the entire remaining 3MB test file, framed) queued ahead of one echo
+ frame.
+
+2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()`
+ in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did
+ `Buffer.concat([buffered, chunk])` per data event, re-copying the whole
+ backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame,
+ ~80MB per 10MB file — on the Electron main thread, delaying `pty:write`
+ IPC dispatch and echo delivery to the renderer.
+
+## Fix
+
+- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`):
+ `fs.streamChunk` frames are serialized per client and each send waits for
+ the sink's `drain` when `write()` returns `false`. Interactive frames
+ (`pty.data`) still use plain `notify()` and are admitted immediately —
+ because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps
+ ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix
+ socket clients) surface `write()`'s boolean and a one-shot
+ `waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose)
+ flushes parked waiters so a pump can never hang on a dead pipe.
+
+- **Credit-based flow control** (`fs.streamAck`): the client requests
+ streams with `flowControl: 'ack'` and acks each processed chunk; the relay
+ caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This
+ bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP
+ buffers) and paces the relay's base64/JSON encode loop so incoming
+ keystroke frames get event-loop turns. A parked pump wakes on ack, cancel,
+ release, client detach, and a 1s staleness recheck.
+
+- **Cross-version compatibility**: old client + new relay → no
+ `flowControl` param → legacy unpaced pump (still drain-bounded, which is
+ transparent). New client + old relay → `fs.streamAck` is an unknown
+ notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is
+ intentionally unchanged — both sides bake it into chunk offset math, so
+ changing it would corrupt cross-version streams.
+
+- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list
+ buffering; each frame is assembled exactly once (one copy) instead of
+ re-concatenating the backlog per feed.
+
+## What Stays Fast / Unchanged
+
+- Relay PTY output batching (interactive echo fast path in
+ `src/relay/pty-handler.ts`) is untouched.
+- No `listSessions()` / provider inventory was added anywhere near typing,
+ focus, switch, resume, resize, or render paths.
+- Remote file streaming correctness: per-chunk length checks, chunk-count and
+ byte-count invariants, cancel paths, and the 12MB round-trip integration
+ test all pass unchanged.
+
+## Regression Coverage
+
+- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real
+ mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts
+ deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed
+ chunks when congested (pre-fix: whole file), ack window caps in-flight
+ chunks, legacy no-ack clients still get complete streams.
+- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window,
+ resumes per ack, and releases its file handle when cancelled while parked.
+- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating,
+ per-client targeting, dispose releases parked senders, interactive
+ notify() not gated behind a stalled bulk lane.
+- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary
+ straddling, oversized-frame resync across odd chunk sizes, and a
+ no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape.
+- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario:
+ types while two 8MB remote file-read loops and a git.status loop run;
+ budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix
+ HOL is only tens of ms — the in-process byte-bound test is the
+ authoritative red/green; the e2e guards the end-to-end wiring.)
+
+## Residual Gaps
+
+- **Large single-frame responses**: `git.*` responses (stdout capped at
+ `MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results
+ are one atomic frame on the wire; a huge diff can still delay an echo by
+ its own transfer time. Fixing this requires response chunking (a protocol
+ change); bounded by the 16MB `MAX_MESSAGE_SIZE`.
+- **Outbound direction**: a large `fs.writeFile` request frame (client →
+ relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`.
+ Same shape, opposite direction, rarer trigger (saving a large remote file
+ while typing).
+- `pty.ackData` flow control for PTY output remains unenforced
+ (`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are
+ already paced by the relay's 8ms batch/16KB slice scheduler.
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index ad94114bd66..f47ec591057 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -102,6 +102,14 @@ export default function RootLayout() {
return
}
handledNotificationIdsRef.current.add(notificationId)
+ // Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
+ // than letting it grow one id per notification tapped for the app's life.
+ if (handledNotificationIdsRef.current.size > 256) {
+ const oldest = handledNotificationIdsRef.current.values().next().value
+ if (oldest !== undefined) {
+ handledNotificationIdsRef.current.delete(oldest)
+ }
+ }
const path = await getNavigationPath(response.notification.request.content.data)
clearLastNotificationResponse()
diff --git a/mobile/scripts/mock-server-file-preview-data.ts b/mobile/scripts/mock-server-file-preview-data.ts
index a64c76eda3b..9bf43471df6 100644
--- a/mobile/scripts/mock-server-file-preview-data.ts
+++ b/mobile/scripts/mock-server-file-preview-data.ts
@@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [
{ relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' }
]
+function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> {
+ const prefix = relativePath ? `${relativePath}/` : ''
+ const children = new Map()
+ for (const file of MOCK_FILE_LIST) {
+ if (!file.relativePath.startsWith(prefix)) {
+ continue
+ }
+ const rest = file.relativePath.slice(prefix.length)
+ if (!rest) {
+ continue
+ }
+ const [name, ...descendants] = rest.split('/')
+ if (!name) {
+ continue
+ }
+ children.set(name, children.get(name) === true || descendants.length > 0)
+ }
+ return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1
+ }
+ return a.name.localeCompare(b.name)
+ })
+}
+
export function handleMockFilePreviewRequest(
request: RpcRequest,
respond: Respond,
@@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest(
)
return true
+ case 'files.readDir':
+ respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? ''))))
+ return true
+
case 'files.read': {
const relativePath = String(request.params?.relativePath ?? '')
const content = MOCK_FILE_CONTENT[relativePath]
diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts
new file mode 100644
index 00000000000..0d003e9f456
--- /dev/null
+++ b/mobile/src/files/MobileFileExplorerPanel.test.ts
@@ -0,0 +1,398 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
+import type { MobileDirEntry } from './file-tree'
+import type { RpcResponse } from '../transport/types'
+
+type MockClient = {
+ sendRequest: ReturnType
+}
+
+const mockTransport = vi.hoisted(() => ({
+ client: null as MockClient | null,
+ connectionState: 'connected',
+ forceReconnect: vi.fn()
+}))
+
+vi.mock('react-native', async () => {
+ const React = await import('react')
+ return {
+ ActivityIndicator: 'ActivityIndicator',
+ FlatList: (props: {
+ data: unknown[]
+ keyExtractor: (item: unknown, index: number) => string
+ renderItem: (info: {
+ item: unknown
+ index: number
+ separators: Record
+ }) => unknown
+ }) =>
+ React.createElement(
+ 'FlatList',
+ props,
+ props.data.map((item, index) =>
+ React.createElement(
+ 'FlatListItem',
+ { key: props.keyExtractor(item, index) },
+ props.renderItem({ item, index, separators: {} })
+ )
+ )
+ ),
+ Pressable: 'Pressable',
+ StyleSheet: {
+ create: (styles: unknown) => styles,
+ hairlineWidth: 1
+ },
+ Text: 'Text',
+ View: 'View'
+ }
+})
+
+vi.mock('react-native-safe-area-context', () => ({
+ SafeAreaView: 'SafeAreaView'
+}))
+
+vi.mock('expo-router', () => ({
+ useRouter: () => ({
+ back: vi.fn(),
+ push: vi.fn()
+ })
+}))
+
+vi.mock('lucide-react-native', () => ({
+ ChevronDown: 'ChevronDown',
+ ChevronLeft: 'ChevronLeft',
+ ChevronRight: 'ChevronRight',
+ File: 'File',
+ FileText: 'FileText',
+ Folder: 'Folder',
+ Image: 'Image',
+ X: 'X'
+}))
+
+vi.mock('../platform/haptics', () => ({
+ triggerSelection: vi.fn()
+}))
+
+vi.mock('../transport/client-context', () => ({
+ useForceReconnect: () => mockTransport.forceReconnect,
+ useHostClient: () => ({
+ client: mockTransport.client,
+ state: mockTransport.connectionState
+ })
+}))
+
+function suppressReactTestRendererDeprecationWarning(): () => void {
+ const originalConsoleError = console.error
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
+ const firstArg = args[0]
+ if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
+ return
+ }
+ originalConsoleError(...args)
+ })
+ return () => consoleErrorSpy.mockRestore()
+}
+
+function entry(name: string, isDirectory = false): MobileDirEntry {
+ return { name, isDirectory }
+}
+
+function ok(result: MobileDirEntry[]): RpcResponse {
+ return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } }
+}
+
+function createMockClient(entriesByPath: Record): MockClient {
+ return {
+ sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => {
+ return ok(entriesByPath[params.relativePath] ?? [])
+ })
+ }
+}
+
+async function renderExplorer(): Promise {
+ let renderer: ReactTestRenderer | null = null
+ const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
+ try {
+ await act(async () => {
+ renderer = create(
+ createElement(MobileFileExplorerPanel, {
+ hostId: 'host-a',
+ worktreeId: 'worktree-a',
+ name: 'Example Worktree',
+ embedded: true
+ })
+ )
+ })
+ } finally {
+ restoreConsoleError()
+ }
+ if (!renderer) {
+ throw new Error('MobileFileExplorerPanel did not render')
+ }
+ return renderer
+}
+
+async function updateExplorer(renderer: ReactTestRenderer): Promise {
+ await act(async () => {
+ renderer.update(
+ createElement(MobileFileExplorerPanel, {
+ hostId: 'host-a',
+ worktreeId: 'worktree-a',
+ name: 'Example Worktree',
+ embedded: true
+ })
+ )
+ })
+}
+
+async function pressByLabel(
+ renderer: ReactTestRenderer,
+ accessibilityLabel: string
+): Promise {
+ const pressable = renderer.root
+ .findAllByType('Pressable')
+ .find((node) => node.props.accessibilityLabel === accessibilityLabel)
+ if (!pressable) {
+ throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
+ }
+ await act(async () => {
+ pressable.props.onPress()
+ })
+}
+
+function renderedText(renderer: ReactTestRenderer): string {
+ return renderer.root
+ .findAllByType('Text')
+ .flatMap((node) => node.props.children)
+ .join(' ')
+}
+
+describe('MobileFileExplorerPanel', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ mockTransport.client = null
+ mockTransport.connectionState = 'connected'
+ mockTransport.forceReconnect = vi.fn()
+ })
+
+ it('loads directories lazily and reuses cached children when reopened', async () => {
+ const client = createMockClient({
+ '': [entry('src', true), entry('README.md')],
+ src: [entry('app.ts'), entry('components', true)]
+ })
+ mockTransport.client = client
+
+ const renderer = await renderExplorer()
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
+ worktree: 'id:worktree-a',
+ relativePath: ''
+ })
+
+ await pressByLabel(renderer, 'Open folder src')
+ await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2))
+ expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
+ worktree: 'id:worktree-a',
+ relativePath: 'src'
+ })
+ expect(renderedText(renderer)).toContain('app.ts')
+
+ await pressByLabel(renderer, 'Open folder src')
+ await pressByLabel(renderer, 'Open folder src')
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(2)
+ })
+
+ it('keeps the loaded tree visible during a transient disconnect', async () => {
+ const client = createMockClient({
+ '': [entry('src', true), entry('README.md')]
+ })
+ mockTransport.client = client
+
+ const renderer = await renderExplorer()
+ expect(renderedText(renderer)).toContain('src')
+
+ mockTransport.client = null
+ mockTransport.connectionState = 'disconnected'
+ await updateExplorer(renderer)
+
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ expect(renderedText(renderer)).toContain('src')
+ expect(renderedText(renderer)).toContain('README.md')
+ expect(renderedText(renderer)).not.toContain('Waiting for desktop...')
+ })
+
+ it('refreshes the root in the background after reconnect without blanking the tree', async () => {
+ const client = createMockClient({
+ '': [entry('src', true), entry('README.md')]
+ })
+ mockTransport.client = client
+
+ const renderer = await renderExplorer()
+ expect(renderedText(renderer)).toContain('README.md')
+
+ mockTransport.client = null
+ mockTransport.connectionState = 'disconnected'
+ await updateExplorer(renderer)
+
+ let resolveReload: ((response: RpcResponse) => void) | undefined
+ const reconnectedClient: MockClient = {
+ sendRequest: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveReload = resolve
+ })
+ )
+ }
+ mockTransport.client = reconnectedClient
+ mockTransport.connectionState = 'connected'
+ await updateExplorer(renderer)
+
+ expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1)
+ expect(renderedText(renderer)).toContain('src')
+ expect(renderedText(renderer)).toContain('README.md')
+
+ await act(async () => {
+ resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')]))
+ })
+ expect(renderedText(renderer)).toContain('CHANGELOG.md')
+ })
+
+ it('keeps the cached tree when a post-reconnect root refresh fails', async () => {
+ const client = createMockClient({
+ '': [entry('src', true), entry('README.md')]
+ })
+ mockTransport.client = client
+
+ const renderer = await renderExplorer()
+ expect(renderedText(renderer)).toContain('README.md')
+
+ mockTransport.client = null
+ mockTransport.connectionState = 'disconnected'
+ await updateExplorer(renderer)
+
+ const failingClient: MockClient = {
+ sendRequest: vi.fn(async () => {
+ throw new Error('refresh failed')
+ })
+ }
+ mockTransport.client = failingClient
+ mockTransport.connectionState = 'connected'
+ await updateExplorer(renderer)
+
+ expect(failingClient.sendRequest).toHaveBeenCalledTimes(1)
+ expect(renderedText(renderer)).toContain('src')
+ expect(renderedText(renderer)).toContain('README.md')
+ expect(renderedText(renderer)).not.toContain('refresh failed')
+ })
+
+ it('falls back to the capped files.list against desktops without files.readDir', async () => {
+ const legacyClient: MockClient = {
+ sendRequest: vi.fn(async (method: string): Promise => {
+ if (method === 'files.readDir') {
+ return {
+ id: 'response-id',
+ ok: false,
+ error: {
+ code: 'forbidden',
+ message: "Method 'files.readDir' is not available to mobile clients"
+ },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ }
+ return {
+ id: 'response-id',
+ ok: true,
+ result: {
+ files: [
+ { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
+ { relativePath: 'README.md', basename: 'README.md', kind: 'text' }
+ ],
+ totalCount: 2,
+ truncated: false
+ },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ })
+ }
+ mockTransport.client = legacyClient
+
+ const renderer = await renderExplorer()
+
+ expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', {
+ worktree: 'id:worktree-a'
+ })
+ expect(renderedText(renderer)).toContain('src')
+ expect(renderedText(renderer)).toContain('README.md')
+
+ await pressByLabel(renderer, 'Open folder src')
+ expect(renderedText(renderer)).toContain('app.ts')
+ // Every directory comes from the synthesized cache: one readDir attempt
+ // plus one files.list call total, no per-directory RPCs afterwards.
+ expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2)
+ })
+
+ it('surfaces the legacy cap note when the files.list fallback is truncated', async () => {
+ const legacyClient: MockClient = {
+ sendRequest: vi.fn(async (method: string): Promise => {
+ if (method === 'files.readDir') {
+ return {
+ id: 'response-id',
+ ok: false,
+ error: { code: 'method_not_found', message: 'Unknown method' },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ }
+ return {
+ id: 'response-id',
+ ok: true,
+ result: {
+ files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }],
+ totalCount: 6000,
+ truncated: true
+ },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ })
+ }
+ mockTransport.client = legacyClient
+
+ const renderer = await renderExplorer()
+
+ expect(renderedText(renderer)).toContain('README.md')
+ expect(renderedText(renderer)).toContain('Showing first 5000')
+ })
+
+ it('reports the files.list failure when the fallback itself fails', async () => {
+ const legacyClient: MockClient = {
+ sendRequest: vi.fn(async (method: string): Promise => {
+ if (method === 'files.readDir') {
+ return {
+ id: 'response-id',
+ ok: false,
+ error: {
+ code: 'forbidden',
+ message: "Method 'files.readDir' is not available to mobile clients"
+ },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ }
+ return {
+ id: 'response-id',
+ ok: false,
+ error: { code: 'internal', message: 'legacy list failed' },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ })
+ }
+ mockTransport.client = legacyClient
+
+ const renderer = await renderExplorer()
+
+ expect(renderedText(renderer)).toContain('legacy list failed')
+ expect(renderedText(renderer)).not.toContain('not available to mobile clients')
+ })
+})
diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx
index 8192eb90020..7e67bf81b5f 100644
--- a/mobile/src/files/MobileFileExplorerPanel.tsx
+++ b/mobile/src/files/MobileFileExplorerPanel.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
@@ -9,34 +9,33 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
-import {
- ChevronDown,
- ChevronLeft,
- ChevronRight,
- File,
- FileText,
- Folder,
- Image as ImageIcon,
- X
-} from 'lucide-react-native'
+import { ChevronLeft, X } from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { getWorktreeLabel } from '../session/worktree-label'
import {
- buildTree,
- flattenTree,
- isMarkdownPath,
- type FilesListResult,
- type MobileFileEntry,
- type TreeNode
+ flattenDirectoryCache,
+ getDirectoryCacheState,
+ type DirectoryCache,
+ type FileExplorerRow,
+ type MobileDirEntry
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
-import { triggerSelection } from '../platform/haptics'
-import { colors, spacing } from '../theme/mobile-theme'
-import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
+import { colors } from '../theme/mobile-theme'
import {
- canPreviewMobileFileRow,
- navigateToMobileFilePreview
-} from './mobile-file-preview-navigation'
+ beginDirectoryLoad,
+ createDirectoryLoadRevisions,
+ isCurrentDirectoryLoad,
+ resetDirectoryLoadRevisions,
+ type DirectoryLoadRevisions
+} from './directory-load-revisions'
+import {
+ directoryCacheFromFileList,
+ isMobileMethodUnavailableError,
+ type LegacyFilesListResult
+} from './file-list-fallback'
+import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
+import { MobileFileExplorerRow } from './mobile-file-explorer-row'
+import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -49,58 +48,217 @@ export function MobileFileExplorerPanel(props: {
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
- const [files, setFiles] = useState([])
+ const scopeRef = useRef('')
+ const scope = `${hostId}:${worktreeId}`
+ scopeRef.current = scope
+ const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions())
+ const pendingDirectoryRetriesRef = useRef>(new Set())
+ const directoryCacheRef = useRef({})
+ const [directoryCache, setDirectoryCache] = useState({})
const [expanded, setExpanded] = useState>(() => new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
- const [truncated, setTruncated] = useState(false)
+ const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
- const loadFiles = useCallback(async () => {
- if (!client || connState !== 'connected') {
- setLoading(false)
- setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...')
- return
- }
- setLoading(true)
- setError(null)
- try {
- const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` })
- if (!response.ok) {
- throw new Error(response.error?.message || 'Unable to load files')
+ const loadDirectory = useCallback(
+ async (relativePath: string) => {
+ const scope = scopeRef.current
+ const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath)
+ const rootLoad = relativePath === ''
+
+ if (!client || connState !== 'connected') {
+ const message =
+ connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
+ if (rootLoad) {
+ const hasLoadedRoot =
+ (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
+ setLoading(false)
+ // Why: transient reconnects should not blank an already browsable tree.
+ setError(hasLoadedRoot ? null : message)
+ } else {
+ setDirectoryCache((prev) => ({
+ ...prev,
+ [relativePath]: {
+ entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
+ error: message
+ }
+ }))
+ }
+ return
}
- const result = (response as RpcSuccess).result as FilesListResult
- setFiles(result.files)
- setTruncated(result.truncated)
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Unable to load files')
- } finally {
- setLoading(false)
- }
- }, [client, connState, worktreeId])
+
+ const hadLoadedRoot =
+ rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
+ if (rootLoad) {
+ // Why: a reconnect refresh must not blank an already browsable tree —
+ // the full-screen spinner unmounts the list and resets scroll.
+ if (!hadLoadedRoot) {
+ setLoading(true)
+ }
+ setError(null)
+ }
+ setDirectoryCache((prev) => ({
+ ...prev,
+ [relativePath]: {
+ entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
+ loading: true
+ }
+ }))
+
+ try {
+ const response = await client.sendRequest('files.readDir', {
+ worktree: `id:${worktreeId}`,
+ relativePath
+ })
+ if (!response.ok) {
+ // Why: desktops that predate the files.readDir mobile allowlist
+ // entry still serve the capped files.list; fall back so the Files
+ // tab keeps working until the desktop updates.
+ if (
+ rootLoad &&
+ isMobileMethodUnavailableError(response.error?.code, response.error?.message)
+ ) {
+ const legacy = await client.sendRequest('files.list', {
+ worktree: `id:${worktreeId}`
+ })
+ if (legacy.ok) {
+ if (
+ !isCurrentDirectoryLoad(
+ directoryLoadRevisionsRef.current,
+ scopeRef.current,
+ loadToken
+ )
+ ) {
+ return
+ }
+ const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
+ setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
+ // Why: the capped list silently omits files past the cap — keep
+ // the legacy explorer's "Showing first 5000" note.
+ setLegacyListTruncated(legacyResult.truncated)
+ return
+ }
+ throw new Error(
+ legacy.error?.message || response.error?.message || 'Unable to load files'
+ )
+ }
+ throw new Error(response.error?.message || 'Unable to load files')
+ }
+ if (
+ !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
+ ) {
+ return
+ }
+ const entries = (response as RpcSuccess).result as MobileDirEntry[]
+ if (rootLoad) {
+ setLegacyListTruncated(false)
+ }
+ setDirectoryCache((prev) => ({
+ ...prev,
+ [relativePath]: { entries }
+ }))
+ } catch (err) {
+ if (
+ !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
+ ) {
+ return
+ }
+ const message = err instanceof Error ? err.message : 'Unable to load files'
+ if (rootLoad) {
+ // Why: a failed background refresh keeps the cached tree browsable;
+ // only a cold load surfaces the full-screen error.
+ setError(hadLoadedRoot ? null : message)
+ } else {
+ setDirectoryCache((prev) => ({
+ ...prev,
+ [relativePath]: {
+ entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
+ error: message
+ }
+ }))
+ }
+ } finally {
+ if (
+ rootLoad &&
+ isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
+ ) {
+ setLoading(false)
+ }
+ }
+ },
+ [client, connState, worktreeId]
+ )
useEffect(() => {
- void loadFiles()
- }, [loadFiles])
+ scopeRef.current = scope
+ resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
+ pendingDirectoryRetriesRef.current.clear()
+ directoryCacheRef.current = {}
+ setDirectoryCache({})
+ setExpanded(new Set())
+ setLoading(true)
+ setError(null)
+ setLegacyListTruncated(false)
+ }, [scope])
- const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files])
+ useEffect(() => {
+ directoryCacheRef.current = directoryCache
+ }, [directoryCache])
- const toggleDirectory = useCallback((relativePath: string) => {
- triggerSelection()
- setExpanded((prev) => {
- const next = new Set(prev)
- if (next.has(relativePath)) {
- next.delete(relativePath)
- } else {
- next.add(relativePath)
+ useEffect(() => {
+ void loadDirectory('')
+ }, [hostId, loadDirectory])
+
+ useEffect(() => {
+ if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
+ return
+ }
+ const pending = [...pendingDirectoryRetriesRef.current]
+ pendingDirectoryRetriesRef.current.clear()
+ for (const relativePath of pending) {
+ void loadDirectory(relativePath)
+ }
+ }, [connState, loadDirectory])
+
+ const rows = useMemo(
+ () => flattenDirectoryCache(directoryCache, expanded),
+ [directoryCache, expanded]
+ )
+
+ const toggleDirectory = useCallback(
+ (relativePath: string) => {
+ setExpanded((prev) => {
+ const next = new Set(prev)
+ if (next.has(relativePath)) {
+ next.delete(relativePath)
+ } else {
+ next.add(relativePath)
+ }
+ return next
+ })
+ const state = getDirectoryCacheState(directoryCache, relativePath)
+ if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
+ void loadDirectory(relativePath)
}
- return next
- })
- }, [])
+ },
+ [directoryCache, expanded, loadDirectory]
+ )
+
+ const retryDirectory = useCallback(
+ (relativePath: string) => {
+ if (connState !== 'connected' && hostId) {
+ pendingDirectoryRetriesRef.current.add(relativePath)
+ void forceReconnect(hostId)
+ return
+ }
+ void loadDirectory(relativePath)
+ },
+ [connState, forceReconnect, hostId, loadDirectory]
+ )
const previewFile = useCallback(
(relativePath: string, displayName: string) => {
- triggerSelection()
navigateToMobileFilePreview(
router,
{
@@ -116,66 +274,15 @@ export function MobileFileExplorerPanel(props: {
[embedded, hostId, name, onRequestClose, router, worktreeId]
)
- const renderItem: ListRenderItem = ({ item }) => {
- const isDirectory = item.kind === 'directory'
- const isExpanded = expanded.has(item.relativePath)
- // Images render in the mobile viewer (via files.readPreview), so a binary
- // image is openable; only non-previewable binaries are unavailable.
- const previewable =
- item.kind !== 'directory' &&
- canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
- const isImage = item.kind === 'binary' && previewable
- const disabled = item.kind === 'binary' && !previewable
- const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
+ const renderItem: ListRenderItem = ({ item }) => {
return (
- [
- styles.row,
- { paddingLeft: spacing.lg + item.depth * 18 },
- pressed && !disabled && styles.rowPressed,
- disabled && styles.rowDisabled
- ]}
- disabled={disabled}
- onPress={() => {
- if (isDirectory) {
- toggleDirectory(item.relativePath)
- } else if (!disabled) {
- previewFile(item.relativePath, item.name)
- }
- }}
- accessibilityLabel={
- isDirectory
- ? `Open folder ${item.name}`
- : disabled
- ? `${item.name} unavailable on mobile`
- : `Preview file ${item.name}`
- }
- >
- {isDirectory ? (
- isExpanded ? (
-
- ) : (
-
- )
- ) : (
-
- )}
- {isDirectory ? (
-
- ) : markdown ? (
-
- ) : isImage ? (
-
- ) : (
-
- )}
-
-
- {item.name}
-
- {disabled ? Unavailable on mobile : null}
-
-
+
)
}
@@ -206,7 +313,7 @@ export function MobileFileExplorerPanel(props: {
{worktreeLabel}
- {truncated ? ' - Showing first 5000' : ''}
+ {legacyListTruncated ? ' - Showing first 5000' : ''}
@@ -220,12 +327,12 @@ export function MobileFileExplorerPanel(props: {
{error}
{/* Why: while disconnected, re-sending the request is useless — revive
- the parked transport instead (issue #5049); loadFiles re-runs via
+ the parked transport instead (issue #5049); loadDirectory re-runs via
its effect once the new client connects. */}
- connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
+ connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('')
}
>
Retry
diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts
new file mode 100644
index 00000000000..86ebfc3c0e8
--- /dev/null
+++ b/mobile/src/files/directory-load-revisions.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import {
+ beginDirectoryLoad,
+ createDirectoryLoadRevisions,
+ isCurrentDirectoryLoad,
+ resetDirectoryLoadRevisions,
+ type DirectoryLoadRevisions
+} from './directory-load-revisions'
+
+describe('directory-load-revisions', () => {
+ it('ignores stale duplicate loads for the same directory and scope', () => {
+ const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
+ const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
+ const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
+
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true)
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false)
+ })
+
+ it('ignores loads from an old host/worktree scope', () => {
+ const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
+ const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
+
+ expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false)
+ })
+
+ it('ignores loads from an old reset generation in the same scope', () => {
+ const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
+ const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
+
+ resetDirectoryLoadRevisions(revisions)
+ const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
+
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true)
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false)
+ })
+
+ it('tracks directory names that overlap object prototype keys', () => {
+ const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
+ const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__')
+
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
+ })
+})
diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts
new file mode 100644
index 00000000000..04153092bbe
--- /dev/null
+++ b/mobile/src/files/directory-load-revisions.ts
@@ -0,0 +1,42 @@
+export type DirectoryLoadRevisions = {
+ generation: number
+ revisionsByPath: Map
+}
+
+export type DirectoryLoadToken = {
+ generation: number
+ relativePath: string
+ revision: number
+ scope: string
+}
+
+export function createDirectoryLoadRevisions(): DirectoryLoadRevisions {
+ return { generation: 0, revisionsByPath: new Map() }
+}
+
+export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void {
+ revisions.generation += 1
+ revisions.revisionsByPath.clear()
+}
+
+export function beginDirectoryLoad(
+ revisions: DirectoryLoadRevisions,
+ scope: string,
+ relativePath: string
+): DirectoryLoadToken {
+ const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1
+ revisions.revisionsByPath.set(relativePath, revision)
+ return { generation: revisions.generation, relativePath, revision, scope }
+}
+
+export function isCurrentDirectoryLoad(
+ revisions: DirectoryLoadRevisions,
+ currentScope: string,
+ token: DirectoryLoadToken
+): boolean {
+ return (
+ currentScope === token.scope &&
+ revisions.generation === token.generation &&
+ revisions.revisionsByPath.get(token.relativePath) === token.revision
+ )
+}
diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts
new file mode 100644
index 00000000000..a69affc5fe8
--- /dev/null
+++ b/mobile/src/files/file-list-fallback.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
+import { getDirectoryCacheState } from './file-tree'
+
+describe('isMobileMethodUnavailableError', () => {
+ it('detects old-desktop allowlist and missing-method failures', () => {
+ expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
+ expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true)
+ expect(
+ isMobileMethodUnavailableError(
+ 'some_code',
+ "Method 'files.readDir' is not available to mobile clients"
+ )
+ ).toBe(true)
+ expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false)
+ expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false)
+ })
+})
+
+describe('directoryCacheFromFileList', () => {
+ it('synthesizes every ancestor directory from flat paths', () => {
+ const cache = directoryCacheFromFileList([
+ { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
+ { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
+ { relativePath: 'README.md', basename: 'README.md', kind: 'text' }
+ ])
+ expect(cache['']?.entries).toEqual(
+ expect.arrayContaining([
+ { name: 'src', isDirectory: true },
+ { name: 'README.md', isDirectory: false }
+ ])
+ )
+ expect(cache['src']?.entries).toEqual(
+ expect.arrayContaining([
+ { name: 'lib', isDirectory: true },
+ { name: 'app.ts', isDirectory: false }
+ ])
+ )
+ expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
+ })
+
+ it('keeps a name a directory when it appears as both file and dir prefix', () => {
+ const cache = directoryCacheFromFileList([
+ { relativePath: 'src', basename: 'src', kind: 'text' },
+ { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
+ ])
+ expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
+ })
+
+ it('returns an empty root for an empty list', () => {
+ const cache = directoryCacheFromFileList([])
+ expect(cache['']?.entries).toEqual([])
+ })
+
+ it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
+ const cache = directoryCacheFromFileList([
+ { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
+ ])
+ expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
+ expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
+ expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
+ { name: 'pollute.js', isDirectory: false }
+ ])
+ })
+})
diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts
new file mode 100644
index 00000000000..ef3041029af
--- /dev/null
+++ b/mobile/src/files/file-list-fallback.ts
@@ -0,0 +1,73 @@
+// Fallback for desktops that predate files.readDir in the mobile RPC
+// allowlist: synthesize the lazy directory cache from the flat, capped
+// files.list result so the Files tab stays browsable against old desktops.
+import type { DirectoryCache, MobileDirEntry } from './file-tree'
+
+export type LegacyMobileFileEntry = {
+ relativePath: string
+ basename: string
+ kind: 'text' | 'binary'
+}
+
+export type LegacyFilesListResult = {
+ files: LegacyMobileFileEntry[]
+ totalCount: number
+ truncated: boolean
+}
+
+// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
+// 'forbidden' = method exists but is not mobile-allowlisted on the old
+// desktop; 'method_not_found' = desktop predates the method entirely.
+export function isMobileMethodUnavailableError(
+ code: string | undefined,
+ message: string | undefined
+): boolean {
+ return (
+ code === 'forbidden' ||
+ code === 'method_not_found' ||
+ message?.includes('not available to mobile clients') === true
+ )
+}
+
+export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
+ const childrenByDir = new Map>()
+ const ensureDir = (path: string): Map => {
+ let children = childrenByDir.get(path)
+ if (!children) {
+ children = new Map()
+ childrenByDir.set(path, children)
+ }
+ return children
+ }
+ ensureDir('')
+ for (const file of files) {
+ const parts = file.relativePath.split('/').filter(Boolean)
+ let parentPath = ''
+ parts.forEach((name, index) => {
+ const isDirectory = index < parts.length - 1
+ const children = ensureDir(parentPath)
+ children.set(name, children.get(name) === true || isDirectory)
+ parentPath = parentPath ? `${parentPath}/${name}` : name
+ if (isDirectory) {
+ ensureDir(parentPath)
+ }
+ })
+ }
+ // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
+ // object's prototype instead of storing the directory; fromEntries always
+ // creates own keys.
+ return Object.fromEntries(
+ Array.from(childrenByDir, ([path, children]) => [
+ path,
+ {
+ entries: Array.from(
+ children,
+ ([name, isDirectory]): MobileDirEntry => ({
+ name,
+ isDirectory
+ })
+ )
+ }
+ ])
+ )
+}
diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts
index c4777ca2188..bdd9e9225cb 100644
--- a/mobile/src/files/file-tree.test.ts
+++ b/mobile/src/files/file-tree.test.ts
@@ -1,32 +1,109 @@
import { describe, expect, it } from 'vitest'
-import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
+import {
+ flattenDirectoryCache,
+ getMobileFileKind,
+ isMarkdownPath,
+ shouldIncludeMobileFileExplorerEntry,
+ type DirectoryCache,
+ type MobileDirEntry
+} from './file-tree'
-function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
- return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
+function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry {
+ return { name, isDirectory, isSymlink }
}
describe('file-tree', () => {
- it('nests files under their directories', () => {
- const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')])
- expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md'])
- expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe(
- 'src/lib/util.ts'
- )
+ it('flattens cached directories lazily with directories before files', () => {
+ const cache: DirectoryCache = {
+ '': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] },
+ src: { entries: [entry('app.ts'), entry('lib', true)] },
+ 'src/lib': { entries: [entry('util.ts')] }
+ }
+
+ const collapsed = flattenDirectoryCache(cache, new Set())
+ expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt'])
+
+ const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib']))
+ expect(expanded.map((row) => row.id)).toEqual([
+ 'dir:src',
+ 'dir:src/lib',
+ 'file:src/lib/util.ts',
+ 'file:src/app.ts',
+ 'file:readme.md',
+ 'file:zeta.txt'
+ ])
})
- it('flattens with directories before files and only expands open dirs', () => {
- const root = buildTree([entry('src/app.ts'), entry('zeta.txt')])
- const collapsed = flattenTree(root, new Set())
- expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt'])
+ it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
+ const cache: DirectoryCache = {
+ '': {
+ entries: [
+ entry('.git', true),
+ entry('.env'),
+ entry('node_modules', true),
+ entry('src', true)
+ ]
+ }
+ }
- const expanded = flattenTree(root, new Set(['src']))
- expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
+ expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
+ 'dir:src',
+ 'file:.env'
+ ])
+ expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true)
})
- it('preserves the binary kind on flattened rows', () => {
- const root = buildTree([entry('assets/logo.png', 'binary')])
- const rows = flattenTree(root, new Set(['assets']))
- expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary')
+ it('renders inline loading and error rows under expanded directories', () => {
+ const cache: DirectoryCache = {
+ '': { entries: [entry('loading-dir', true), entry('error-dir', true)] },
+ 'loading-dir': { entries: [], loading: true },
+ 'error-dir': { entries: [], error: 'permission denied' }
+ }
+
+ const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir']))
+ expect(rows.map((row) => row.id)).toEqual([
+ 'dir:error-dir',
+ 'error:error-dir',
+ 'dir:loading-dir',
+ 'loading:loading-dir'
+ ])
+ expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({
+ kind: 'error',
+ message: 'permission denied'
+ })
+ })
+
+ it('preserves symlink metadata for user-initiated activation', () => {
+ const cache: DirectoryCache = {
+ '': { entries: [entry('linked-docs', false, true)] }
+ }
+
+ expect(flattenDirectoryCache(cache, new Set())).toEqual([
+ {
+ id: 'file:linked-docs',
+ name: 'linked-docs',
+ relativePath: 'linked-docs',
+ depth: 0,
+ kind: 'text',
+ isSymlink: true
+ }
+ ])
+ })
+
+ it('does not treat inherited object keys as loaded directories', () => {
+ const cache: DirectoryCache = {
+ '': { entries: [entry('constructor', true), entry('__proto__', true)] },
+ ['__proto__']: { entries: [entry('file.txt')] }
+ }
+
+ expect(
+ flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id)
+ ).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor'])
+ })
+
+ it('classifies binary file paths for existing mobile preview behavior', () => {
+ expect(getMobileFileKind('assets/logo.png')).toBe('binary')
+ expect(getMobileFileKind('src/app.ts')).toBe('text')
})
it('detects markdown paths', () => {
diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts
index e453cc152e4..a9283cfa457 100644
--- a/mobile/src/files/file-tree.ts
+++ b/mobile/src/files/file-tree.ts
@@ -1,91 +1,152 @@
-// Pure tree model for the mobile file explorer: turns the flat files.list
-// result into a nested directory structure and flattens it into renderable
-// rows. Kept out of the screen component so the screen stays under its line cap.
+// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
+// browse semantics by flattening cached files.readDir results as folders open.
-export type MobileFileEntry = {
- relativePath: string
- basename: string
- kind: 'text' | 'binary'
+export type MobileDirEntry = {
+ name: string
+ isDirectory: boolean
+ isSymlink?: boolean
}
-export type FilesListResult = {
- files: MobileFileEntry[]
- totalCount: number
- truncated: boolean
-}
+export type MobileFileKind = 'text' | 'binary'
export type TreeNode = {
id: string
name: string
relativePath: string
depth: number
- kind: 'directory' | 'text' | 'binary'
+ kind: 'directory' | MobileFileKind
+ isSymlink?: boolean
}
-export type DirectoryNode = {
- name: string
+export type DirectoryState = {
+ entries: MobileDirEntry[]
+ loading?: boolean
+ error?: string
+}
+
+export type DirectoryCache = Record
+
+export type InlineStatusNode = {
+ id: string
relativePath: string
- directories: Map
- files: MobileFileEntry[]
+ depth: number
+ kind: 'loading' | 'error'
+ message?: string
}
-function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
- return { name, relativePath, directories: new Map(), files: [] }
-}
+export type FileExplorerRow = TreeNode | InlineStatusNode
-export function buildTree(files: MobileFileEntry[]): DirectoryNode {
- const root = createDirectoryNode('', '')
- for (const file of files) {
- const parts = file.relativePath.split('/').filter(Boolean)
- let current = root
- for (let index = 0; index < parts.length - 1; index += 1) {
- const name = parts[index]!
- const relativePath = parts.slice(0, index + 1).join('/')
- let child = current.directories.get(name)
- if (!child) {
- child = createDirectoryNode(name, relativePath)
- current.directories.set(name, child)
- }
- current = child
- }
- current.files.push(file)
- }
- return root
-}
+const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules'])
+const BINARY_EXTENSIONS = new Set([
+ '.avif',
+ '.bmp',
+ '.gif',
+ '.heic',
+ '.ico',
+ '.jpeg',
+ '.jpg',
+ '.mov',
+ '.mp3',
+ '.mp4',
+ '.pdf',
+ '.png',
+ '.webp',
+ '.zip'
+])
-export function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] {
- const rows: TreeNode[] = []
- const visit = (directory: DirectoryNode, depth: number): void => {
- const dirs = Array.from(directory.directories.values()).sort((a, b) =>
- a.name.localeCompare(b.name)
- )
- for (const child of dirs) {
- rows.push({
- id: `dir:${child.relativePath}`,
- name: child.name,
- relativePath: child.relativePath,
- depth,
- kind: 'directory'
- })
- if (expanded.has(child.relativePath)) {
- visit(child, depth + 1)
- }
- }
- const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
- for (const file of files) {
- rows.push({
- id: `file:${file.relativePath}`,
- name: file.basename,
- relativePath: file.relativePath,
- depth,
- kind: file.kind
- })
- }
- }
- visit(root, 0)
+export function flattenDirectoryCache(
+ cache: DirectoryCache,
+ expanded: ReadonlySet
+): FileExplorerRow[] {
+ const rows: FileExplorerRow[] = []
+ visitDirectory('', 0, cache, expanded, rows)
return rows
}
+function visitDirectory(
+ relativePath: string,
+ depth: number,
+ cache: DirectoryCache,
+ expanded: ReadonlySet,
+ rows: FileExplorerRow[]
+): void {
+ const state = getDirectoryCacheState(cache, relativePath)
+ const entries = state?.entries ?? []
+ const visibleEntries = entries
+ .filter(shouldIncludeMobileFileExplorerEntry)
+ .sort(compareDirectoryEntries)
+
+ for (const entry of visibleEntries) {
+ const childPath = joinRelativePath(relativePath, entry.name)
+ rows.push(toTreeNode(entry, childPath, depth))
+ if (entry.isDirectory && expanded.has(childPath)) {
+ const childState = getDirectoryCacheState(cache, childPath)
+ if (childState?.loading) {
+ rows.push({
+ id: `loading:${childPath}`,
+ relativePath: childPath,
+ depth: depth + 1,
+ kind: 'loading'
+ })
+ } else if (childState?.error) {
+ rows.push({
+ id: `error:${childPath}`,
+ relativePath: childPath,
+ depth: depth + 1,
+ kind: 'error',
+ message: childState.error
+ })
+ } else {
+ visitDirectory(childPath, depth + 1, cache, expanded, rows)
+ }
+ }
+ }
+}
+
+function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode {
+ return {
+ id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`,
+ name: entry.name,
+ relativePath,
+ depth,
+ kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath),
+ isSymlink: entry.isSymlink
+ }
+}
+
+function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1
+ }
+ return a.name.localeCompare(b.name)
+}
+
+export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
+ return !DESKTOP_EXCLUDED_NAMES.has(entry.name)
+}
+
+export function getDirectoryCacheState(
+ cache: DirectoryCache,
+ relativePath: string
+): DirectoryState | undefined {
+ // Why: repository paths are arbitrary object keys; inherited keys like
+ // "constructor" must not masquerade as loaded directory state.
+ return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
+}
+
+export function joinRelativePath(parentPath: string, name: string): string {
+ return parentPath ? `${parentPath}/${name}` : name
+}
+
+export function getMobileFileKind(relativePath: string): MobileFileKind {
+ const basename = relativePath.split('/').pop() ?? relativePath
+ const dotIndex = basename.lastIndexOf('.')
+ if (dotIndex <= 0) {
+ return 'text'
+ }
+ return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text'
+}
+
export function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/mobile-file-explorer-row.tsx
new file mode 100644
index 00000000000..36f74ad3b53
--- /dev/null
+++ b/mobile/src/files/mobile-file-explorer-row.tsx
@@ -0,0 +1,145 @@
+import { ActivityIndicator, Pressable, Text, View } from 'react-native'
+import {
+ ChevronDown,
+ ChevronRight,
+ File,
+ FileText,
+ Folder,
+ Image as ImageIcon
+} from 'lucide-react-native'
+import { triggerSelection } from '../platform/haptics'
+import { colors, spacing } from '../theme/mobile-theme'
+import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree'
+import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
+import { canPreviewMobileFileRow } from './mobile-file-preview-navigation'
+
+type Props = {
+ item: FileExplorerRow
+ expanded: ReadonlySet
+ onPreviewFile: (relativePath: string, displayName: string) => void
+ onRetryDirectory: (relativePath: string) => void
+ onToggleDirectory: (relativePath: string) => void
+}
+
+export function MobileFileExplorerRow(props: Props) {
+ const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props
+
+ if (item.kind === 'loading') {
+ return (
+
+
+
+ Loading...
+
+ )
+ }
+
+ if (item.kind === 'error') {
+ return (
+
+
+
+ {item.message || 'Unable to load folder'}
+
+ [styles.inlineRetryButton, pressed && styles.rowPressed]}
+ onPress={() => {
+ triggerSelection()
+ onRetryDirectory(item.relativePath)
+ }}
+ accessibilityLabel={`Retry loading ${item.relativePath}`}
+ >
+ Retry
+
+
+ )
+ }
+
+ if (isTreeNode(item)) {
+ return (
+
+ )
+ }
+
+ return null
+}
+
+function isTreeNode(item: FileExplorerRow): item is TreeNode {
+ return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary'
+}
+
+function TreeRow(props: {
+ item: TreeNode
+ expanded: ReadonlySet
+ onPreviewFile: (relativePath: string, displayName: string) => void
+ onToggleDirectory: (relativePath: string) => void
+}) {
+ const { item, expanded, onPreviewFile, onToggleDirectory } = props
+ const isDirectory = item.kind === 'directory'
+ const isExpanded = expanded.has(item.relativePath)
+ // Images render in the mobile viewer (via files.readPreview), so a binary
+ // image is openable; only non-previewable binaries are unavailable.
+ const previewable =
+ item.kind !== 'directory' &&
+ canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
+ const isImage = item.kind === 'binary' && previewable
+ const disabled = item.kind === 'binary' && !previewable
+ const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
+
+ return (
+ [
+ styles.row,
+ { paddingLeft: spacing.lg + item.depth * 18 },
+ pressed && !disabled && styles.rowPressed,
+ disabled && styles.rowDisabled
+ ]}
+ disabled={disabled}
+ onPress={() => {
+ triggerSelection()
+ if (isDirectory) {
+ onToggleDirectory(item.relativePath)
+ } else if (!disabled) {
+ onPreviewFile(item.relativePath, item.name)
+ }
+ }}
+ accessibilityLabel={
+ isDirectory
+ ? `Open folder ${item.name}`
+ : disabled
+ ? `${item.name} unavailable on mobile`
+ : `Preview file ${item.name}`
+ }
+ >
+ {isDirectory ? (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ {isDirectory ? (
+
+ ) : markdown ? (
+
+ ) : isImage ? (
+
+ ) : (
+
+ )}
+
+
+ {item.name}
+
+ {disabled ? Unavailable on mobile : null}
+
+
+ )
+}
diff --git a/mobile/src/files/mobile-file-explorer-styles.ts b/mobile/src/files/mobile-file-explorer-styles.ts
index 339dd5519de..5df9710be10 100644
--- a/mobile/src/files/mobile-file-explorer-styles.ts
+++ b/mobile/src/files/mobile-file-explorer-styles.ts
@@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({
color: colors.textMuted,
fontSize: 11
},
+ inlineStatusRow: {
+ minHeight: 36,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ paddingRight: spacing.md
+ },
+ inlineStatusText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize
+ },
+ inlineErrorText: {
+ flex: 1,
+ minWidth: 0,
+ color: colors.statusRed,
+ fontSize: typography.metaSize
+ },
+ inlineRetryButton: {
+ minHeight: 28,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: radii.button,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle,
+ paddingHorizontal: spacing.md
+ },
+ inlineRetryText: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
state: {
flex: 1,
alignItems: 'center',
diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts
index 46eab52ec92..7ae4315086b 100644
--- a/mobile/src/notifications/mobile-notifications.test.ts
+++ b/mobile/src/notifications/mobile-notifications.test.ts
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
-import { subscribeToDesktopNotifications } from './mobile-notifications'
+import {
+ setScheduledNotificationsMaxForTests,
+ subscribeToDesktopNotifications
+} from './mobile-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
@@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => {
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
+
+ // Why: notificationId is unique per completion, so the map grew unbounded when
+ // the desktop never sent a dismiss (the remote-mobile case). It is now capped.
+ it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
+ setScheduledNotificationsMaxForTests(1)
+ try {
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync)
+ .mockResolvedValueOnce('scheduled-old')
+ .mockResolvedValueOnce('scheduled-new')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn()
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-1')
+ onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' })
+ await flushAsync()
+ onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
+ await flushAsync()
+
+ // The older entry was evicted by the cap: dismissing it is a no-op...
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
+ await flushAsync()
+ expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
+
+ // ...while the most-recent entry is retained and still dismissable.
+ onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
+ await flushAsync()
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
+ } finally {
+ setScheduledNotificationsMaxForTests()
+ }
+ })
})
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index 83abea6fb8a..9ab936f75c6 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -31,10 +31,42 @@ type ScheduledNotificationState = {
const scheduledNotificationsByHostAndNotificationId = new Map()
+// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
+// so every agent-task-complete inserts a new, never-reused key. Entries are only
+// removed when the desktop sends a matching dismiss — which a remote mobile user
+// (not at the desktop) frequently never gets — so the map grew for the app's whole
+// life. Bound it; a settled entry only retains a small identifier used for later
+// programmatic dismissal, unnecessary for long-past completions.
+const MAX_SCHEDULED_NOTIFICATIONS = 256
+let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
+
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
+// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
+// Map iteration is insertion order, so the first match is the oldest.
+function boundScheduledNotifications(): void {
+ while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
+ let evicted = false
+ for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
+ if (!state.pending) {
+ scheduledNotificationsByHostAndNotificationId.delete(key)
+ evicted = true
+ break
+ }
+ }
+ if (!evicted) {
+ break
+ }
+ }
+}
+
+/** Test-only: override the cap (pass no arg to restore the default). */
+export function setScheduledNotificationsMaxForTests(max?: number): void {
+ maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
+}
+
export type NotificationPermissionState = {
granted: boolean
status: string
@@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string):
return
}
notificationState.identifier = scheduledIdentifier
+ boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
diff --git a/package.json b/package.json
index 27f7d72598a..524ccf7b2b5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.126-rc.0",
+ "version": "1.4.127-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
- "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
+ "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
@@ -19,6 +19,7 @@
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
+ "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts
index 46ff181a73d..91b0dc66051 100644
--- a/src/main/agent-hooks/server.test.ts
+++ b/src/main/agent-hooks/server.test.ts
@@ -13,7 +13,12 @@ import {
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { AgentHookServer, agentHookServer, _internals } from './server'
+import {
+ AgentHookServer,
+ agentHookServer,
+ CLOSED_AGENT_STATUS_TAB_IDS_MAX,
+ _internals
+} from './server'
import {
AGENT_STATUS_MAX_FIELD_LENGTH,
AGENT_STATUS_STALE_AFTER_MS,
@@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => {
expect(server.getStatusSnapshot()).toEqual([])
})
})
+
+describe('AgentHookServer closed-tab suppression bound', () => {
+ it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => {
+ const server = new AgentHookServer()
+ const internals = server as unknown as {
+ markTabClosedForAgentStatus: (tabId: string) => void
+ closedAgentStatusTabIds: Set
+ }
+
+ const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200
+ for (let i = 0; i < total; i += 1) {
+ internals.markTabClosedForAgentStatus(`closed-tab-${i}`)
+ }
+
+ // Set stays bounded; oldest ids are evicted, most-recent are retained.
+ expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX)
+ expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false)
+ expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true)
+ })
+})
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index d4cc443d8ab..167331765cf 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES)
// not resurrect on hydrate.
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
+// Why: closed-tab suppression only needs to cover recently closed tabs — a
+// status event for a long-closed tab cannot arrive once its process/hooks are
+// gone. Bound the set so it can't grow one entry per tab close for the whole
+// session (it is otherwise only cleared at app quit).
+export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
+
type LastStatusFile = {
version: number
entries: Record
@@ -596,7 +602,17 @@ export class AgentHookServer {
}
private markTabClosedForAgentStatus(tabId: string): void {
+ // Delete-then-add keeps recently closed tabs most-recent so eviction only
+ // sheds the oldest ids, which can no longer receive status events.
+ 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 shouldSuppressClosedTabStatus(paneKey: string): boolean {
diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts
index f232b0adf81..e8d43ae1f08 100644
--- a/src/main/ai-vault/remote-session-scanner-sources.ts
+++ b/src/main/ai-vault/remote-session-scanner-sources.ts
@@ -15,7 +15,7 @@ import {
parseHermesSessionContent
} from './session-scanner-secondary-parsers'
import type { FileWithMtime } from './session-scanner-types'
-import { normalizePiSessionsDir } from './session-scanner-values'
+import { normalizeAgentSessionsDir } from './session-scanner-values'
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
import type {
RemoteParserOptions,
@@ -83,6 +83,7 @@ export function remoteSessionSources(
parseDevinSessionContent
),
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
+ jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
jsonlSource(
'droid',
remoteHome,
@@ -203,6 +204,15 @@ function piParser(
return parseMessageGraphSessionContent('pi', file, content, platform, options)
}
+function ompParser(
+ file: FileWithMtime,
+ content: string,
+ platform: NodeJS.Platform,
+ options: RemoteParserOptions
+): Promise {
+ return parseMessageGraphSessionContent('omp', file, content, platform, options)
+}
+
function openClawParser(
file: FileWithMtime,
content: string,
@@ -217,5 +227,9 @@ function remotePathSegments(path: string): string[] {
}
function remotePiSessionsSegments(): string[] {
- return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
+ return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
+}
+
+function remoteOmpSessionsSegments(): string[] {
+ return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
}
diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts
index c31408a8496..8e44e30c60b 100644
--- a/src/main/ai-vault/session-scanner-accumulator.ts
+++ b/src/main/ai-vault/session-scanner-accumulator.ts
@@ -113,6 +113,7 @@ export function finalizeSession(
resumeCommand: buildAiVaultResumeCommand({
agent: accumulator.agent,
sessionId,
+ resumeFilePath: accumulator.filePath,
cwd: accumulator.cwd,
platform,
codexHome: options.codexHome
diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts
index 1114c1b8bb2..5a81ff260d0 100644
--- a/src/main/ai-vault/session-scanner-agent-parser.ts
+++ b/src/main/ai-vault/session-scanner-agent-parser.ts
@@ -64,6 +64,8 @@ export async function parseAgentSessionFile(
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
case 'pi':
return parseMessageGraphSessionFile('pi', candidate.file, platform)
+ case 'omp':
+ return parseMessageGraphSessionFile('omp', candidate.file, platform)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
case 'devin':
diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts
index 585e37b0203..8a6fb183f62 100644
--- a/src/main/ai-vault/session-scanner-codex-workers.test.ts
+++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts
@@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
+ ompSessionsDir: join(root, 'omp-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions'),
diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts
index 01d84ddfac0..04601eacde5 100644
--- a/src/main/ai-vault/session-scanner-graph-parsers.ts
+++ b/src/main/ai-vault/session-scanner-graph-parsers.ts
@@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str
return extractContentText(textParts)
}
+// Agents whose transcripts are append-only message-graph JSONL (session +
+// model_change + message records). OMP is a Pi fork and shares the format.
+export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp'
+
export async function parseMessageGraphSessionFile(
- agent: 'openclaw' | 'pi',
+ agent: MessageGraphAgent,
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise {
@@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile(
}
export async function parseMessageGraphSessionContent(
- agent: 'openclaw' | 'pi',
+ agent: MessageGraphAgent,
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
@@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
return
}
if (record.type === 'model_change') {
- accumulator.model = extractString(record.modelId) ?? accumulator.model
+ // Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress
+ // session shows its model before the first assistant reply lands.
+ accumulator.model =
+ extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model
return
}
if (record.type !== 'message') {
@@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
}
export function createMessageGraphSessionResumeState(
- agent: 'openclaw' | 'pi',
+ agent: MessageGraphAgent,
file: FileWithMtime
): ResumableSessionParseState {
return accumulatorFoldResumeState(
@@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState(
}
async function parseMessageGraphSessionLines(args: {
- agent: 'openclaw' | 'pi'
+ agent: MessageGraphAgent
file: FileWithMtime
lines: AsyncIterable | Iterable
platform: NodeJS.Platform
diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts
index 01219836a58..1945193e6c7 100644
--- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts
+++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts
@@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture {
}
}
+// OMP is a Pi fork sharing the message-graph format, but keys the model on
+// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp'
+// branch and that model-key difference. (The session `title` is included for
+// realism only — the parser derives the title from the first user message.)
+export function ompFixture(): IncrementalAgentFixture {
+ return {
+ agent: 'omp',
+ fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl',
+ seedLines: [
+ JSON.stringify({
+ type: 'session',
+ version: 3,
+ id: 'cccccccc-dddd-4eee-8fff-000000000000',
+ cwd: '/repo/app',
+ title: 'OMP session title',
+ timestamp: '2026-05-01T10:00:00.000Z'
+ }),
+ JSON.stringify({
+ type: 'model_change',
+ model: 'gpt-5.4-mini',
+ timestamp: '2026-05-01T10:00:01.000Z'
+ }),
+ JSON.stringify({
+ type: 'message',
+ message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] },
+ timestamp: '2026-05-01T10:00:05.000Z'
+ })
+ ],
+ appendLines: [
+ JSON.stringify({
+ type: 'message',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: 'omp incremental answer' }],
+ model: 'gpt-5.4-mini',
+ usage: { input: 40, output: 20, totalTokens: 60 }
+ },
+ timestamp: '2026-05-01T10:01:00.000Z'
+ })
+ ],
+ truncatedLines: [
+ JSON.stringify({
+ type: 'session',
+ id: 'cccccccc-dddd-4eee-8fff-000000000000',
+ timestamp: '2026-05-01T10:00:00.000Z'
+ })
+ ]
+ }
+}
+
export function geminiJsonlFixture(): IncrementalAgentFixture {
return {
agent: 'gemini',
@@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] {
droidFixture(),
openclawFixture(),
piFixture(),
+ ompFixture(),
geminiJsonlFixture()
]
}
diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts
index d0eaa649818..2fb3df21613 100644
--- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts
+++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts
@@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) {
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
- kimiSessionsDir: join(root, 'kimi-sessions')
+ kimiSessionsDir: join(root, 'kimi-sessions'),
+ ompSessionsDir: join(root, 'omp-sessions')
}
}
diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts
index dcc7fc649aa..20650f105a9 100644
--- a/src/main/ai-vault/session-scanner-parse-cache.ts
+++ b/src/main/ai-vault/session-scanner-parse-cache.ts
@@ -59,7 +59,8 @@ function resumableStateFactoryFor(
case 'droid':
return () => createDroidSessionResumeState(candidate.file)
case 'openclaw':
- case 'pi': {
+ case 'pi':
+ case 'omp': {
const agent = candidate.agent
return () => createMessageGraphSessionResumeState(agent, candidate.file)
}
@@ -67,7 +68,12 @@ function resumableStateFactoryFor(
return candidate.file.path.endsWith('.jsonl')
? () => createGeminiJsonlSessionResumeState(candidate.file)
: null
- default:
+ case 'devin':
+ case 'grok':
+ case 'hermes':
+ case 'kimi':
+ case 'opencode':
+ case 'rovo':
return null
}
}
@@ -103,7 +109,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void {
/**
* Parse a session file, reusing prior work where the file is provably
* unchanged (mtime+size) and, for append-only JSONL transcripts (Claude,
- * Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the
+ * Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the
* parse from the last consumed byte when the file only grew. This is what
* keeps the renderer's ~5s forced rescans from re-reading gigabytes of
* transcripts (STA-1278/STA-1417: main process pegging one core during
diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts
index 12acdeab188..8c03ec72e42 100644
--- a/src/main/ai-vault/session-scanner-source-discovery.ts
+++ b/src/main/ai-vault/session-scanner-source-discovery.ts
@@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
-import { normalizePiSessionsDir } from './session-scanner-values'
+import { normalizeAgentSessionsDir } from './session-scanner-values'
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex')
@@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join(
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw')
-const PI_SESSIONS_DIR = normalizePiSessionsDir(
- process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
+const PI_SESSIONS_DIR = normalizeAgentSessionsDir(
+ process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'),
+ '.pi'
+)
+const OMP_SESSIONS_DIR = normalizeAgentSessionsDir(
+ process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'),
+ '.omp'
)
// Why: Devin ATIF transcripts are stored under /transcripts.
const DEVIN_TRANSCRIPTS_DIR = join(
@@ -124,7 +129,8 @@ function standardDiscoveries(
...devinDiscoveries(options, wslHomeDirs, limit, issues),
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
...rovoDiscoveries(options, wslHomeDirs, limit, issues),
- ...piDiscoveries(options, wslHomeDirs, limit, issues)
+ ...piDiscoveries(options, wslHomeDirs, limit, issues),
+ ...ompDiscoveries(options, wslHomeDirs, limit, issues)
]
}
@@ -244,6 +250,21 @@ function piDiscoveries(
)
}
+function ompDiscoveries(
+ options: AiVaultScanOptions,
+ wslHomeDirs: readonly string[],
+ limit: number,
+ issues: AiVaultScanIssue[]
+): Promise[] {
+ return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [
+ '.omp',
+ 'agent',
+ 'sessions'
+ ]).map((rootDir) =>
+ discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] })
+ )
+}
+
function openClawDiscovery(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts
index b7c4dfd05cf..0a3b6c7c1a8 100644
--- a/src/main/ai-vault/session-scanner-test-fixtures.ts
+++ b/src/main/ai-vault/session-scanner-test-fixtures.ts
@@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) {
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
+ ompSessionsDir: join(root, 'omp-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts
index 7c6c38a0be3..5364e29e21e 100644
--- a/src/main/ai-vault/session-scanner-types.ts
+++ b/src/main/ai-vault/session-scanner-types.ts
@@ -25,6 +25,7 @@ export type AiVaultScanOptions = {
openclawStateDir?: string
openclawLegacyStateDir?: string
piSessionsDir?: string
+ ompSessionsDir?: string
droidSessionsDir?: string
droidProjectsDir?: string
kimiSessionsDir?: string
diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts
index 9c1de341d28..a9954ae8d26 100644
--- a/src/main/ai-vault/session-scanner-values.test.ts
+++ b/src/main/ai-vault/session-scanner-values.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
extractPreviewContentText,
+ normalizeAgentSessionsDir,
normalizePreviewText,
normalizeTitleText
} from './session-scanner-values'
@@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => {
expect(result).toBe(`${'a'.repeat(216)}...`)
})
+
+ it('expands Pi and OMP agent homes to their session directories', () => {
+ expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions')
+ expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions')
+ expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe(
+ '/agents/.pi/agent/sessions'
+ )
+
+ expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions')
+ expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe(
+ '/agents/.omp/agent/sessions'
+ )
+ expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe(
+ '/agents/.omp/agent/sessions'
+ )
+ })
})
diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts
index 9733175e360..39006bd68a4 100644
--- a/src/main/ai-vault/session-scanner-values.ts
+++ b/src/main/ai-vault/session-scanner-values.ts
@@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null {
return dirname(sessionRoot)
}
-export function normalizePiSessionsDir(rawValue: string): string {
+// Pi and OMP (a Pi fork) both store transcripts under
+// //agent/sessions; accept any prefix of that path.
+export function normalizeAgentSessionsDir(
+ rawValue: string,
+ agentHomeDirName: '.pi' | '.omp'
+): string {
const trimmed = rawValue.trim()
if (!trimmed) {
- return join(homedir(), '.pi', 'agent', 'sessions')
+ return join(homedir(), agentHomeDirName, 'agent', 'sessions')
}
const normalized = trimmed.replace(/[\\/]+$/, '')
const leaf = basename(normalized)
@@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string {
if (leaf === 'agent') {
return join(normalized, 'sessions')
}
- if (leaf === '.pi') {
+ if (leaf === agentHomeDirName) {
return join(normalized, 'agent', 'sessions')
}
return normalized
diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts
index 41b1b2b05ab..d4404341fc1 100644
--- a/src/main/ai-vault/session-scanner.test.ts
+++ b/src/main/ai-vault/session-scanner.test.ts
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
+import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
@@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => {
])
)
+ const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl')
+ await mkdir(roots.ompSessionsDir, { recursive: true })
+ await writeFile(
+ ompSessionFile,
+ jsonLines([
+ {
+ type: 'session',
+ version: 3,
+ id: 'omp-session',
+ title: 'OMP session title',
+ timestamp: '2026-05-01T10:08:30.000Z',
+ cwd: '/tmp/omp'
+ },
+ {
+ type: 'model_change',
+ model: 'gpt-5.4-mini',
+ timestamp: '2026-05-01T10:08:30.500Z'
+ },
+ {
+ type: 'message',
+ timestamp: '2026-05-01T10:08:31.000Z',
+ message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] }
+ },
+ {
+ type: 'message',
+ timestamp: '2026-05-01T10:08:32.000Z',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: 'OMP answer' }],
+ model: 'gpt-5.4-mini',
+ // totalTokens deliberately != input+output so the assertion proves
+ // the explicit-total field is read, not an input/output sum.
+ usage: { input: 10, output: 5, totalTokens: 160 }
+ }
+ }
+ ])
+ )
+
await mkdir(roots.devinTranscriptsDir, { recursive: true })
await writeFile(
join(roots.devinTranscriptsDir, 'devin-session.json'),
@@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => {
"cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'"
)
expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'")
+ // OMP resumes by absolute transcript path, not by internal session id.
+ expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`)
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
expect(commandByAgent.get('kimi')).toBe(
"cd '/tmp/kimi' && kimi --session 'session_kimi-session'"
)
+
+ const ompSession = result.sessions.find((session) => session.agent === 'omp')
+ expect(ompSession?.model).toBe('gpt-5.4-mini')
+ expect(ompSession?.totalTokens).toBe(160)
+ })
+
+ it('captures an in-progress OMP model from model_change before any assistant reply', async () => {
+ // OMP writes the model on `model_change.model` (not Pi's `modelId`). With no
+ // assistant message yet, the model must still come through — proving the
+ // model_change fallback rather than assistant-message capture.
+ const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-'))
+ tempRoots.push(root)
+ const roots = isolatedScanRoots(root)
+ await mkdir(roots.ompSessionsDir, { recursive: true })
+ await writeFile(
+ join(roots.ompSessionsDir, 'omp-in-progress.jsonl'),
+ jsonLines([
+ {
+ type: 'session',
+ id: 'omp-in-progress',
+ timestamp: '2026-05-01T10:00:00.000Z',
+ cwd: '/tmp/omp'
+ },
+ { type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' },
+ {
+ type: 'message',
+ timestamp: '2026-05-01T10:00:02.000Z',
+ message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] }
+ }
+ ])
+ )
+
+ const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 })
+ const session = result.sessions.find((s) => s.agent === 'omp')
+ expect(session?.model).toBe('omp-mc-only-model')
})
it('strips newline-heavy Grok user_query envelopes without regex matching', async () => {
@@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => {
expect(usedGrokWrapperMatch).toBe(false)
})
})
-
-describe('buildAiVaultResumeCommand', () => {
- it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => {
- expect(
- buildAiVaultResumeCommand({
- agent: 'codex',
- sessionId: 'session-1',
- cwd: 'C:\\Users\\Ada Lovelace\\repo',
- platform: 'win32'
- })
- ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""')
- })
-
- it('carries non-default Codex homes in copied resume commands', () => {
- expect(
- buildAiVaultResumeCommand({
- agent: 'codex',
- sessionId: 'session-1',
- cwd: '/repo/app',
- platform: 'darwin',
- codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home'
- })
- ).toBe(
- "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'"
- )
-
- expect(
- buildAiVaultResumeCommand({
- agent: 'codex',
- sessionId: 'session-1',
- cwd: 'C:\\Users\\Ada Lovelace\\repo',
- platform: 'win32',
- codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
- })
- ).toBe(
- 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""'
- )
- })
-})
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index 9a6b8566f57..7a1facf1420 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -533,6 +533,56 @@ describe('CodexRuntimeHomeService', () => {
}
})
+ it('bridges WSL history from a configured per-distro source-home override', async () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+ const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
+ vi.doMock('../codex/wsl-codex-session-bridge', () => ({
+ startWslCodexSessionBridgeInBackground
+ }))
+ const wslHome = join(testState.userDataDir, 'wsl-home')
+ vi.doMock('../wsl', () => ({
+ getDefaultWslDistro: () => 'Ubuntu',
+ getWslHome: () => wslHome
+ }))
+ const store = createStore(
+ createSettings({
+ activeCodexManagedAccountId: null,
+ activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } },
+ // Why: the override is a Linux path inside the distro, not /.codex.
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
+ })
+ )
+
+ try {
+ const { CodexRuntimeHomeService } = await import('./runtime-home-service')
+ const service = new CodexRuntimeHomeService(store as never)
+ const wslRuntimeHomePath = join(
+ wslHome,
+ '.local',
+ 'share',
+ 'orca',
+ 'codex-runtime-home',
+ 'home'
+ )
+
+ service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
+
+ expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
+ expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
+ distro: 'Ubuntu',
+ systemCodexHomePath: '/home/me/.config/codex',
+ managedCodexHomePath: wslRuntimeHomePath
+ })
+ } finally {
+ vi.doUnmock('../codex/wsl-codex-session-bridge')
+ vi.doUnmock('../wsl')
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ }
+ })
+
it('starts WSL session bridging for the distro used by the materialized runtime home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts
index 3d2b0670354..67120f15e53 100644
--- a/src/main/codex-accounts/runtime-home-service.ts
+++ b/src/main/codex-accounts/runtime-home-service.ts
@@ -40,6 +40,10 @@ import {
syncSystemCodexResourcesIntoManagedHome
} from '../codex/codex-home-paths'
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 {
prepareSystemConfigForFreshRuntimeMirror,
@@ -146,7 +150,10 @@ export class CodexRuntimeHomeService {
syncSystemConfigIntoManagedCodexHome()
// Why: historical Codex sessions can be large; bridge them after launch
// setup so starting a fresh Codex TUI never waits on a full tree walk.
- void startSystemCodexSessionBridgeInBackground()
+ void startSystemCodexSessionBridgeInBackground(
+ {},
+ resolveHostCodexSessionSourceHome(this.store.getSettings())
+ )
return this.getRuntimeHomePath()
}
@@ -162,10 +169,11 @@ export class CodexRuntimeHomeService {
if (!distro) {
return
}
- const systemCodexHomePath = this.getWslSystemCodexHomePath({
- runtime: 'wsl',
- wslDistro: distro
- })
+ // Why: history-only override lets custom-CODEX_HOME users bridge from their
+ // real home; falls back to /.codex, which auth/config still use.
+ const systemCodexHomePath =
+ resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
+ this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) {
return
}
diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts
index ae4e17be693..ed3c838ea7c 100644
--- a/src/main/codex/codex-session-bridge.test.ts
+++ b/src/main/codex/codex-session-bridge.test.ts
@@ -215,6 +215,51 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => {
).toBe(false)
})
+ it('bridges from a custom source home override instead of ~/.codex', () => {
+ // Why: users with a custom CODEX_HOME point history discovery at that
+ // folder; the default ~/.codex must be ignored when an override is given.
+ const customSourceHome = join(fakeHomeDir, 'custom-codex')
+ const customSessionPath = join(
+ customSourceHome,
+ 'sessions',
+ '2026',
+ '05',
+ '26',
+ 'rollout-custom.jsonl'
+ )
+ mkdirSync(dirname(customSessionPath), { recursive: true })
+ writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8')
+
+ // A session under the default ~/.codex should NOT be bridged when overridden.
+ const defaultSessionPath = join(
+ getSystemCodexHomePath(),
+ 'sessions',
+ '2026',
+ '05',
+ '26',
+ 'rollout-default.jsonl'
+ )
+ mkdirSync(dirname(defaultSessionPath), { recursive: true })
+ writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8')
+
+ syncSystemCodexSessionsIntoManagedHome(customSourceHome)
+
+ const bridgedCustomPath = join(
+ getRuntimeCodexHomePath(),
+ 'sessions',
+ '2026',
+ '05',
+ '26',
+ 'rollout-custom.jsonl'
+ )
+ expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n')
+ expect(
+ existsSync(
+ join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl')
+ )
+ ).toBe(false)
+ })
+
it('falls back to symlinks when hardlinks are unavailable', () => {
fsMockState.failLink = true
const systemSessionPath = join(
diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts
index a574228953e..e546ce9d8f8 100644
--- a/src/main/codex/codex-session-bridge.ts
+++ b/src/main/codex/codex-session-bridge.ts
@@ -42,9 +42,12 @@ let backgroundSessionBridgeTask: Promise | null = null
/**
* Synchronously mirrors system session files into the managed runtime home.
+ *
+ * `sourceCodexHomePath` overrides the default ~/.codex history source for users
+ * who run Codex with a custom CODEX_HOME; it only affects history discovery.
*/
-export function syncSystemCodexSessionsIntoManagedHome(): void {
- const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions')
+export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void {
+ const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
if (!existsSync(systemSessionsRoot)) {
return
}
@@ -62,12 +65,13 @@ export function syncSystemCodexSessionsIntoManagedHome(): void {
* background bridging without starting duplicate directory walks.
*/
export function startSystemCodexSessionBridgeInBackground(
- options: CodexSessionBridgeIncrementalOptions = {}
+ options: CodexSessionBridgeIncrementalOptions = {},
+ sourceCodexHomePath?: string
): Promise {
if (backgroundSessionBridgeTask) {
return backgroundSessionBridgeTask
}
- const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options)
+ const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath)
.catch((error: unknown) => {
console.warn('[codex-session-bridge] Background session bridge failed:', error)
})
@@ -88,9 +92,10 @@ export function startSystemCodexSessionBridgeInBackground(
* bridge operation equivalent to the synchronous path.
*/
export async function syncSystemCodexSessionsIntoManagedHomeIncrementally(
- options: CodexSessionBridgeIncrementalOptions = {}
+ options: CodexSessionBridgeIncrementalOptions = {},
+ sourceCodexHomePath?: string
): Promise {
- const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions')
+ const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
if (!existsSync(systemSessionsRoot)) {
return { scannedFiles: 0, linkedFiles: 0 }
}
diff --git a/src/main/codex/codex-session-source-home.test.ts b/src/main/codex/codex-session-source-home.test.ts
new file mode 100644
index 00000000000..fa29650c495
--- /dev/null
+++ b/src/main/codex/codex-session-source-home.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import {
+ resolveHostCodexSessionSourceHome,
+ resolveWslCodexSessionSourceHome
+} from './codex-session-source-home'
+
+describe('resolveHostCodexSessionSourceHome', () => {
+ it('returns undefined when no override is configured', () => {
+ expect(resolveHostCodexSessionSourceHome({})).toBeUndefined()
+ expect(resolveHostCodexSessionSourceHome({ codexSessionSourceHome: {} })).toBeUndefined()
+ })
+
+ it('returns undefined for blank/whitespace overrides so the default is kept', () => {
+ expect(
+ resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' ' } })
+ ).toBeUndefined()
+ })
+
+ it('returns the trimmed host override path', () => {
+ expect(
+ resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' /custom/codex ' } })
+ ).toBe('/custom/codex')
+ })
+})
+
+describe('resolveWslCodexSessionSourceHome', () => {
+ it('returns undefined when no per-distro override exists', () => {
+ expect(resolveWslCodexSessionSourceHome({}, 'Ubuntu')).toBeUndefined()
+ expect(
+ resolveWslCodexSessionSourceHome(
+ { codexSessionSourceHome: { wsl: { Debian: '/home/me/.codex' } } },
+ 'Ubuntu'
+ )
+ ).toBeUndefined()
+ })
+
+ it('resolves a per-distro override for the matching distro', () => {
+ expect(
+ resolveWslCodexSessionSourceHome(
+ { codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } },
+ 'Ubuntu'
+ )
+ ).toBe('/home/me/.config/codex')
+ })
+
+ it('matches distro names case-insensitively, mirroring WSL', () => {
+ expect(
+ resolveWslCodexSessionSourceHome(
+ { codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } },
+ 'ubuntu'
+ )
+ ).toBe('/home/me/.config/codex')
+ })
+
+ it('ignores blank per-distro overrides so the default is kept', () => {
+ expect(
+ resolveWslCodexSessionSourceHome(
+ { codexSessionSourceHome: { wsl: { Ubuntu: ' ' } } },
+ 'Ubuntu'
+ )
+ ).toBeUndefined()
+ })
+
+ it('does not leak the host override into WSL resolution', () => {
+ expect(
+ resolveWslCodexSessionSourceHome(
+ { codexSessionSourceHome: { host: '/custom/codex' } },
+ 'Ubuntu'
+ )
+ ).toBeUndefined()
+ })
+})
diff --git a/src/main/codex/codex-session-source-home.ts b/src/main/codex/codex-session-source-home.ts
new file mode 100644
index 00000000000..0060051e924
--- /dev/null
+++ b/src/main/codex/codex-session-source-home.ts
@@ -0,0 +1,43 @@
+import type { GlobalSettings } from '../../shared/types'
+
+/**
+ * Resolves the user-configured Codex *session history* source home, if any.
+ *
+ * Why: Orca relocates CODEX_HOME to a managed home, then bridges history from
+ * the user's real Codex home so /resume finds it. That source defaults to
+ * ~/.codex, but users who run Codex with a custom CODEX_HOME need to point
+ * history discovery at that folder. This override affects history only; auth,
+ * config, and hooks continue to read from ~/.codex.
+ */
+
+/** Host override; returns undefined to keep the default ~/.codex source. */
+export function resolveHostCodexSessionSourceHome(
+ settings: Pick
+): string | undefined {
+ return normalizeSourceHome(settings.codexSessionSourceHome?.host)
+}
+
+/** Per-distro WSL override; returns undefined to keep the default /.codex source. */
+export function resolveWslCodexSessionSourceHome(
+ settings: Pick,
+ distro: string
+): string | undefined {
+ const perDistro = settings.codexSessionSourceHome?.wsl
+ if (!perDistro) {
+ return undefined
+ }
+ // Why: distro keys are matched case-insensitively so "Ubuntu" and "ubuntu"
+ // resolve the same override, mirroring how WSL treats distro names.
+ const normalizedDistro = distro.trim().toLowerCase()
+ for (const [key, value] of Object.entries(perDistro)) {
+ if (key.trim().toLowerCase() === normalizedDistro) {
+ return normalizeSourceHome(value)
+ }
+ }
+ return undefined
+}
+
+function normalizeSourceHome(value: string | undefined): string | undefined {
+ const trimmed = value?.trim()
+ return trimmed ? trimmed : undefined
+}
diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts
index 2138ba0aad0..5363cb56c60 100644
--- a/src/main/github/client.test.ts
+++ b/src/main/github/client.test.ts
@@ -122,7 +122,7 @@ import {
_resetMergeQueueCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
} from './client'
-import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary'
+import { __resetPRConflictSummaryCachesForTests } from './conflict-summary'
import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership'
describe('checkOrcaStarred', () => {
@@ -194,7 +194,7 @@ describe('getPRForBranch', () => {
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
- __resetPRConflictSummaryGitCapabilityCacheForTests()
+ __resetPRConflictSummaryCachesForTests()
resetMergedPRCommitMembershipCacheForTest()
})
@@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => {
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
+ // Why a second head OID: identical inputs now hit the summary result
+ // cache outright; a pushed head re-derives and must still skip the
+ // unsupported --merge-base retry via the capability cache.
+ const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } }
+ const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' }
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
- .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
- .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
+ .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) })
+ .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) })
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
@@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => {
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
- .mockResolvedValueOnce({ stdout: '' })
- .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
@@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
+ __resetPRConflictSummaryCachesForTests()
})
it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => {
diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts
new file mode 100644
index 00000000000..99a2715ce6d
--- /dev/null
+++ b/src/main/github/conflict-summary-cache.ts
@@ -0,0 +1,150 @@
+import type { PRConflictSummary } from '../../shared/types'
+
+// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR
+// (10s mergeability-pending, 2.5s manual-pending) previously each ran a
+// network fetch; one fetch per base branch per minute matches the 60s minimum
+// background refresh cadence, so the tracked base tip is never staler than the
+// PR data around it. Manual refresh intentionally shares the window: GitHub's
+// own mergeability recompute is async too, and the card self-corrects within
+// a minute.
+export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000
+
+// Why bounded: the main process is long-lived; cap the maps so repos and PRs
+// that stop refreshing can't accumulate entries forever.
+const BASE_OID_CACHE_MAX = 64
+const SUMMARY_CACHE_MAX = 128
+
+export type FreshBaseTipResolution =
+ | { kind: 'resolved'; oid: string }
+ | { kind: 'fallback-unresolved' }
+
+type CachedBaseTipResolution = {
+ oid: string | null
+ resolvedAt: number
+}
+
+type CachedSummary = {
+ value: PRConflictSummary | undefined
+ // Why: a successful summary is a pure function of two immutable commit OIDs
+ // and never goes stale; a failed derivation depends on which objects exist
+ // locally, which the next fetch window can change, so it carries an expiry.
+ staleAt: number | null
+}
+
+const baseOidCache = new Map()
+const summaryCache = new Map()
+const inFlightBaseOidResolves = new Map>()
+const inFlightSummaryDerivations = new Map>()
+
+// Why: WSL distros have their own git binary, filesystem view, and remote
+// access, so cached state must never leak across the host/distro boundary.
+export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string {
+ return wslDistro ? `wsl:${wslDistro}` : 'local:host'
+}
+
+// Why JSON: repo paths and git ref names may contain any printable joiner
+// character (git allows `|` in branch names), so a delimiter-joined key could
+// alias distinct identities onto one cache entry.
+export function buildConflictSummaryCacheKey(...parts: string[]): string {
+ return JSON.stringify(parts)
+}
+
+export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null {
+ const entry = baseOidCache.get(baseKey)
+ if (!entry) {
+ return null
+ }
+ if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) {
+ baseOidCache.delete(baseKey)
+ return null
+ }
+ return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' }
+}
+
+export function storeResolvedBaseTip(baseKey: string, oid: string): void {
+ setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX)
+}
+
+export function rememberUnresolvedBaseTip(baseKey: string): void {
+ setBoundedMapEntry(
+ baseOidCache,
+ baseKey,
+ { oid: null, resolvedAt: Date.now() },
+ BASE_OID_CACHE_MAX
+ )
+}
+
+export function readCachedSummary(summaryKey: string): CachedSummary | null {
+ const entry = summaryCache.get(summaryKey)
+ if (!entry) {
+ return null
+ }
+ if (entry.staleAt !== null && Date.now() >= entry.staleAt) {
+ summaryCache.delete(summaryKey)
+ return null
+ }
+ return entry
+}
+
+export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void {
+ setBoundedMapEntry(
+ summaryCache,
+ summaryKey,
+ {
+ value,
+ staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null
+ },
+ SUMMARY_CACHE_MAX
+ )
+}
+
+export function dedupeBaseOidResolve(
+ key: string,
+ factory: () => Promise
+): Promise {
+ return dedupeInFlight(inFlightBaseOidResolves, key, factory)
+}
+
+export function dedupeSummaryDerivation(
+ key: string,
+ factory: () => Promise
+): Promise {
+ return dedupeInFlight(inFlightSummaryDerivations, key, factory)
+}
+
+function dedupeInFlight(
+ map: Map>,
+ key: string,
+ factory: () => Promise
+): Promise {
+ const existing = map.get(key)
+ if (existing) {
+ return existing
+ }
+ const promise = factory().finally(() => {
+ map.delete(key)
+ })
+ map.set(key, promise)
+ return promise
+}
+
+function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void {
+ if (map.has(key)) {
+ map.delete(key)
+ }
+ map.set(key, value)
+ while (map.size > maxEntries) {
+ const oldest = map.keys().next()
+ if (oldest.done) {
+ return
+ }
+ map.delete(oldest.value)
+ }
+}
+
+export function __resetPRConflictSummaryDerivationCachesForTests(): void {
+ baseOidCache.clear()
+ summaryCache.clear()
+ inFlightBaseOidResolves.clear()
+ inFlightSummaryDerivations.clear()
+}
diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts
new file mode 100644
index 00000000000..a9b5e9aee9a
--- /dev/null
+++ b/src/main/github/conflict-summary.test.ts
@@ -0,0 +1,331 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Why these tests exist: the conflict-summary derivation used to re-run a
+// network `git fetch` plus a four-subprocess chain on every PR refresh tick
+// (measured ~490 full derivations in 2.1h on one machine). They pin the
+// regression contract by counting subprocess spawns: unchanged inputs must
+// cost zero subprocesses, and the base fetch must run at most once per
+// throttle window.
+
+const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
+
+vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
+
+import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache'
+import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary'
+
+type GitResult = { stdout: string }
+type GitHandler = (argv: string[]) => Promise
+
+const defaultHandlers: Record = {
+ fetch: async () => ({ stdout: '' }),
+ 'rev-parse': async () => ({ stdout: 'base-tip-1\n' }),
+ 'merge-base': async () => ({ stdout: 'merge-base-1\n' }),
+ 'rev-list': async () => ({ stdout: '3\n' }),
+ 'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' })
+}
+
+function mockGitDispatch(overrides: Record = {}): void {
+ gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
+ const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]]
+ if (!handler) {
+ return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`))
+ }
+ return handler(argv)
+ })
+}
+
+function spawnCount(command?: string): number {
+ const calls = gitExecFileAsyncMock.mock.calls
+ if (!command) {
+ return calls.length
+ }
+ return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length
+}
+
+const expectedSummary = {
+ baseRef: 'main',
+ baseCommit: 'base-ti',
+ commitsBehind: 3,
+ files: ['src/conflict.ts']
+}
+
+function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) {
+ return getPRConflictSummary(
+ '/repo-root',
+ 'main',
+ 'github-base-oid',
+ headRefOid,
+ wslDistro ? { wslDistro } : {}
+ )
+}
+
+describe('getPRConflictSummary caching', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_750_000_000_000)
+ gitExecFileAsyncMock.mockReset()
+ __resetPRConflictSummaryCachesForTests()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => {
+ mockGitDispatch()
+
+ const first = await deriveSummary()
+ expect(first).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(1)
+ expect(spawnCount()).toBe(5)
+
+ const second = await deriveSummary()
+ const third = await deriveSummary()
+ expect(second).toEqual(expectedSummary)
+ expect(third).toEqual(expectedSummary)
+ expect(spawnCount()).toBe(5)
+ })
+
+ it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => {
+ mockGitDispatch()
+
+ await deriveSummary('head-oid-1')
+ const afterPush = await deriveSummary('head-oid-2')
+
+ expect(afterPush).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(1)
+ expect(spawnCount('rev-parse')).toBe(1)
+ expect(spawnCount('merge-base')).toBe(2)
+ expect(spawnCount('rev-list')).toBe(2)
+ expect(spawnCount('merge-tree')).toBe(2)
+ })
+
+ it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => {
+ mockGitDispatch()
+
+ await deriveSummary()
+ vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
+ const afterExpiry = await deriveSummary()
+
+ expect(afterExpiry).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(2)
+ expect(spawnCount('rev-parse')).toBe(2)
+ // Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits.
+ expect(spawnCount('merge-base')).toBe(1)
+ expect(spawnCount('rev-list')).toBe(1)
+ expect(spawnCount('merge-tree')).toBe(1)
+ })
+
+ it('re-derives after the throttle window when the base tip moved', async () => {
+ mockGitDispatch()
+ await deriveSummary()
+
+ vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
+ mockGitDispatch({
+ 'rev-parse': async () => ({ stdout: 'base-tip-2\n' }),
+ 'rev-list': async () => ({ stdout: '5\n' })
+ })
+ const afterBaseMove = await deriveSummary()
+
+ expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 })
+ expect(spawnCount('fetch')).toBe(2)
+ expect(spawnCount('merge-base')).toBe(2)
+ expect(spawnCount('rev-list')).toBe(2)
+ expect(spawnCount('merge-tree')).toBe(2)
+ })
+
+ it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => {
+ let releaseFetch: ((value: GitResult) => void) | undefined
+ mockGitDispatch({
+ fetch: () =>
+ new Promise((resolve) => {
+ releaseFetch = resolve
+ })
+ })
+
+ const firstCall = deriveSummary()
+ const secondCall = deriveSummary()
+ releaseFetch?.({ stdout: '' })
+ const [first, second] = await Promise.all([firstCall, secondCall])
+
+ expect(first).toEqual(expectedSummary)
+ expect(second).toEqual(expectedSummary)
+ expect(spawnCount()).toBe(5)
+ })
+
+ it('dedupes overlapping derivations after resolving the same live base tip', async () => {
+ let releaseFetch: ((value: GitResult) => void) | undefined
+ mockGitDispatch({
+ fetch: () =>
+ new Promise((resolve) => {
+ releaseFetch = resolve
+ })
+ })
+
+ const backgroundRefresh = getPRConflictSummary(
+ '/repo-root',
+ 'main',
+ 'older-github-base-oid',
+ 'head-oid-1',
+ {}
+ )
+ const manualRefresh = getPRConflictSummary(
+ '/repo-root',
+ 'main',
+ 'newer-github-base-oid',
+ 'head-oid-1',
+ {}
+ )
+ releaseFetch?.({ stdout: '' })
+ const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh])
+
+ expect(backgroundSummary).toEqual(expectedSummary)
+ expect(manualSummary).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(1)
+ expect(spawnCount('rev-parse')).toBe(1)
+ expect(spawnCount('merge-base')).toBe(1)
+ expect(spawnCount('rev-list')).toBe(1)
+ expect(spawnCount('merge-tree')).toBe(1)
+ })
+
+ it('shares one base fetch across concurrent PRs on the same base branch', async () => {
+ let releaseFetch: ((value: GitResult) => void) | undefined
+ mockGitDispatch({
+ fetch: () =>
+ new Promise((resolve) => {
+ releaseFetch = resolve
+ })
+ })
+
+ const prA = deriveSummary('head-oid-a')
+ const prB = deriveSummary('head-oid-b')
+ releaseFetch?.({ stdout: '' })
+ const [summaryA, summaryB] = await Promise.all([prA, prB])
+
+ expect(summaryA).toEqual(expectedSummary)
+ expect(summaryB).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(1)
+ expect(spawnCount('rev-parse')).toBe(1)
+ expect(spawnCount('merge-base')).toBe(2)
+ })
+
+ it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => {
+ let releaseFetch: ((value: GitResult) => void) | undefined
+ mockGitDispatch({
+ fetch: () =>
+ new Promise((resolve) => {
+ releaseFetch = resolve
+ }),
+ 'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')),
+ 'rev-list': async (argv) => ({
+ stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n'
+ }),
+ 'merge-tree': async (argv) => ({
+ stdout: argv.includes('bbbb2222-base')
+ ? 'tree-oid\u0000src/b.ts\u0000'
+ : 'tree-oid\u0000src/a.ts\u0000'
+ })
+ })
+
+ const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {})
+ const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {})
+ releaseFetch?.({ stdout: '' })
+ const [summaryA, summaryB] = await Promise.all([prA, prB])
+
+ expect(summaryA).toEqual({
+ baseRef: 'main',
+ baseCommit: 'aaaa111',
+ commitsBehind: 4,
+ files: ['src/a.ts']
+ })
+ expect(summaryB).toEqual({
+ baseRef: 'main',
+ baseCommit: 'bbbb222',
+ commitsBehind: 7,
+ files: ['src/b.ts']
+ })
+ expect(spawnCount('fetch')).toBe(1)
+ expect(spawnCount('merge-base')).toBe(2)
+ })
+
+ it('falls back to the local remote-tracking ref when fetch fails', async () => {
+ mockGitDispatch({
+ fetch: () => Promise.reject(new Error('offline'))
+ })
+
+ const summary = await deriveSummary()
+
+ expect(summary).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(1)
+
+ // Why: a failed fetch attempt must also be throttled — offline machines
+ // were the worst case, paying the 10s fetch timeout on every tick.
+ const repeat = await deriveSummary()
+ expect(repeat).toEqual(expectedSummary)
+ expect(spawnCount('fetch')).toBe(1)
+ expect(spawnCount()).toBe(5)
+ })
+
+ it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => {
+ mockGitDispatch({
+ fetch: () => Promise.reject(new Error('offline')),
+ 'rev-parse': () => Promise.reject(new Error('unknown revision'))
+ })
+
+ const summary = await deriveSummary()
+
+ expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' })
+ expect(
+ gitExecFileAsyncMock.mock.calls.some(
+ ([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid')
+ )
+ ).toBe(true)
+ })
+
+ it('keeps WSL-distro derivations isolated from host derivations', async () => {
+ mockGitDispatch()
+
+ await deriveSummary()
+ await deriveSummary('head-oid-1', 'Ubuntu')
+
+ expect(spawnCount('fetch')).toBe(2)
+ expect(
+ gitExecFileAsyncMock.mock.calls.filter(
+ ([argv, options]) =>
+ argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu'
+ )
+ ).toHaveLength(1)
+ })
+
+ it('keeps identities distinct when paths or ref names contain a joiner character', async () => {
+ mockGitDispatch()
+
+ // Why: these two calls alias under naive pipe-joined keys — the segment
+ // boundary shifts between repoPath and baseRefName.
+ await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {})
+ await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {})
+
+ expect(spawnCount('fetch')).toBe(2)
+ expect(spawnCount('merge-base')).toBe(2)
+ })
+
+ it('caches failed derivations only until the throttle window expires', async () => {
+ mockGitDispatch({
+ 'merge-base': () => Promise.reject(new Error('bad object'))
+ })
+
+ await expect(deriveSummary()).resolves.toBeUndefined()
+ const failureSpawns = spawnCount()
+
+ // Within the window the failure is negative-cached: zero subprocesses.
+ await expect(deriveSummary()).resolves.toBeUndefined()
+ expect(spawnCount()).toBe(failureSpawns)
+
+ // After expiry the derivation retries (a fetch may have brought objects in).
+ vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
+ mockGitDispatch()
+ await expect(deriveSummary()).resolves.toEqual(expectedSummary)
+ expect(spawnCount('merge-base')).toBe(2)
+ })
+})
diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts
index ce0c4b361b7..9644555048a 100644
--- a/src/main/github/conflict-summary.ts
+++ b/src/main/github/conflict-summary.ts
@@ -1,5 +1,17 @@
import type { PRConflictSummary } from '../../shared/types'
import { gitExecFileAsync } from '../git/runner'
+import {
+ __resetPRConflictSummaryDerivationCachesForTests,
+ buildConflictSummaryCacheKey,
+ dedupeBaseOidResolve,
+ dedupeSummaryDerivation,
+ getConflictSummaryGitRuntimeKey,
+ readCachedSummary,
+ readFreshBaseTipResolution,
+ rememberUnresolvedBaseTip,
+ storeResolvedBaseTip,
+ storeCachedSummary
+} from './conflict-summary-cache'
type LocalGitExecOptions = {
wslDistro?: string
@@ -7,8 +19,9 @@ type LocalGitExecOptions = {
const mergeTreeMergeBaseUnsupportedRuntimes = new Set()
-export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void {
+export function __resetPRConflictSummaryCachesForTests(): void {
mergeTreeMergeBaseUnsupportedRuntimes.clear()
+ __resetPRConflictSummaryDerivationCachesForTests()
}
export async function getPRConflictSummary(
@@ -18,45 +31,122 @@ export async function getPRConflictSummary(
headRefOid: string,
localGitOptions: LocalGitExecOptions = {}
): Promise {
- try {
- // Why: the renderer only needs a read-only merge-conflict snapshot. We
- // derive it from local git state so the PR card can show GitHub-style
- // detail without spending additional gh API calls on every refresh. We use
- // GitHub's head OID directly because the registered repo path may not have
- // a matching local branch name for the PR head. For the base side, prefer a
- // freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
- // which compares against the latest base branch tip rather than the PR's
- // older pinned baseRefOid snapshot.
- const latestBaseOid = await resolveLatestBaseOid(
+ // Why: the renderer only needs a read-only merge-conflict snapshot. We
+ // derive it from local git state so the PR card can show GitHub-style
+ // detail without spending additional gh API calls on every refresh. We use
+ // GitHub's head OID directly because the registered repo path may not have
+ // a matching local branch name for the PR head. For the base side, prefer a
+ // freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
+ // which compares against the latest base branch tip rather than the PR's
+ // older pinned baseRefOid snapshot.
+ const latestBaseOid = await resolveLatestBaseOidThrottled(
+ repoPath,
+ baseRefName,
+ baseRefOid,
+ localGitOptions
+ )
+ // Why: the summary is a pure function of the two commit OIDs, so a key hit
+ // can skip the whole merge-base/rev-list/merge-tree subprocess chain.
+ const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
+ const summaryKey = buildConflictSummaryCacheKey(
+ runtimeKey,
+ repoPath,
+ baseRefName,
+ headRefOid,
+ latestBaseOid
+ )
+ const cached = readCachedSummary(summaryKey)
+ if (cached) {
+ return cached.value
+ }
+
+ // Why: different GitHub reads can report different pinned baseRefOid values
+ // while still resolving to the same live base tip; dedupe the expensive
+ // local derivation on the actual summary identity.
+ return dedupeSummaryDerivation(summaryKey, () =>
+ derivePRConflictSummary(
repoPath,
baseRefName,
- baseRefOid,
+ headRefOid,
+ latestBaseOid,
+ summaryKey,
localGitOptions
)
+ )
+}
+
+async function derivePRConflictSummary(
+ repoPath: string,
+ baseRefName: string,
+ headRefOid: string,
+ latestBaseOid: string,
+ summaryKey: string,
+ localGitOptions: LocalGitExecOptions
+): Promise {
+ const cached = readCachedSummary(summaryKey)
+ if (cached) {
+ return cached.value
+ }
+
+ try {
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions)
const [commitsBehind, files] = await Promise.all([
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions),
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions)
])
- return {
+ const summary = {
baseRef: baseRefName,
baseCommit: latestBaseOid.slice(0, 7),
commitsBehind,
files,
...(files.length === 0 ? { localMergeState: 'clean' as const } : {})
}
+ storeCachedSummary(summaryKey, summary)
+ return summary
} catch {
+ storeCachedSummary(summaryKey, undefined)
return undefined
}
}
-async function resolveLatestBaseOid(
+async function resolveLatestBaseOidThrottled(
repoPath: string,
baseRefName: string,
fallbackBaseOid: string,
localGitOptions: LocalGitExecOptions
): Promise {
+ const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
+ const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName)
+ const cachedResolution = readFreshBaseTipResolution(baseKey)
+ if (cachedResolution) {
+ return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid
+ }
+ return dedupeBaseOidResolve(baseKey, async () => {
+ // Why re-check inside the dedupe slot: a sibling caller may have finished
+ // resolving between our cache read and this factory starting.
+ const freshResolution = readFreshBaseTipResolution(baseKey)
+ if (freshResolution) {
+ return freshResolution
+ }
+ const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions)
+ if (oid) {
+ storeResolvedBaseTip(baseKey, oid)
+ return { kind: 'resolved', oid }
+ }
+ // Why cache the unresolved probe, not the caller fallback: the fetch
+ // attempt is branch-wide expensive work, but GitHub's baseRefOid is
+ // PR-specific and must not leak to sibling PRs on the same base branch.
+ rememberUnresolvedBaseTip(baseKey)
+ return { kind: 'fallback-unresolved' }
+ }).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid))
+}
+
+async function resolveLatestBaseOid(
+ repoPath: string,
+ baseRefName: string,
+ localGitOptions: LocalGitExecOptions
+): Promise {
const remoteName = 'origin'
try {
@@ -88,7 +178,7 @@ async function resolveLatestBaseOid(
}
}
- return fallbackBaseOid
+ return null
}
async function resolveMergeBase(
@@ -123,7 +213,7 @@ async function loadConflictingFiles(
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise {
- const capabilityKey = getMergeTreeCapabilityKey(localGitOptions)
+ const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const modernArgs = [
'merge-tree',
'--write-tree',
@@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree(
}
}
-function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string {
- return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host'
-}
-
function parseMergeTreeNameOnlyOutput(stdout: string): string[] {
const entries = stdout.split('\0').filter(Boolean)
if (entries.length === 0) {
diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts
index 76bd2460307..3e53d0116d1 100644
--- a/src/main/github/pr-refresh-coordinator.test.ts
+++ b/src/main/github/pr-refresh-coordinator.test.ts
@@ -3,14 +3,19 @@ request timestamps, and follow-up scheduling against shared module state. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types'
-const { sendMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock } = vi.hoisted(
- () => ({
- sendMock: vi.fn(),
- getAllWebContentsMock: vi.fn(),
- getPRForBranchOutcomeMock: vi.fn(),
- getRateLimitMock: vi.fn()
- })
-)
+const {
+ sendMock,
+ getAllWebContentsMock,
+ getPRForBranchOutcomeMock,
+ getRateLimitMock,
+ rateLimitGuardMock
+} = vi.hoisted(() => ({
+ sendMock: vi.fn(),
+ getAllWebContentsMock: vi.fn(),
+ getPRForBranchOutcomeMock: vi.fn(),
+ getRateLimitMock: vi.fn(),
+ rateLimitGuardMock: vi.fn()
+}))
vi.mock('electron', () => ({
webContents: {
@@ -25,7 +30,7 @@ vi.mock('./client', () => ({
vi.mock('./rate-limit', () => ({
getRateLimit: getRateLimitMock,
noteRateLimitSpend: vi.fn(),
- rateLimitGuard: vi.fn(() => ({ blocked: false }))
+ rateLimitGuard: rateLimitGuardMock
}))
function makeCandidate(
@@ -80,6 +85,8 @@ describe('pr-refresh-coordinator', () => {
getAllWebContentsMock.mockReset()
getPRForBranchOutcomeMock.mockReset()
getRateLimitMock.mockReset()
+ rateLimitGuardMock.mockReset()
+ rateLimitGuardMock.mockReturnValue({ blocked: false })
getAllWebContentsMock.mockReturnValue([
{
id: 1,
@@ -505,13 +512,45 @@ describe('pr-refresh-coordinator', () => {
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
})
+ it('proceeds with background refreshes when the budget probe fails (fail open)', async () => {
+ const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
+ // Why: regression for #7553 — GHES with rate limiting disabled 404s every
+ // probe; an unreadable budget must not pause refreshes.
+ getRateLimitMock.mockResolvedValue({
+ ok: false,
+ error: 'HTTP 404: Rate limiting is not enabled.'
+ })
+ getPRForBranchOutcomeMock.mockResolvedValue({
+ kind: 'no-pr',
+ fetchedAt: Date.now()
+ })
+
+ enqueuePRRefresh(makeCandidate(), 'active', 80, 1)
+
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
+ const pausedEvents = sendMock.mock.calls
+ .map(([, event]) => event)
+ .filter((event) => event.status === 'paused' && event.skippedReason === 'rate-limit')
+ expect(pausedEvents).toHaveLength(0)
+ })
+
it('does not consume active burst slots for rate-limit pauses', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
- getRateLimitMock
- .mockResolvedValueOnce({ ok: false })
- .mockResolvedValueOnce({ ok: false })
- .mockResolvedValueOnce({ ok: false })
- .mockResolvedValue({ ok: true })
+ // Why: keyed on drained items (one getRateLimit call each) rather than
+ // guard-call counts, so the guard's per-bucket evaluation order stays free
+ // to change without breaking the slot-accounting assertion.
+ let drainedItems = 0
+ getRateLimitMock.mockImplementation(async () => {
+ drainedItems += 1
+ return { ok: true }
+ })
+ rateLimitGuardMock.mockImplementation(() =>
+ drainedItems <= 3
+ ? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 }
+ : { blocked: false }
+ )
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts
index a2576e4805e..8065fbb8145 100644
--- a/src/main/github/pr-refresh-coordinator.ts
+++ b/src/main/github/pr-refresh-coordinator.ts
@@ -708,20 +708,11 @@ async function drainQueue(): Promise {
)
if (isBackground(next.reason)) {
- const rateLimit = await getRateLimit()
- if (!rateLimit.ok) {
- const retryAt = Date.now() + 30_000
- queue.set(next.key, { ...next, dueAt: retryAt })
- broadcast({
- aliases,
- reason: next.reason,
- status: 'paused',
- pausedUntil: retryAt,
- skippedReason: 'rate-limit'
- })
- scheduleDrain(30_000)
- continue
- }
+ // Why: the probe only warms rateLimitGuard's cached snapshot, so a
+ // failed probe must fail open — GHES with rate limiting disabled 404s
+ // every probe (#7553). A genuinely broken gh surfaces per-key as typed
+ // fetch outcomes instead (visible keys additionally back off).
+ await getRateLimit()
const buckets = backgroundRefreshBuckets()
const blockedGuard = buckets
.map((bucket) => rateLimitGuard(bucket))
diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts
new file mode 100644
index 00000000000..ab1373a70f0
--- /dev/null
+++ b/src/main/github/rate-limit.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { ghExecFileAsyncMock } = vi.hoisted(() => ({
+ ghExecFileAsyncMock: vi.fn()
+}))
+
+vi.mock('../git/runner', () => ({
+ ghExecFileAsync: ghExecFileAsyncMock
+}))
+
+vi.mock('./gh-utils', () => ({
+ acquire: vi.fn(),
+ release: vi.fn()
+}))
+
+import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit'
+
+const PAYLOAD = JSON.stringify({
+ resources: {
+ core: { limit: 5000, remaining: 4200, reset: 1_700_000_000 },
+ search: { limit: 30, remaining: 28, reset: 1_700_000_000 },
+ graphql: { limit: 5000, remaining: 4900, reset: 1_700_000_000 }
+ }
+})
+
+describe('getRateLimit', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ ghExecFileAsyncMock.mockReset()
+ _resetRateLimitCache()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('parses bucket counts from gh api rate_limit', async () => {
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
+
+ const result = await getRateLimit()
+
+ expect(result).toEqual({
+ ok: true,
+ snapshot: {
+ core: { limit: 5000, remaining: 4200, resetAt: 1_700_000_000 },
+ search: { limit: 30, remaining: 28, resetAt: 1_700_000_000 },
+ graphql: { limit: 5000, remaining: 4900, resetAt: 1_700_000_000 },
+ fetchedAt: 1_000
+ }
+ })
+ })
+
+ it('caches a failed probe for the TTL instead of re-spawning gh', async () => {
+ // Why: regression for #7553 — GHES with rate limiting disabled fails every
+ // probe. Refreshes fail open past the failure, so without a negative cache
+ // each queued refresh would spawn its own doomed gh subprocess.
+ ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.'))
+
+ const first = await getRateLimit()
+ const second = await getRateLimit()
+
+ expect(first).toEqual({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' })
+ expect(second).toEqual(first)
+ expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('re-probes after the failure TTL elapses and recovers on success', async () => {
+ ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom'))
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
+
+ expect((await getRateLimit()).ok).toBe(false)
+ expect((await getRateLimit()).ok).toBe(false)
+ expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
+
+ vi.setSystemTime(1_000 + 30_000)
+
+ expect((await getRateLimit()).ok).toBe(true)
+ expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
+ })
+
+ it('bypasses the failure cache when forced', async () => {
+ ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom'))
+ ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
+
+ expect((await getRateLimit()).ok).toBe(false)
+ expect((await getRateLimit({ force: true })).ok).toBe(true)
+ expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('rateLimitGuard', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ ghExecFileAsyncMock.mockReset()
+ _resetRateLimitCache()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise {
+ ghExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: JSON.stringify({
+ resources: {
+ core: { limit: 5000, remaining: 3, reset: resetEpochSeconds },
+ search: { limit: 30, remaining: 28, reset: resetEpochSeconds },
+ graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds }
+ }
+ })
+ })
+ expect((await getRateLimit()).ok).toBe(true)
+ }
+
+ it('blocks below the floor while resetAt is in the future', async () => {
+ await cacheExhaustedSnapshot(61)
+
+ expect(rateLimitGuard('core')).toEqual({
+ blocked: true,
+ remaining: 3,
+ limit: 5000,
+ resetAt: 61
+ })
+ })
+
+ it('fails open once the blocking bucket resetAt has elapsed', async () => {
+ // Why: probes can fail indefinitely (sleep past resetAt, network blip), so
+ // a stale exhausted snapshot must not block once GitHub has already reset
+ // the budget — a past-due pause would spin the coordinator drain loop.
+ await cacheExhaustedSnapshot(61)
+
+ vi.setSystemTime(61_000)
+
+ expect(rateLimitGuard('core')).toEqual({ blocked: false })
+ })
+})
diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts
index ad140422488..f4a1ba56248 100644
--- a/src/main/github/rate-limit.ts
+++ b/src/main/github/rate-limit.ts
@@ -35,6 +35,11 @@ import {
// 1-per-second "is it safe now?" polling pattern UIs tend to fall into.
const RATE_LIMIT_CACHE_TTL_MS = 30_000
let cached: GitHubRateLimitSnapshot | null = null
+// Why: failed probes are cached for the same TTL as successes. Refreshes fail
+// open past a failed probe, so on a host that can never report a budget (GHES
+// with rate limiting disabled 404s every probe) an uncached failure would cost
+// a gh subprocess per queued refresh.
+let probeFailure: { at: number; error: string } | null = null
type GhRateLimitPayload = {
resources?: {
@@ -66,6 +71,7 @@ function parseBucket(
/** @internal — test-only */
export function _resetRateLimitCache(): void {
cached = null
+ probeFailure = null
}
// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new
@@ -196,6 +202,9 @@ export async function getRateLimit(options?: { force?: boolean }): Promise {
fetchedAt: Date.now()
}
cached = snapshot
+ probeFailure = null
return { ok: true, snapshot }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
+ probeFailure = { at: Date.now(), error: message }
return { ok: false, error: message }
} finally {
release()
diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts
index 979d6b5611f..c6fa78b126d 100644
--- a/src/main/ipc/filesystem-auth.test.ts
+++ b/src/main/ipc/filesystem-auth.test.ts
@@ -8,8 +8,11 @@ import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types'
import {
+ AUTHORIZED_EXTERNAL_PATHS_MAX,
+ authorizeExternalPath,
invalidateAuthorizedRootsCache,
isDescendantOrEqual,
+ isPathAllowed,
rebuildAuthorizedRootsCache,
resolveAuthorizedPath,
resolveRegisteredWorktreePath,
@@ -336,3 +339,42 @@ describe('filesystem-auth path containment', () => {
}
})
})
+
+describe('filesystem-auth authorized external path bound', () => {
+ // Empty allow-list store, so a path is allowed only if it (or an ancestor) is
+ // in the session-authorized external-path set.
+ const emptyStore = makeStore([])
+ const flood = (n: number): string =>
+ resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`)
+
+ it('bounds the authorized external path set with LRU eviction', () => {
+ const keep = resolve('/leak-audit-ext/keep')
+ authorizeExternalPath(keep)
+
+ // Flood past the cap with distinct external paths, re-authorizing `keep`
+ // periodically so LRU keeps it hot.
+ const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200
+ for (let i = 0; i < total; i += 1) {
+ authorizeExternalPath(flood(i))
+ if (i % 250 === 0) {
+ authorizeExternalPath(keep)
+ }
+ }
+
+ // The oldest never-re-touched entries fell out of the bounded set...
+ expect(isPathAllowed(flood(0), emptyStore)).toBe(false)
+ // ...while the periodically re-authorized path and the most recent survive.
+ expect(isPathAllowed(keep, emptyStore)).toBe(true)
+ expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true)
+ })
+
+ it('re-authorizes an evicted path on next use (self-healing)', () => {
+ const path = resolve('/leak-audit-ext/evicted-then-reused')
+ for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) {
+ authorizeExternalPath(flood(100_000 + i))
+ }
+ expect(isPathAllowed(path, emptyStore)).toBe(false)
+ authorizeExternalPath(path)
+ expect(isPathAllowed(path, emptyStore)).toBe(true)
+ })
+})
diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts
index f931ebe119b..7fee4ca1990 100644
--- a/src/main/ipc/filesystem-auth.ts
+++ b/src/main/ipc/filesystem-auth.ts
@@ -13,6 +13,14 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
+// Why: authorized external paths accumulate for the whole session (file drops,
+// terminal link opens, editor/composer/notebook opens). Bound the set with LRU
+// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long
+// session touching many distinct external files cannot grow it (or the O(n) auth
+// scan in isPathAllowed) without limit. Every caller re-authorizes a path right
+// before operating on it, so evicting a stale entry is self-healing and does not
+// weaken the security boundary.
+export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
const authorizedExternalPaths = new Set()
const registeredWorktreeRoots = new Set()
const registeredWorktreeRootsByRepo = new Map>()
@@ -23,12 +31,26 @@ const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
type FolderScopeStore = Pick &
Partial>
+function rememberAuthorizedExternalPath(path: string): void {
+ // Delete-then-add keeps re-authorized (actively used) paths most-recent so
+ // eviction only sheds the oldest never-re-touched entries.
+ authorizedExternalPaths.delete(path)
+ authorizedExternalPaths.add(path)
+ while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
+ const oldest = authorizedExternalPaths.keys().next().value
+ if (oldest === undefined) {
+ break
+ }
+ authorizedExternalPaths.delete(oldest)
+ }
+}
+
export function authorizeExternalPath(targetPath: string): void {
const resolvedTarget = resolve(targetPath)
- authorizedExternalPaths.add(resolvedTarget)
+ rememberAuthorizedExternalPath(resolvedTarget)
try {
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
- authorizedExternalPaths.add(realpathSync(resolvedTarget))
+ rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
} catch {}
}
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index 00d244e3031..cffdf6cb668 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -3381,6 +3381,54 @@ describe('registerPtyHandlers', () => {
}
})
+ // Why: the cap and its flag must never fire in the common case (renderer keeps
+ // up), so ordinary small output carries no droppedBacklog.
+ it('does not flag droppedBacklog for ordinary small output under the cap', async () => {
+ vi.useFakeTimers()
+ const runtime = {
+ setPtyController: vi.fn(),
+ registerPty: vi.fn(),
+ onPtySpawned: vi.fn(),
+ onPtyExit: vi.fn(),
+ onPtyData: vi.fn(() => 12),
+ createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'),
+ registerPreAllocatedHandleForPty: vi.fn()
+ }
+ try {
+ registerPtyHandlers(
+ mainWindow as never,
+ runtime as never,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ { awaitLocalPtyStartup: () => Promise.resolve() }
+ )
+ const pendingSpawn = handlers.get('pty:spawn')!(null, {
+ cols: 80,
+ rows: 24,
+ sessionId: 'small-output-session'
+ }) as Promise<{ id: string }>
+ await Promise.resolve()
+ const daemon = installObservableDaemonTestProvider()
+ rebindLocalProviderListeners()
+ const result = await pendingSpawn
+
+ daemon.emitData(result.id, 'small output')
+ await vi.advanceTimersByTimeAsync(50)
+
+ const dataSends = mainWindow.webContents.send.mock.calls.filter(
+ (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id
+ )
+ expect(dataSends.length).toBeGreaterThan(0)
+ for (const call of dataSends) {
+ expect((call[1] as { droppedBacklog?: boolean }).droppedBacklog).toBeUndefined()
+ }
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => {
const barrier = makeDeferred()
const runtime = {
@@ -8380,6 +8428,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
+ hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -8797,6 +8846,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
+ hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -8853,6 +8903,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
+ hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -8916,6 +8967,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
+ hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -10332,6 +10384,7 @@ describe('registerPtyHandlers', () => {
onPtyData: vi.fn(),
preAllocateHandleForPty: vi.fn(() => null),
getPtyOutputSequence: vi.fn(() => 2_472),
+ hasRemoteTerminalViewSubscriber: vi.fn(() => false),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 100,
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 8d14ac8830f..8be80723582 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -1504,6 +1504,11 @@ export function registerPtyHandlers(
data: string
startSeq?: number
containsBackgroundOutput?: boolean
+ // Why droppedOutput (not main's droppedBacklog trim): this branch bounds
+ // the unsent backlog with the O(1) drop-to-sentinel + query-salvage +
+ // snapshot-restore mechanism below, which strictly supersedes main's
+ // #7630 keep-2MB-tail trim — carrying both would race two cap policies
+ // over the same buffer.
droppedOutput?: true
}
@@ -1552,7 +1557,9 @@ export function registerPtyHandlers(
const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024
// Why: while the renderer cannot receive (frozen, starved, mid-reload), a
// chatty PTY used to grow its pendingData string without bound — main-process
- // heap ballooning that a renderer reload cannot clear. Beyond this cap the
+ // heap ballooning that a renderer reload cannot clear (main's #7630 fixed the
+ // same Win/Linux background-throttled-renderer leak with a 2MB tail trim;
+ // this branch's sentinel mechanism supersedes it). Beyond this cap the
// buffered bytes are dropped and the pane heals from the main-owned buffer
// snapshot via the droppedOutput sentinel (renderer hidden-output restore).
// Why read settings live: the cap scales with the user's scrollback setting
@@ -2339,15 +2346,25 @@ export function registerPtyHandlers(
// tears down the terminal on pty:exit before the batch timer fires.
const remaining = pendingData.get(payload.id)
if (remaining) {
- sendPtyDataToRenderer(
- payload.id,
- makePtyDataPayload(
+ if (remaining.droppedOutput === true) {
+ // Sentinel entry: only salvaged query bytes remain; keep the flag so
+ // the renderer knows the span was dropped (same as the flush loop).
+ sendPtyDataToRenderer(payload.id, {
+ id: payload.id,
+ data: remaining.data,
+ droppedOutput: true
+ })
+ } else {
+ sendPtyDataToRenderer(
payload.id,
- remaining.data,
- remaining.startSeq,
- remaining.containsBackgroundOutput
+ makePtyDataPayload(
+ payload.id,
+ remaining.data,
+ remaining.startSeq,
+ remaining.containsBackgroundOutput
+ )
)
- )
+ }
pendingData.delete(payload.id)
}
// Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY)
@@ -2514,7 +2531,8 @@ export function registerPtyHandlers(
...(typeof pending.startSeq === 'number'
? { seq: pending.startSeq + nextData.length, rawLength: nextData.length }
: {}),
- ...(pending.containsBackgroundOutput === true ? { background: true } : {})
+ ...(pending.containsBackgroundOutput === true ? { background: true } : {}),
+ ...(pending.droppedOutput === true ? { droppedOutput: true } : {})
})
return
}
diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts
index 4ca47c3aa63..6d04e8c7b0f 100644
--- a/src/main/ipc/ssh-browse.test.ts
+++ b/src/main/ipc/ssh-browse.test.ts
@@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } {
})
}
+// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand `
+// command so tests can assert on the actual (UTF-16LE) payload sent to the host.
+function decodeEncodedCommand(command: string): string {
+ const match = /-EncodedCommand (\S+)/.exec(command)
+ if (!match) {
+ throw new Error(`no -EncodedCommand in: ${command}`)
+ }
+ return Buffer.from(match[1], 'base64').toString('utf16le')
+}
+
describe('registerSshBrowseHandler', () => {
let handler: BrowseHandler
@@ -88,6 +98,275 @@ describe('registerSshBrowseHandler', () => {
expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap")
})
+ it('falls back to PowerShell when a Windows SSH shell rejects POSIX exec', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users/alice' })
+ await Promise.resolve()
+ posixChannel.stderr.emit(
+ 'data',
+ Buffer.from('"exec" no se reconoce como un comando interno o externo')
+ )
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ // Windows OpenSSH exec emits CRLF; the parser must strip \r so directories
+ // aren't misclassified as files with a stray carriage return in the name.
+ // The script emits a forward-slash resolvedPath (the -replace '\\','/' line)
+ // so the renderer's parentPath/joinPath, which only split on `/`, still work.
+ windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\nDesktop/\r\nnotes.txt\r\n'))
+ windowsChannel.emit('exit', 0)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).resolves.toEqual({
+ resolvedPath: 'C:/Users/alice',
+ entries: [
+ { name: 'Desktop', isDirectory: true },
+ { name: 'notes.txt', isDirectory: false }
+ ]
+ })
+ expect(exec).toHaveBeenCalledTimes(2)
+ expect(exec).toHaveBeenNthCalledWith(1, "cd 'C:/Users/alice' && pwd && command ls -1Ap")
+ expect(exec.mock.calls[1]?.[0]).toMatch(/^powershell\.exe /)
+ expect(exec.mock.calls[1]?.[1]).toEqual({ wrapCommand: false })
+
+ // Decode the -EncodedCommand payload so an accidental switch from the
+ // single-quote-escaped PowerShell literal to raw interpolation (an injection
+ // regression) is caught, and to lock in the UTF-8 output pin.
+ const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
+ expect(script).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8')
+ expect(script).toContain("$dir = 'C:/Users/alice'")
+ expect(script).toContain('Get-ChildItem -LiteralPath $resolved -Force')
+ // resolvedPath must be emitted with forward slashes so the renderer's
+ // parentPath/joinPath (which only split on `/`) keep working on Windows.
+ expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')")
+ })
+
+ it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => {
+ // Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with
+ // localized stderr. The old 9009/English-string trigger silently missed this,
+ // so German/Japanese/etc. hosts never fell back. Keying off the non-zero exit
+ // of a RemoteBrowseError fixes it regardless of OS display language.
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users' })
+ await Promise.resolve()
+ // Japanese cmd.exe "not recognized" text — matches none of the removed English
+ // /Spanish substrings, and exit 1 is not the removed 9009 sentinel.
+ posixChannel.stderr.emit(
+ 'data',
+ Buffer.from("'exec' は、内部コマンドとして認識されていません。")
+ )
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.emit('data', Buffer.from('C:/Users\r\nAdmin/\r\n'))
+ windowsChannel.emit('exit', 0)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).resolves.toEqual({
+ resolvedPath: 'C:/Users',
+ entries: [{ name: 'Admin', isDirectory: true }]
+ })
+ expect(exec).toHaveBeenCalledTimes(2)
+ })
+
+ it('escapes single quotes in the PowerShell literal path', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "C:/O'Brien" })
+ await Promise.resolve()
+ // A cmd.exe reject exits 1 over SSH (its 9009 ERRORLEVEL never crosses the
+ // process boundary) with localized German stderr. The fallback must still fire,
+ // since it keys off the non-zero exit, not the (localized, unmatchable) text.
+ posixChannel.stderr.emit('data', Buffer.from('Der Befehl "exec" ist falsch geschrieben'))
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.emit('data', Buffer.from("C:/O'Brien\r\n"))
+ windowsChannel.emit('exit', 0)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).resolves.toEqual({
+ resolvedPath: "C:/O'Brien",
+ entries: []
+ })
+ const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
+ // Single quote must be doubled inside the PowerShell literal, not passed raw.
+ expect(script).toContain("$dir = 'C:/O''Brien'")
+ })
+
+ it('expands ~ to $HOME in the PowerShell fallback (the default browse path)', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' })
+ await Promise.resolve()
+ posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\n'))
+ windowsChannel.emit('exit', 0)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] })
+ const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
+ // ~ must expand to $HOME, not be passed literally to Set-Location.
+ expect(script).toContain('$dir = $HOME')
+ })
+
+ // The renderer rebuilds forward-slash Windows paths with POSIX helpers: the
+ // breadcrumb prepends a spurious leading '/' before the drive, and "Up" from a
+ // first-level dir yields a bare drive letter. Both must be rooted for
+ // Set-Location, or navigation lands in the drive-relative cwd / errors.
+ it.each([
+ { dirPath: '/C:/Users', expected: "$dir = 'C:/Users'" },
+ { dirPath: 'C:', expected: "$dir = 'C:/'" },
+ // Combined strip + root, so a future refactor can't break the ordering.
+ { dirPath: '/C:', expected: "$dir = 'C:/'" }
+ ])(
+ 'roots the Windows drive path $dirPath in the PowerShell fallback',
+ async ({ dirPath, expected }) => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath })
+ await Promise.resolve()
+ posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.emit('data', Buffer.from('C:/Users\r\n'))
+ windowsChannel.emit('exit', 0)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] })
+ const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
+ expect(script).toContain(expected)
+ }
+ )
+
+ it('surfaces the original POSIX failure when the PowerShell retry shows the host is not Windows', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/root/secret' })
+ await Promise.resolve()
+ // A genuine POSIX permission failure exits non-zero; it's indistinguishable
+ // from a Windows shell reject without probing, so the fallback is attempted...
+ posixChannel.stderr.emit('data', Buffer.from('ls: /root/secret: Permission denied'))
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ // ...but on a POSIX host the login shell can't find powershell.exe (exit 127),
+ // so the original permission error is surfaced, never masked by the retry.
+ windowsChannel.stderr.emit('data', Buffer.from('bash: powershell.exe: command not found'))
+ windowsChannel.emit('exit', 127)
+ windowsChannel.emit('close')
+
+ await expect(resultPromise).rejects.toThrow('Permission denied')
+ expect(exec).toHaveBeenCalledTimes(2)
+ })
+
+ it('surfaces the PowerShell error when the Windows fallback runs but fails', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/missing' })
+ await Promise.resolve()
+ posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
+ posixChannel.emit('exit', 1)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.stderr.emit('data', Buffer.from('Cannot find path C:/missing'))
+ windowsChannel.emit('exit', 1)
+ windowsChannel.emit('close')
+
+ // PowerShell exited non-127, so it genuinely ran on a Windows host — its error
+ // is the real cause. Surface it rather than the cmd.exe "not recognized" prose.
+ await expect(resultPromise).rejects.toThrow('Cannot find path')
+ })
+
+ it('surfaces the original POSIX error when the fallback shows powershell.exe is missing', async () => {
+ const posixChannel = createMockChannel()
+ const windowsChannel = createMockChannel()
+ const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
+ const getConnectionManager = () => ({
+ getConnection: () => ({ exec })
+ })
+ registerSshBrowseHandler(getConnectionManager as never)
+
+ const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/opt/exec' })
+ await Promise.resolve()
+ // A non-zero POSIX failure triggers the fallback probe on any host...
+ posixChannel.stderr.emit('data', Buffer.from('exec: command not found'))
+ posixChannel.emit('exit', 127)
+ posixChannel.emit('close')
+ await vi.waitFor(() => {
+ expect(windowsChannel.listenerCount('close')).toBe(1)
+ })
+ windowsChannel.stderr.emit('data', Buffer.from('sh: powershell.exe: not found'))
+ windowsChannel.emit('exit', 127)
+ windowsChannel.emit('close')
+
+ // The original POSIX failure is the real one — don't mask it with the
+ // misleading "powershell.exe: not found" from the doomed retry.
+ await expect(resultPromise).rejects.toThrow('exec: command not found')
+ })
+
it('rejects and detaches listeners when the browse channel errors', async () => {
const channel = createMockChannel()
const exec = vi.fn().mockResolvedValue(channel)
@@ -101,6 +380,9 @@ describe('registerSshBrowseHandler', () => {
channel.emit('error', new Error('remote disconnected'))
await expect(resultPromise).rejects.toThrow('remote disconnected')
+ // A transport failure is not a RemoteBrowseError, so it must not trigger a
+ // pointless PowerShell retry.
+ expect(exec).toHaveBeenCalledTimes(1)
expect(channel.listenerCount('data')).toBe(0)
expect(channel.listenerCount('exit')).toBe(0)
expect(channel.listenerCount('close')).toBe(0)
diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts
index 83afbd9de01..2729cd59ab7 100644
--- a/src/main/ipc/ssh-browse.ts
+++ b/src/main/ipc/ssh-browse.ts
@@ -1,5 +1,7 @@
import { ipcMain } from 'electron'
import type { SshConnectionManager } from '../ssh/ssh-connection'
+import type { SshExecOptions } from '../ssh/ssh-connection-utils'
+import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell'
export type RemoteDirEntry = {
name: string
@@ -8,6 +10,33 @@ export type RemoteDirEntry = {
const SSH_BROWSE_TIMEOUT_MS = 15_000
+// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX
+// "command not found" convention, identical across sh/bash/zsh and locales). It's
+// the locale-independent signal that the Windows fallback never actually ran, so
+// the original POSIX failure — not the doomed retry — is the real error.
+//
+// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value
+// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit
+// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2
+// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec`
+// wrapper is detected by "the remote command ran and exited non-zero"
+// (RemoteBrowseError), not by a magic exit code or localized stderr text.
+const POSIX_COMMAND_NOT_FOUND_EXIT = 127
+
+// Carries the raw exit code so the fallback can (a) recognize that the remote
+// command actually ran and failed — the locale-independent trigger for the
+// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart
+// from a genuine PowerShell error, without parsing localized shell prose.
+class RemoteBrowseError extends Error {
+ constructor(
+ message: string,
+ readonly exitCode: number | null
+ ) {
+ super(message)
+ this.name = 'RemoteBrowseError'
+ }
+}
+
// Why: the relay's fs.readDir enforces workspace root ACLs, which aren't
// registered until a repo is added. This handler uses a raw SSH exec channel
// to list directories, allowing the user to browse the remote filesystem
@@ -32,156 +61,229 @@ export function registerSshBrowseHandler(
throw new Error(`SSH connection "${args.targetId}" not found`)
}
- // Why: using one line per entry preserves filenames containing spaces.
- // `command ls` bypasses user aliases/functions like `ls='eza ...'`.
- // The -1 flag outputs one entry per line. The -p flag appends / to directories.
- // We resolve ~ and get the absolute path via `cd && pwd`.
- // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
- // denied after a readable `cd ... && pwd`) propagates as a non-zero exit
- // code rather than being indistinguishable from an empty directory.
- const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap`
- const channel = await conn.exec(command)
-
- return new Promise((resolve, reject) => {
- let stdout = ''
- let stderr = ''
- let exitCode: number | null = null
- let settled = false
- let timeout: ReturnType | null = null
-
- const cleanup = (): void => {
- if (timeout) {
- clearTimeout(timeout)
- timeout = null
- }
- channel.off('data', onStdoutData)
- channel.stderr.off('data', onStderrData)
- channel.off('exit', onExit)
- channel.off('close', onClose)
- channel.off('error', onError)
- channel.stderr.off('error', onError)
+ try {
+ return await browseWithPosixShell(conn, args.dirPath)
+ } catch (posixError) {
+ // Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX
+ // `exec` wrapper, and the only locale-independent signal for that is "the
+ // remote command executed and exited non-zero" (RemoteBrowseError). Its
+ // stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches
+ // us (sshd forwards process exit 1). Transport errors/timeouts aren't
+ // RemoteBrowseErrors, so a dropped connection is never retried as Windows.
+ if (!(posixError instanceof RemoteBrowseError)) {
+ throw posixError
}
- const rejectOnce = (error: Error): void => {
- if (settled) {
- return
- }
- settled = true
- cleanup()
- reject(error)
+ try {
+ return await browseWithWindowsPowerShell(conn, args.dirPath)
+ } catch (fallbackError) {
+ // Why: if the login shell couldn't find powershell.exe (exit 127) the
+ // host isn't Windows — surface the original POSIX failure rather than a
+ // misleading "powershell.exe: not found". Otherwise PowerShell genuinely
+ // ran and its error (e.g. "Cannot find path") is the real cause.
+ throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError
}
- const closeChannel = (): void => {
- const closable = channel as { close?: () => void; destroy?: () => void }
- try {
- if (typeof closable.close === 'function') {
- closable.close()
- } else if (typeof closable.destroy === 'function') {
- closable.destroy()
- }
- } catch {
- /* best effort */
- }
- }
- const onTimeout = (): void => {
- // Why: remote browsing runs before a relay workspace root exists, so
- // it cannot rely on relay request deadlines. Bound this raw exec
- // channel directly to keep Add Remote Project from hanging forever.
- rejectOnce(new Error('Remote directory listing timed out'))
- closeChannel()
- }
- const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
- if (settled) {
- return
- }
- settled = true
- cleanup()
- resolve(result)
- }
-
- const onStdoutData = (data: Buffer): void => {
- stdout += data.toString()
- }
- const onStderrData = (data: Buffer): void => {
- stderr += data.toString()
- }
- // `exit` fires before `close`; capture the code so we can distinguish
- // a failed `ls` that still produced `pwd` output from an empty listing.
- const onExit = (code: number | null): void => {
- exitCode = code
- }
- const onError = (error: Error): void => {
- rejectOnce(error)
- }
- const onClose = (): void => {
- // A null exitCode means the server closed the channel without
- // sending an exit-status message (or signalled termination). We
- // can't assume success — falling back to "empty stdout = empty
- // directory" is exactly the bug the exit-code branch was added to
- // fix. Treat any non-zero OR null exit as a failure when stderr
- // has content, and otherwise require stdout to contain at least
- // the resolved `pwd` line before accepting the result.
- if (exitCode !== 0) {
- const msg =
- stderr.trim() ||
- (exitCode === null
- ? 'Remote listing failed (channel closed without exit status)'
- : `Remote listing failed (exit ${exitCode})`)
- rejectOnce(new Error(msg))
- return
- }
- if (stderr.trim() && !stdout.trim()) {
- rejectOnce(new Error(stderr.trim()))
- return
- }
-
- const lines = stdout.trim().split('\n')
- if (lines.length === 0) {
- rejectOnce(new Error('Empty response from remote'))
- return
- }
-
- const resolvedPath = lines[0]
- const entries: RemoteDirEntry[] = []
-
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i]
- if (!line || line === './' || line === '../') {
- continue
- }
- if (line.endsWith('/')) {
- entries.push({ name: line.slice(0, -1), isDirectory: true })
- } else {
- entries.push({ name: line, isDirectory: false })
- }
- }
-
- // Sort: directories first, then alphabetical
- entries.sort((a, b) => {
- if (a.isDirectory !== b.isDirectory) {
- return a.isDirectory ? -1 : 1
- }
- return a.name.localeCompare(b.name)
- })
-
- resolveOnce({ entries, resolvedPath })
- }
-
- channel.on('data', onStdoutData)
- channel.stderr.on('data', onStderrData)
- channel.on('exit', onExit)
- channel.on('close', onClose)
- // Why: SSH exec streams emit `error` on transport loss; without a
- // scoped listener, a disappearing remote can become process-fatal.
- channel.on('error', onError)
- channel.stderr.on('error', onError)
- timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
- if (typeof timeout.unref === 'function') {
- timeout.unref()
- }
- })
+ }
}
)
}
+type SshBrowseConnection = NonNullable>
+
+function browseWithPosixShell(
+ conn: SshBrowseConnection,
+ dirPath: string
+): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
+ // Why: using one line per entry preserves filenames containing spaces.
+ // `command ls` bypasses user aliases/functions like `ls='eza ...'`.
+ // The -1 flag outputs one entry per line. The -p flag appends / to directories.
+ // We resolve ~ and get the absolute path via `cd && pwd`.
+ // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
+ // denied after a readable `cd ... && pwd`) propagates as a non-zero exit
+ // code rather than being indistinguishable from an empty directory.
+ return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`)
+}
+
+function browseWithWindowsPowerShell(
+ conn: SshBrowseConnection,
+ dirPath: string
+): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
+ const script = [
+ "$ErrorActionPreference = 'Stop'",
+ // Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM
+ // code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so
+ // non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake.
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
+ `$dir = ${powerShellPathExpression(dirPath)}`,
+ 'Set-Location -LiteralPath $dir',
+ '$resolved = (Get-Location).ProviderPath',
+ // Why: the renderer's parentPath/joinPath only split on `/`, so a native
+ // backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a
+ // forward-slash resolvedPath (matching the POSIX branch) while keeping the
+ // native $resolved for Get-ChildItem -LiteralPath.
+ "Write-Output ($resolved -replace '\\\\', '/')",
+ 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {',
+ " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }",
+ '}'
+ ].join('; ')
+
+ return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false })
+}
+
+async function runBrowseCommand(
+ conn: SshBrowseConnection,
+ command: string,
+ options?: SshExecOptions
+): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
+ const channel = options ? await conn.exec(command, options) : await conn.exec(command)
+
+ return new Promise((resolve, reject) => {
+ let stdout = ''
+ let stderr = ''
+ let exitCode: number | null = null
+ let settled = false
+ let timeout: ReturnType | null = null
+
+ const cleanup = (): void => {
+ if (timeout) {
+ clearTimeout(timeout)
+ timeout = null
+ }
+ channel.off('data', onStdoutData)
+ channel.stderr.off('data', onStderrData)
+ channel.off('exit', onExit)
+ channel.off('close', onClose)
+ channel.off('error', onError)
+ channel.stderr.off('error', onError)
+ }
+ const rejectOnce = (error: Error): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ cleanup()
+ reject(error)
+ }
+ const closeChannel = (): void => {
+ const closable = channel as { close?: () => void; destroy?: () => void }
+ try {
+ if (typeof closable.close === 'function') {
+ closable.close()
+ } else if (typeof closable.destroy === 'function') {
+ closable.destroy()
+ }
+ } catch {
+ /* best effort */
+ }
+ }
+ const onTimeout = (): void => {
+ // Why: remote browsing runs before a relay workspace root exists, so
+ // it cannot rely on relay request deadlines. Bound this raw exec
+ // channel directly to keep Add Remote Project from hanging forever.
+ rejectOnce(new Error('Remote directory listing timed out'))
+ closeChannel()
+ }
+ const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ cleanup()
+ resolve(result)
+ }
+
+ const onStdoutData = (data: Buffer): void => {
+ stdout += data.toString()
+ }
+ const onStderrData = (data: Buffer): void => {
+ stderr += data.toString()
+ }
+ // `exit` fires before `close`; capture the code so we can distinguish
+ // a failed `ls` that still produced `pwd` output from an empty listing.
+ const onExit = (code: number | null): void => {
+ exitCode = code
+ }
+ const onError = (error: Error): void => {
+ rejectOnce(error)
+ }
+ const onClose = (): void => {
+ // A null exitCode means the server closed the channel without
+ // sending an exit-status message (or signalled termination). We
+ // can't assume success — falling back to "empty stdout = empty
+ // directory" is exactly the bug the exit-code branch was added to
+ // fix. Treat any non-zero OR null exit as a failure when stderr
+ // has content, and otherwise require stdout to contain at least
+ // the resolved `pwd` line before accepting the result.
+ if (exitCode !== 0) {
+ const msg =
+ stderr.trim() ||
+ (exitCode === null
+ ? 'Remote listing failed (channel closed without exit status)'
+ : `Remote listing failed (exit ${exitCode})`)
+ rejectOnce(new RemoteBrowseError(msg, exitCode))
+ return
+ }
+ if (stderr.trim() && !stdout.trim()) {
+ rejectOnce(new Error(stderr.trim()))
+ return
+ }
+
+ // Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a
+ // trailing \r defeats the endsWith('/') dir check and leaves a stray CR
+ // in every name.
+ const lines = stdout.trim().split(/\r?\n/)
+ if (lines.length === 0) {
+ rejectOnce(new Error('Empty response from remote'))
+ return
+ }
+
+ const resolvedPath = lines[0]
+ const entries: RemoteDirEntry[] = []
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i]
+ if (!line || line === './' || line === '../') {
+ continue
+ }
+ if (line.endsWith('/')) {
+ entries.push({ name: line.slice(0, -1), isDirectory: true })
+ } else {
+ entries.push({ name: line, isDirectory: false })
+ }
+ }
+
+ // Sort: directories first, then alphabetical
+ entries.sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1
+ }
+ return a.name.localeCompare(b.name)
+ })
+
+ resolveOnce({ entries, resolvedPath })
+ }
+
+ channel.on('data', onStdoutData)
+ channel.stderr.on('data', onStderrData)
+ channel.on('exit', onExit)
+ channel.on('close', onClose)
+ // Why: SSH exec streams emit `error` on transport loss; without a
+ // scoped listener, a disappearing remote can become process-fatal.
+ channel.on('error', onError)
+ channel.stderr.on('error', onError)
+ timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
+ if (typeof timeout.unref === 'function') {
+ timeout.unref()
+ }
+ })
+}
+
+// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the
+// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX
+// failure, not the doomed retry, is the error worth surfacing.
+function isPosixCommandNotFound(error: unknown): boolean {
+ return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT
+}
+
// Why: prevent shell injection in the directory path. Single-quote wrapping
// with escaped internal single quotes is the safest approach for sh/bash.
// Tilde must be expanded by the shell, so paths starting with ~ use $HOME
@@ -199,3 +301,24 @@ function shellEscape(s: string): string {
function shellEscapeRaw(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
+
+function powerShellPathExpression(s: string): string {
+ if (s === '~') {
+ return '$HOME'
+ }
+ if (s.startsWith('~/') || s.startsWith('~\\')) {
+ return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}`
+ }
+ return powerShellLiteral(normalizeWindowsDrivePath(s))
+}
+
+// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them
+// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the
+// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter
+// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's
+// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both
+// back to a rooted drive path here so navigation lands where the user clicked.
+function normalizeWindowsDrivePath(s: string): string {
+ const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '')
+ return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped
+}
diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts
index ff676112218..0ac0c5e0db0 100644
--- a/src/main/ipc/ssh.ts
+++ b/src/main/ipc/ssh.ts
@@ -714,8 +714,8 @@ export function registerSshHandlers(
await removeRegisteredSshTarget(args.id)
})
- ipcMain.handle('ssh:importConfig', () => {
- return sshStore!.importFromSshConfig()
+ ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => {
+ return sshStore!.importFromSshConfig(args)
})
// ── Connection lifecycle ───────────────────────────────────────────
diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts
index c7ff8f45c82..8b966e2b537 100644
--- a/src/main/ipc/worktrees.ts
+++ b/src/main/ipc/worktrees.ts
@@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
}
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
+import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
import {
assertWorktreeDoesNotContainRegisteredWorktree,
canCleanupUnregisteredOrcaLeftoverDirectory,
@@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin
// drop process-local caches before the same ID can point at a new workspace.
store.removeWorktreeMeta(worktreeId)
advertisedUrlWatcher.forgetWorktree(worktreeId)
+ // Why: drop this worktree's localhost label routes so they don't accumulate
+ // in the proxy's route maps for the rest of the session.
+ localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
deleteWorktreeHistoryDir(worktreeId)
// Why: release the removed worktree's PR-refresh aliases so coalesced queue
// entries do not retain it for the rest of the session (memory creep).
diff --git a/src/main/localhost-worktree-label-proxy.test.ts b/src/main/localhost-worktree-label-proxy.test.ts
index 49a5e866ec1..7d499209847 100644
--- a/src/main/localhost-worktree-label-proxy.test.ts
+++ b/src/main/localhost-worktree-label-proxy.test.ts
@@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => {
expect(result.status).toBe(404)
})
+
+ it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => {
+ const port = await startUpstream((_request, response) => {
+ response.writeHead(200, { 'content-type': 'text/plain' })
+ response.end('ok')
+ })
+ const proxy = new LocalhostWorktreeLabelProxy()
+ const a = await proxy.registerRoute({
+ targetUrl: `http://localhost:${port}/`,
+ projectName: 'Snap Studio',
+ worktreeName: 'feature-a',
+ worktreeId: 'wt-a'
+ })
+ const b = await proxy.registerRoute({
+ targetUrl: `http://localhost:${port}/`,
+ projectName: 'Snap Studio',
+ worktreeName: 'feature-b',
+ worktreeId: 'wt-b'
+ })
+
+ expect((await fetchThroughProxy(a.url)).status).toBe(200)
+ expect((await fetchThroughProxy(b.url)).status).toBe(200)
+
+ proxy.unregisterWorktree('wt-a')
+
+ // The removed worktree's label no longer routes; the other worktree is unaffected.
+ expect((await fetchThroughProxy(a.url)).status).toBe(404)
+ expect((await fetchThroughProxy(b.url)).status).toBe(200)
+ })
})
diff --git a/src/main/localhost-worktree-label-proxy.ts b/src/main/localhost-worktree-label-proxy.ts
index b2f8152707b..acc1164d1ee 100644
--- a/src/main/localhost-worktree-label-proxy.ts
+++ b/src/main/localhost-worktree-label-proxy.ts
@@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy {
}
}
+ // Why: routes are added per (worktreeId, targetUrl) but were never removed, so
+ // labels for deleted worktrees accumulated in both maps for the whole session.
+ // Drop them when the worktree is torn down. The shared http.Server stays up.
+ unregisterWorktree(worktreeId: string): void {
+ for (const [label, route] of this.routes) {
+ if (route.worktreeId === worktreeId) {
+ this.routes.delete(label)
+ this.routeKeys.delete(route.routeKey)
+ }
+ }
+ }
+
private async ensureServer(): Promise {
if (this.server && this.listenPort !== null) {
return
diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts
index df5b310fcef..6f0fc39acb6 100644
--- a/src/main/native-chat/transcript-read-cache.test.ts
+++ b/src/main/native-chat/transcript-read-cache.test.ts
@@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => {
import { isTextBlock } from '../../shared/native-chat-types'
import {
clearNativeChatTranscriptCache,
- readNativeChatTranscriptCached
+ readNativeChatTranscriptCached,
+ setNativeChatTranscriptCacheMaxBytesForTests
} from './transcript-read-cache'
let tempRoots: string[] = []
@@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise {
return filePath
}
+// Writes a transcript file at an explicit path whose on-disk size is ~`bytes`
+// (padded via one big user message), returning the path. Read it back by passing
+// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME.
+async function seedBigFile(name: string, bytes: number): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-'))
+ tempRoots.push(root)
+ const filePath = join(root, `${name}.jsonl`)
+ const record = {
+ type: 'user',
+ uuid: `u-${name}`,
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) }
+ }
+ await writeFile(filePath, jsonLines([record]))
+ return filePath
+}
+
beforeEach(() => {
clearNativeChatTranscriptCache()
+ setNativeChatTranscriptCacheMaxBytesForTests()
readSpy.mockClear()
})
@@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => {
// Distinct files must not share a cached parse object.
expect(c).not.toBe(a)
})
+
+ // Why: each cached entry is a FULL unwindowed transcript parse; heavy agent
+ // sessions are tens of MB, so the count-only cap let 50 entries retain multiple
+ // GB in the one process serving desktop + every paired client. The byte budget
+ // evicts the oldest large entries while always keeping the most-recent.
+ it('evicts the oldest entry once total cached bytes exceed the budget', async () => {
+ // ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest.
+ setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024)
+ const fileA = await seedBigFile('big-a', 4 * 1024)
+ const fileB = await seedBigFile('big-b', 4 * 1024)
+ const fileC = await seedBigFile('big-c', 4 * 1024)
+
+ await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1
+ await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2
+ await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A
+ expect(readSpy).toHaveBeenCalledTimes(3)
+
+ // C is the most-recent → still cached (no re-read).
+ await readNativeChatTranscriptCached('claude', 'sc', fileC)
+ expect(readSpy).toHaveBeenCalledTimes(3)
+
+ // A was evicted by the byte cap → this re-reads (and evicts B in turn).
+ await readNativeChatTranscriptCached('claude', 'sa', fileA)
+ expect(readSpy).toHaveBeenCalledTimes(4)
+
+ // B is now evicted → also re-reads.
+ await readNativeChatTranscriptCached('claude', 'sb', fileB)
+ expect(readSpy).toHaveBeenCalledTimes(5)
+ })
+
+ it('keeps a single active transcript larger than the whole budget cached', async () => {
+ // A lone entry over budget must NOT be dropped, else every read re-parses.
+ setNativeChatTranscriptCacheMaxBytesForTests(1024)
+ const big = await seedBigFile('huge', 8 * 1024)
+ await readNativeChatTranscriptCached('claude', 'huge', big)
+ await readNativeChatTranscriptCached('claude', 'huge', big)
+ expect(readSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not evict small entries under the default budget (no regression for typical use)', async () => {
+ const files = await Promise.all([
+ seedBigFile('s1', 512),
+ seedBigFile('s2', 512),
+ seedBigFile('s3', 512)
+ ])
+ for (const [i, file] of files.entries()) {
+ await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
+ }
+ expect(readSpy).toHaveBeenCalledTimes(3)
+ // Re-read all three: every one is still cached (byte budget never triggered).
+ for (const [i, file] of files.entries()) {
+ await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
+ }
+ expect(readSpy).toHaveBeenCalledTimes(3)
+ })
})
diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts
index b62a1a1142a..9f4df5bde87 100644
--- a/src/main/native-chat/transcript-read-cache.ts
+++ b/src/main/native-chat/transcript-read-cache.ts
@@ -21,6 +21,9 @@ type CachedTranscript = {
result: ReadTranscriptResult
/** mtime of the resolved file when cached; a newer mtime invalidates it. */
mtimeMs: number
+ /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this
+ * entry's parsed memory footprint, used to bound the cache by total bytes. */
+ bytes: number
}
const cache = new Map()
@@ -28,19 +31,36 @@ const cache = new Map()
// Why: cap the cache so a long-lived process browsing many sessions can't grow
// it unbounded. Map preserves insertion order, so evicting the first key drops
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
-// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling
-// shows RSS pressure now that one process serves many remote clients.
const MAX_CACHE_ENTRIES = 50
+// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool
+// results embed whole file contents, command output, and diffs), and each cached
+// entry is the full unwindowed parse. The count cap alone let 50 such entries
+// retain multiple GB in the one process that now serves desktop + every paired
+// web/mobile client. Bound total cached file bytes too; we always keep the most-
+// recent entry (see setCached) so an active transcript is never re-parsed on
+// every read, which caps the regression to extra re-parses only past this budget.
+const MAX_CACHE_BYTES = 128 * 1024 * 1024
+// Overridable only from tests so the byte-eviction path can be exercised without
+// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES.
+let maxCacheBytes = MAX_CACHE_BYTES
function setCached(key: string, value: CachedTranscript): void {
// Re-insert moves the key to the most-recent position for LRU eviction.
cache.delete(key)
cache.set(key, value)
- while (cache.size > MAX_CACHE_ENTRIES) {
+ let totalBytes = 0
+ for (const entry of cache.values()) {
+ totalBytes += entry.bytes
+ }
+ // Evict oldest until within BOTH caps, but never drop the most-recent entry
+ // (cache.size > 1): a single active transcript larger than the whole budget
+ // must stay cached or every read would re-parse the full file.
+ while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
+ totalBytes -= cache.get(oldest)?.bytes ?? 0
cache.delete(oldest)
}
}
@@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string {
return `${agent}:${filePath}`
}
-async function fileMtimeMs(filePath: string): Promise {
+async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
try {
- return (await stat(filePath)).mtimeMs
+ const stats = await stat(filePath)
+ return { mtimeMs: stats.mtimeMs, bytes: stats.size }
} catch {
- return Number.NaN
+ return { mtimeMs: Number.NaN, bytes: 0 }
}
}
@@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached(
}
const key = cacheKey(agent, filePath)
- const mtimeMs = await fileMtimeMs(filePath)
+ const { mtimeMs, bytes } = await fileStat(filePath)
const cached = cache.get(key)
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
// Bump recency so a frequently-read session survives eviction.
@@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached(
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
if (Number.isFinite(mtimeMs)) {
- setCached(key, { result, mtimeMs })
+ setCached(key, { result, mtimeMs, bytes })
}
return result
}
@@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached(
export function clearNativeChatTranscriptCache(): void {
cache.clear()
}
+
+/** Test-only: override the byte budget (pass no arg to restore the default). */
+export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void {
+ maxCacheBytes = bytes ?? MAX_CACHE_BYTES
+}
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index 157ab31f011..5bad9510e65 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -3339,6 +3339,11 @@ export class Store {
defaults.workspaceSession
),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
+ deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
+ ? parsed.deletedSshConfigAliases.filter(
+ (alias): alias is string => typeof alias === 'string'
+ )
+ : [],
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
.map(normalizeSshRemotePtyLease)
.filter((lease): lease is SshRemotePtyLease => lease !== null),
@@ -5973,6 +5978,34 @@ export class Store {
this.scheduleSave()
}
+ getDeletedSshConfigAliases(): string[] {
+ return [...(this.state.deletedSshConfigAliases ?? [])]
+ }
+
+ addDeletedSshConfigAlias(alias: string): void {
+ this.state.deletedSshConfigAliases ??= []
+ if (!this.state.deletedSshConfigAliases.includes(alias)) {
+ this.state.deletedSshConfigAliases.push(alias)
+ this.scheduleSave()
+ }
+ }
+
+ removeDeletedSshConfigAlias(alias: string): void {
+ const current = this.state.deletedSshConfigAliases
+ if (!current || !current.includes(alias)) {
+ return
+ }
+ this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias)
+ this.scheduleSave()
+ }
+
+ clearDeletedSshConfigAliases(): void {
+ if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) {
+ this.state.deletedSshConfigAliases = []
+ this.scheduleSave()
+ }
+ }
+
// ── SSH Remote PTY Leases ──────────────────────────────────────────
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts
index 29daf5af532..b66b5633503 100644
--- a/src/main/providers/ssh-filesystem-provider-stream.test.ts
+++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts
@@ -80,7 +80,8 @@ describe('SshFilesystemProvider readFile streaming', () => {
const result = await provider.readFile('/home/user/file.txt')
expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', {
- filePath: '/home/user/file.txt'
+ filePath: '/home/user/file.txt',
+ flowControl: 'ack'
})
expect(result).toEqual({ content: text, isBinary: false })
})
diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts
index 59efa6abfa2..a5418500c1d 100644
--- a/src/main/runtime/runtime-rpc.ts
+++ b/src/main/runtime/runtime-rpc.ts
@@ -153,6 +153,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.openDiff',
'files.read',
'files.readChunk',
+ 'files.readDir',
'files.readPreview',
'files.readTerminalArtifact',
'files.readTerminalArtifactPreview',
diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts
index 8369c3c02cd..c4e9f089743 100644
--- a/src/main/ssh/relay-protocol.test.ts
+++ b/src/main/ssh/relay-protocol.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import {
HEADER_LENGTH,
MessageType,
@@ -160,6 +160,74 @@ describe('FrameDecoder', () => {
expect(frames).toHaveLength(1)
expect(frames[0].id).toBe(2)
})
+
+ it('decodes frames fed one byte at a time (worst-case boundary straddling)', () => {
+ const frames: DecodedFrame[] = []
+ const decoder = new FrameDecoder((f) => frames.push(f))
+
+ const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('first payload'))
+ const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('second'))
+ const combined = Buffer.concat([frame1, frame2])
+
+ for (let i = 0; i < combined.length; i += 1) {
+ decoder.feed(combined.subarray(i, i + 1))
+ }
+
+ expect(frames).toHaveLength(2)
+ expect(frames[0].payload.toString()).toBe('first payload')
+ expect(frames[1].id).toBe(2)
+ expect(frames[1].payload.toString()).toBe('second')
+ })
+
+ it('skips an oversized frame fed in odd-sized chunks and resynchronizes', () => {
+ const errors: Error[] = []
+ const frames: DecodedFrame[] = []
+ const decoder = new FrameDecoder(
+ (f) => frames.push(f),
+ (err) => errors.push(err)
+ )
+
+ const oversizedLength = 17 * 1024 * 1024
+ const header = Buffer.alloc(HEADER_LENGTH)
+ header[0] = MessageType.Regular
+ header.writeUInt32BE(1, 1)
+ header.writeUInt32BE(0, 5)
+ header.writeUInt32BE(oversizedLength, 9)
+ const oversized = Buffer.concat([header, Buffer.alloc(oversizedLength)])
+ const valid = encodeFrame(MessageType.Regular, 2, 0, Buffer.from('after'))
+ const combined = Buffer.concat([oversized, valid])
+
+ const chunkSize = 1024 * 1024 - 7
+ for (let i = 0; i < combined.length; i += chunkSize) {
+ decoder.feed(combined.subarray(i, i + chunkSize))
+ }
+
+ expect(errors).toHaveLength(1)
+ expect(frames).toHaveLength(1)
+ expect(frames[0].payload.toString()).toBe('after')
+ })
+
+ it('never rebuilds the buffered stream per feed while assembling a large frame', () => {
+ // Regression: feed() used Buffer.concat([buffered, chunk]) per data event,
+ // re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the
+ // Electron main thread while fs.streamChunk frames arrive (SSH typing lag).
+ const frames: DecodedFrame[] = []
+ const decoder = new FrameDecoder((f) => frames.push(f))
+ const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61))
+
+ const concatSpy = vi.spyOn(Buffer, 'concat')
+ try {
+ for (let i = 0; i < frame.length; i += 32 * 1024) {
+ decoder.feed(frame.subarray(i, i + 32 * 1024))
+ }
+ } finally {
+ concatSpy.mockRestore()
+ }
+
+ expect(frames).toHaveLength(1)
+ expect(frames[0].payload.length).toBe(512 * 1024)
+ expect(concatSpy).not.toHaveBeenCalled()
+ })
})
describe('parseJsonRpcMessage', () => {
diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts
index 3b8b63af159..db57c4d19aa 100644
--- a/src/main/ssh/relay-protocol.ts
+++ b/src/main/ssh/relay-protocol.ts
@@ -127,7 +127,13 @@ export type DecodedFrame = {
* Incremental frame parser. Feed it chunks of data; it emits complete frames.
*/
export class FrameDecoder {
- private buffer = Buffer.alloc(0)
+ // Why: feed() runs on the Electron main thread for every SSH channel data
+ // event. Rebuilding one contiguous buffer per feed (Buffer.concat) re-copies
+ // every already-buffered byte for each incoming ~32KB TCP chunk — O(n²) per
+ // large frame (a 340KB fs.streamChunk frame cost ~2MB of memcpy). A chunk
+ // list assembles each frame exactly once instead.
+ private chunks: Buffer[] = []
+ private bufferedLength = 0
private onFrame: (frame: DecodedFrame) => void
private onError: ((err: Error) => void) | null
@@ -137,21 +143,31 @@ export class FrameDecoder {
}
feed(chunk: Buffer | Uint8Array): void {
- this.buffer = Buffer.concat([this.buffer, chunk])
+ const buf = Buffer.isBuffer(chunk)
+ ? chunk
+ : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
+ if (buf.length > 0) {
+ this.chunks.push(buf)
+ this.bufferedLength += buf.length
+ }
- while (this.buffer.length >= HEADER_LENGTH) {
- const length = this.buffer.readUInt32BE(9)
+ while (this.bufferedLength >= HEADER_LENGTH) {
+ const header = this.peekBytes(HEADER_LENGTH)
+ const length = header.readUInt32BE(9)
const totalLength = HEADER_LENGTH + length
+ if (this.bufferedLength < totalLength) {
+ // Not fully received yet (also holds oversized frames until they can
+ // be skipped whole, keeping the decoder synchronized).
+ break
+ }
+
// Why: throwing here would leave the buffer in a partially consumed
// state — subsequent feed() calls would try to parse leftover payload
// bytes as a new header, corrupting every future frame. Instead we
// skip the entire oversized frame so the decoder stays synchronized.
if (length > MAX_MESSAGE_SIZE) {
- if (this.buffer.length < totalLength) {
- break
- }
- this.buffer = this.buffer.subarray(totalLength)
+ this.discardBytes(totalLength)
const err = new Error(`Frame payload too large: ${length} bytes — discarded`)
if (this.onError) {
this.onError(err)
@@ -159,24 +175,83 @@ export class FrameDecoder {
continue
}
- if (this.buffer.length < totalLength) {
- break
- }
-
+ const framed = this.takeBytes(totalLength)
const frame: DecodedFrame = {
- type: this.buffer[0],
- id: this.buffer.readUInt32BE(1),
- ack: this.buffer.readUInt32BE(5),
- payload: this.buffer.subarray(HEADER_LENGTH, totalLength)
+ type: framed[0],
+ id: framed.readUInt32BE(1),
+ ack: framed.readUInt32BE(5),
+ payload: framed.subarray(HEADER_LENGTH, totalLength)
}
-
- this.buffer = this.buffer.subarray(totalLength)
this.onFrame(frame)
}
}
reset(): void {
- this.buffer = Buffer.alloc(0)
+ this.chunks = []
+ this.bufferedLength = 0
+ }
+
+ /** View of the first `count` buffered bytes without consuming them. */
+ private peekBytes(count: number): Buffer {
+ const first = this.chunks[0]
+ if (first.length >= count) {
+ return first
+ }
+ const out = Buffer.allocUnsafe(count)
+ let copied = 0
+ for (const part of this.chunks) {
+ copied += part.copy(out, copied, 0, Math.min(part.length, count - copied))
+ if (copied >= count) {
+ break
+ }
+ }
+ return out
+ }
+
+ /** Consume and return the first `count` buffered bytes (single copy). */
+ private takeBytes(count: number): Buffer {
+ const first = this.chunks[0]
+ if (first.length === count) {
+ this.chunks.shift()
+ this.bufferedLength -= count
+ return first
+ }
+ if (first.length > count) {
+ this.chunks[0] = first.subarray(count)
+ this.bufferedLength -= count
+ return first.subarray(0, count)
+ }
+ const out = Buffer.allocUnsafe(count)
+ let copied = 0
+ while (copied < count) {
+ const part = this.chunks[0]
+ const take = Math.min(part.length, count - copied)
+ part.copy(out, copied, 0, take)
+ copied += take
+ if (take === part.length) {
+ this.chunks.shift()
+ } else {
+ this.chunks[0] = part.subarray(take)
+ }
+ }
+ this.bufferedLength -= count
+ return out
+ }
+
+ /** Consume the first `count` buffered bytes without assembling them. */
+ private discardBytes(count: number): void {
+ let remaining = count
+ while (remaining > 0) {
+ const part = this.chunks[0]
+ if (part.length <= remaining) {
+ this.chunks.shift()
+ remaining -= part.length
+ } else {
+ this.chunks[0] = part.subarray(remaining)
+ remaining = 0
+ }
+ }
+ this.bufferedLength -= count
}
}
diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts
index 8e3e347d709..f1bf80d17aa 100644
--- a/src/main/ssh/ssh-connection-store.test.ts
+++ b/src/main/ssh/ssh-connection-store.test.ts
@@ -14,6 +14,7 @@ vi.mock('./ssh-config-parser', () => ({
function createMockStore() {
const targets: SshTarget[] = []
+ let deletedAliases: string[] = []
return {
getSshTargets: vi.fn(() => [...targets]),
@@ -32,6 +33,18 @@ function createMockStore() {
if (idx !== -1) {
targets.splice(idx, 1)
}
+ }),
+ getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]),
+ addDeletedSshConfigAlias: vi.fn((alias: string) => {
+ if (!deletedAliases.includes(alias)) {
+ deletedAliases.push(alias)
+ }
+ }),
+ removeDeletedSshConfigAlias: vi.fn((alias: string) => {
+ deletedAliases = deletedAliases.filter((entry) => entry !== alias)
+ }),
+ clearDeletedSshConfigAliases: vi.fn(() => {
+ deletedAliases = []
})
}
}
@@ -324,4 +337,106 @@ describe('SshConnectionStore', () => {
expect(result).toEqual([])
})
})
+
+ describe('deleted config host tombstones', () => {
+ function candidate(overrides: Partial & { configHost: string }): SshTarget {
+ return {
+ id: `tmp-${overrides.configHost}`,
+ label: overrides.configHost,
+ host: `${overrides.configHost}.example.com`,
+ port: 22,
+ username: '',
+ ...overrides
+ }
+ }
+
+ // PRIMARY regression: deleting a config-sourced host must not let the next
+ // ~/.ssh/config sync resurrect it.
+ it('tombstones a deleted config-sourced host so re-import skips it', () => {
+ mockStore.addSshTarget({
+ id: 'ssh-1',
+ label: 'mini',
+ configHost: 'mini',
+ host: 'mini.example.com',
+ port: 22,
+ username: 'ping',
+ source: 'ssh-config'
+ })
+
+ sshStore.removeTarget('ssh-1')
+ expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
+
+ loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
+ sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
+
+ const result = sshStore.importFromSshConfig()
+
+ expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert
+ expect(result).toEqual([])
+ })
+
+ it('does not tombstone a manual target on delete', () => {
+ mockStore.addSshTarget({
+ id: 'ssh-1',
+ label: 'mini',
+ configHost: 'mini',
+ host: 'mini.example.com',
+ port: 22,
+ username: 'ping',
+ source: 'manual'
+ })
+
+ sshStore.removeTarget('ssh-1')
+ expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled()
+ })
+
+ it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => {
+ mockStore.addDeletedSshConfigAlias('mini')
+
+ sshStore.addTarget({
+ label: 'mini',
+ configHost: 'mini',
+ host: '10.0.0.2',
+ port: 22,
+ username: 'ping'
+ })
+ expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
+
+ loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
+ sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
+ // Alias reclaimed, but it is now a manual target — still not re-inserted.
+ const result = sshStore.importFromSshConfig()
+ expect(result).toEqual([])
+ })
+
+ it('editing a target reclaims its alias from the deleted set', () => {
+ mockStore.addSshTarget({
+ id: 'ssh-1',
+ label: 'mini',
+ configHost: 'mini',
+ host: 'mini.example.com',
+ port: 22,
+ username: 'ping',
+ source: 'ssh-config'
+ })
+ mockStore.addDeletedSshConfigAlias('mini')
+
+ sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' })
+ expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
+ })
+
+ it('reAdopt clears all tombstones and re-imports the deleted host', () => {
+ mockStore.addDeletedSshConfigAlias('mini')
+ loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
+ sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
+
+ const result = sshStore.importFromSshConfig({ reAdopt: true })
+
+ expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled()
+ expect(mockStore.addSshTarget).toHaveBeenCalledWith(
+ expect.objectContaining({ configHost: 'mini', source: 'ssh-config' })
+ )
+ expect(result).toHaveLength(1)
+ })
+ })
})
diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts
index d65bd42b9f0..975da8cdbc2 100644
--- a/src/main/ssh/ssh-connection-store.ts
+++ b/src/main/ssh/ssh-connection-store.ts
@@ -23,6 +23,9 @@ export class SshConnectionStore {
source: target.source ?? 'manual',
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
+ // Why: re-adding a host the user previously deleted is an explicit intent to
+ // keep it — lift any tombstone so config sync stops suppressing this alias.
+ this.reclaimAlias(full.configHost ?? full.label)
this.store.addSshTarget(full)
return full
}
@@ -51,19 +54,49 @@ export class SshConnectionStore {
}
updateTarget(id: string, updates: Partial>): SshTarget | null {
- return this.store.updateSshTarget(id, updates)
+ const updated = this.store.updateSshTarget(id, updates)
+ if (updated) {
+ // Why: actively editing a target reclaims its alias from the deleted set,
+ // so an edit can never leave the host tombstoned.
+ this.reclaimAlias(updated.configHost ?? updated.label)
+ }
+ return updated
}
removeTarget(id: string): void {
+ const target = this.store.getSshTarget(id)
+ // Why: deleting a config-managed target must record a tombstone; otherwise
+ // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still
+ // exists on disk) and the host reappears. Manual targets need no tombstone —
+ // sync never re-adds them.
+ if (target && isConfigManagedTarget(target)) {
+ const alias = target.configHost ?? target.label
+ if (alias) {
+ this.store.addDeletedSshConfigAlias(alias)
+ }
+ }
this.store.removeSshTarget(id)
}
+ private reclaimAlias(alias: string | undefined): void {
+ if (alias) {
+ this.store.removeDeletedSshConfigAlias(alias)
+ }
+ }
+
/**
* Sync targets from ~/.ssh/config: insert new hosts, update existing
* config-sourced ones in place (so a rotated port takes effect), never touch
* manual targets. Returns the inserted and updated targets.
*/
- importFromSshConfig(): SshTarget[] {
+ importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] {
+ // Why: the explicit Import action re-adopts every config host, so it clears
+ // all tombstones first. The passive on-open sync passes no flag and keeps
+ // deleted hosts suppressed.
+ if (options?.reAdopt) {
+ this.store.clearDeletedSshConfigAliases()
+ }
+ const deletedAliases = new Set(this.store.getDeletedSshConfigAliases())
const configHosts = loadUserSshConfig()
const existingTargets = this.store.getSshTargets()
// Map config-managed targets (and legacy targets that strongly look like
@@ -102,6 +135,11 @@ export class SshConnectionStore {
// A manual target owns this alias — never clobber it.
continue
}
+ if (deletedAliases.has(alias)) {
+ // The user deleted this config host — stay deleted until they re-add it
+ // or re-adopt config explicitly.
+ continue
+ }
if (processedAliases.has(alias)) {
continue
}
@@ -156,6 +194,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean {
return target.owner?.type === 'on-demand-runtime'
}
+function isConfigManagedTarget(target: SshTarget): boolean {
+ // Why: a target is subject to config sync (and therefore needs a tombstone on
+ // delete) when it is explicitly config-sourced, or a legacy import that sync
+ // still adopts. Manual targets are excluded — sync never re-adds them.
+ return (
+ target.source === 'ssh-config' ||
+ (target.source === undefined && isLegacyConfigImportTarget(target))
+ )
+}
+
function isLegacyConfigImportTarget(target: SshTarget): boolean {
const alias = target.configHost ?? target.label
// Why: legacy manual and imported targets both lack `source`. Only adopt the
diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts
index cda490c8580..2147ed0ef4f 100644
--- a/src/main/ssh/ssh-filesystem-stream-reader.ts
+++ b/src/main/ssh/ssh-filesystem-stream-reader.ts
@@ -148,6 +148,10 @@ export async function readFileViaStream(
expectedSeq += 1
receivedChunks += 1
bytesReceived += decoded.length
+ // Why: credit-based flow control — the relay caps unacked chunks so bulk
+ // stream frames cannot queue unbounded ahead of interactive pty.data
+ // frames on the shared SSH channel. Old relays ignore this notification.
+ mux.notify('fs.streamAck', { streamId: id, seq })
}
const handleEnd = (params: Record): void => {
@@ -260,7 +264,9 @@ export async function readFileViaStream(
unsubscribers.push(onDispose)
void mux
- .request('fs.readFileStream', { filePath })
+ // Why: flowControl declares this client acks each chunk, letting a new
+ // relay pace the pump. Old relays ignore the extra param and flood.
+ .request('fs.readFileStream', { filePath, flowControl: 'ack' })
.then((rawMetadata) => {
if (settled) {
return
diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts
index 31e26a5bc40..b49c627b8b3 100644
--- a/src/main/ssh/ssh-port-forward.test.ts
+++ b/src/main/ssh/ssh-port-forward.test.ts
@@ -267,6 +267,27 @@ describe('SshPortForwardManager', () => {
)
})
+ it('keeps only a bounded tail of a chatty forward stderr (memory-leak regression)', async () => {
+ const onForwardClosed = vi.fn()
+ manager.setCallbacks({ onForwardClosed })
+ const forward = createFakeSystemSshForward()
+ startSystemSshPortForwardProcessMock.mockReturnValue(forward)
+ const conn = createSystemSshConn()
+
+ await manager.addForward('conn-1', conn as never, 3000, '127.0.0.1', 8080)
+ // A long-lived forward against a chatty remote sshd: emit >64 KB of stderr.
+ forward.process.stderr.emit('data', Buffer.from(`HEAD_MARKER${'x'.repeat(70 * 1024)}`))
+ forward.process.stderr.emit('data', Buffer.from('TAIL_MARKER'))
+ forward.process.emit('exit', 255)
+
+ const detail = onForwardClosed.mock.calls[0]?.[1]?.detail as string
+ // The oldest bytes are trimmed; the most-recent tail is retained.
+ expect(detail).toContain('TAIL_MARKER')
+ expect(detail).not.toContain('HEAD_MARKER')
+ // Bounded well below the ~70 KB produced.
+ expect(detail.length).toBeLessThan(66 * 1024)
+ })
+
it('lists forwards filtered by connectionId', async () => {
const conn = createMockConn()
await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080)
diff --git a/src/main/ssh/system-ssh-port-forward-provider.ts b/src/main/ssh/system-ssh-port-forward-provider.ts
index 74227f67cfe..e2b53abab7b 100644
--- a/src/main/ssh/system-ssh-port-forward-provider.ts
+++ b/src/main/ssh/system-ssh-port-forward-provider.ts
@@ -33,9 +33,17 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider {
)
await forward.waitForStartup()
+ // Why: this stderr stays attached for the forward's whole lifetime but is
+ // only used to build the exit-error detail, so keep a bounded tail — a chatty
+ // remote sshd could otherwise grow it unbounded on a long-lived forward
+ // (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers).
+ const MAX_STDERR_TAIL_BYTES = 64 * 1024
let stderr = ''
const onStderr = (chunk: Buffer): void => {
stderr += chunk.toString('utf-8')
+ if (stderr.length > MAX_STDERR_TAIL_BYTES) {
+ stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES)
+ }
}
forward.process.stderr?.on('data', onStderr)
diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts
index 3f63d8930f0..c6c5e0e42c4 100644
--- a/src/main/system-fonts.test.ts
+++ b/src/main/system-fonts.test.ts
@@ -9,16 +9,56 @@ vi.mock('child_process', () => ({
execFile: execFileMock
}))
-function expectedFallbackFont(): string {
- if (process.platform === 'darwin') {
+function expectedFallbackFont(platform = process.platform): string {
+ if (platform === 'darwin') {
return 'SF Mono'
}
- if (process.platform === 'win32') {
+ if (platform === 'win32') {
return 'Cascadia Mono'
}
return 'JetBrains Mono'
}
+async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', { configurable: true, value: platform })
+ try {
+ return await fn()
+ } finally {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+}
+
+async function expectFontCommandTimeout(
+ platform: NodeJS.Platform,
+ timeoutMs: number
+): Promise {
+ await withPlatform(platform, async () => {
+ vi.useFakeTimers()
+ execFileMock.mockReturnValue({ kill: killMock })
+
+ const { listSystemFontFamilies } = await import('./system-fonts')
+ const fontsPromise = listSystemFontFamilies()
+ let resolvedFonts: string[] | null = null
+ fontsPromise.then((fonts) => {
+ resolvedFonts = fonts
+ })
+
+ await vi.advanceTimersByTimeAsync(timeoutMs - 1)
+
+ expect(resolvedFonts).toBeNull()
+ expect(killMock).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(1)
+
+ await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform))
+ expect(killMock).toHaveBeenCalledOnce()
+ })
+}
+
describe('listSystemFontFamilies', () => {
afterEach(() => {
vi.useRealTimers()
@@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => {
expect(resolvedFonts).toContain(expectedFallbackFont())
expect(killMock).toHaveBeenCalledOnce()
})
+
+ it('uses the longer timeout for macOS profiler scans', async () => {
+ await expectFontCommandTimeout('darwin', 45_000)
+ })
+
+ it.each([
+ ['linux' as NodeJS.Platform, 15_000],
+ ['win32' as NodeJS.Platform, 15_000]
+ ])('keeps the %s font command timeout short', async (platform, timeoutMs) => {
+ await expectFontCommandTimeout(platform, timeoutMs)
+ })
})
diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts
index afe142562d9..15ff9c28bde 100644
--- a/src/main/system-fonts.ts
+++ b/src/main/system-fonts.ts
@@ -3,6 +3,9 @@ import { execFile } from 'node:child_process'
let cachedFonts: string[] | null = null
let fontsPromise: Promise | null = null
const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000
+// Why: large macOS font catalogs can make system_profiler exceed 15s even
+// when it is healthy; keep the longer wait scoped to that slow command.
+const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000
export async function listSystemFontFamilies(): Promise {
if (cachedFonts) {
@@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise {
}
function listMacFonts(): Promise {
- return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then(
- (output) => {
- const parsed = JSON.parse(output) as {
- SPFontsDataType?: {
- typefaces?: {
- family?: string
- }[]
+ return execFileText(
+ 'system_profiler',
+ ['SPFontsDataType', '-json'],
+ 32 * 1024 * 1024,
+ MAC_SYSTEM_FONT_LIST_TIMEOUT_MS
+ ).then((output) => {
+ const parsed = JSON.parse(output) as {
+ SPFontsDataType?: {
+ typefaces?: {
+ family?: string
}[]
- }
-
- return uniqueSorted(
- (parsed.SPFontsDataType ?? []).flatMap((font) =>
- (font.typefaces ?? []).map((typeface) => typeface.family)
- )
- )
+ }[]
}
- )
+
+ return uniqueSorted(
+ (parsed.SPFontsDataType ?? []).flatMap((font) =>
+ (font.typefaces ?? []).map((typeface) => typeface.family)
+ )
+ )
+ })
}
function listLinuxFonts(): Promise {
@@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name }
)
}
-function execFileText(command: string, args: string[], maxBuffer: number): Promise {
+function execFileText(
+ command: string,
+ args: string[],
+ maxBuffer: number,
+ timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS
+): Promise {
return new Promise((resolve, reject) => {
let settled = false
let timer: ReturnType | undefined
@@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi
// should fall back instead of keeping settings IPC pending forever.
child.kill()
reject(new Error(`Timed out listing system fonts with ${command}`))
- }, SYSTEM_FONT_LIST_TIMEOUT_MS)
+ }, timeoutMs)
if (typeof timer === 'object' && 'unref' in timer) {
timer.unref()
}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 4201cfd351a..2e9f0c7272c 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -2879,7 +2879,7 @@ export type PreloadApi = {
updates: Partial>
}) => Promise
removeTarget: (args: { id: string }) => Promise
- importConfig: () => Promise
+ importConfig: (args?: { reAdopt?: boolean }) => Promise
connect: (args: { targetId: string }) => Promise
disconnect: (args: { targetId: string }) => Promise
terminateSessions: (args: { targetId: string }) => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 04b8217620f..d9272ec40f9 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -3947,7 +3947,8 @@ const api = {
removeTarget: (args: { id: string }): Promise =>
ipcRenderer.invoke('ssh:removeTarget', args),
- importConfig: (): Promise => ipcRenderer.invoke('ssh:importConfig'),
+ importConfig: (args?: { reAdopt?: boolean }): Promise =>
+ ipcRenderer.invoke('ssh:importConfig', args),
connect: (args: { targetId: string }): Promise =>
ipcRenderer.invoke('ssh:connect', args),
diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts
index a87e37c6078..5ab997c9d21 100644
--- a/src/relay/dispatcher.test.ts
+++ b/src/relay/dispatcher.test.ts
@@ -395,4 +395,119 @@ describe('RelayDispatcher', () => {
expect(listener).toHaveBeenCalledWith(1)
})
+
+ describe('notifyBulk (bulk lane backpressure)', () => {
+ it('resolves immediately when the sink accepts the frame', async () => {
+ const frames: Buffer[] = []
+ const bulkDispatcher = new RelayDispatcher((data) => {
+ frames.push(Buffer.from(data))
+ return true
+ })
+ try {
+ await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
+ expect(frames).toHaveLength(1)
+ const frame = decodeFirstFrame(frames[0])
+ const msg = JSON.parse(frame.payload.toString()) as JsonRpcNotification
+ expect(msg.method).toBe('bulk.event')
+ } finally {
+ bulkDispatcher.dispose()
+ }
+ })
+
+ it('holds the next bulk frame until the saturated sink drains', async () => {
+ const frames: Buffer[] = []
+ const drainWaiters = new Set<() => void>()
+ const bulkDispatcher = new RelayDispatcher(
+ (data) => {
+ frames.push(Buffer.from(data))
+ return false
+ },
+ { waitWriteDrain: (cb) => drainWaiters.add(cb) }
+ )
+ try {
+ let firstSettled = false
+ const first = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }).then(() => {
+ firstSettled = true
+ })
+ void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 })
+ await vi.advanceTimersByTimeAsync(0)
+
+ // First frame written, but its send has not settled and the second
+ // frame is not admitted while the sink stays saturated.
+ expect(frames).toHaveLength(1)
+ expect(firstSettled).toBe(false)
+
+ for (const cb of Array.from(drainWaiters)) {
+ drainWaiters.delete(cb)
+ cb()
+ }
+ await first
+ await vi.advanceTimersByTimeAsync(0)
+ expect(frames).toHaveLength(2)
+ } finally {
+ bulkDispatcher.dispose()
+ }
+ })
+
+ it('interactive notify() frames are not gated behind a stalled bulk lane', async () => {
+ const frames: Buffer[] = []
+ const bulkDispatcher = new RelayDispatcher(
+ (data) => {
+ frames.push(Buffer.from(data))
+ return false
+ },
+ { waitWriteDrain: () => {} }
+ )
+ try {
+ void bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
+ void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(frames).toHaveLength(1)
+
+ bulkDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' })
+ expect(frames).toHaveLength(2)
+ const msg = JSON.parse(
+ decodeFirstFrame(frames[1]).payload.toString()
+ ) as JsonRpcNotification
+ expect(msg.method).toBe('pty.data')
+ } finally {
+ bulkDispatcher.dispose()
+ }
+ })
+
+ it('releases a parked bulk send when the dispatcher is disposed', async () => {
+ const bulkDispatcher = new RelayDispatcher(() => false, { waitWriteDrain: () => {} })
+ const pending = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
+ await vi.advanceTimersByTimeAsync(0)
+ bulkDispatcher.dispose()
+ await expect(pending).resolves.toBeUndefined()
+ })
+
+ it('targets only the requested client and resolves for missing clients', async () => {
+ const primaryFrames: Buffer[] = []
+ const secondaryFrames: Buffer[] = []
+ const bulkDispatcher = new RelayDispatcher((data) => {
+ primaryFrames.push(Buffer.from(data))
+ return true
+ })
+ try {
+ const secondaryId = bulkDispatcher.attachClient((data) => {
+ secondaryFrames.push(Buffer.from(data))
+ return true
+ })
+
+ await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }, { clientId: secondaryId })
+ expect(primaryFrames).toHaveLength(0)
+ expect(secondaryFrames).toHaveLength(1)
+
+ await expect(
+ bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }, { clientId: 999 })
+ ).resolves.toBeUndefined()
+ expect(primaryFrames).toHaveLength(0)
+ expect(secondaryFrames).toHaveLength(1)
+ } finally {
+ bulkDispatcher.dispose()
+ }
+ })
+ })
})
diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts
index 41809a4d749..427d90e6ac0 100644
--- a/src/relay/dispatcher.ts
+++ b/src/relay/dispatcher.ts
@@ -27,10 +27,28 @@ export type MethodHandler = (
export type NotificationHandler = (params: Record, context: RequestContext) => void
+/** Sink write. Returning literal `false` signals saturation (Node stream
+ * semantics); `void`/`true` mean the frame was accepted. */
+export type RelayClientWrite = (data: Buffer) => boolean | void
+
+export type RelayClientSinkOptions = {
+ /** One-shot: invoke `cb` once when the sink can accept more data again
+ * (stream 'drain'), or when the sink is permanently dead (error/close) so
+ * bulk senders waiting on it never hang. */
+ waitWriteDrain?: (cb: () => void) => void
+}
+
type RelayClient = {
id: number
decoder: FrameDecoder
- write: (data: Buffer) => void
+ write: RelayClientWrite
+ waitWriteDrain?: (cb: () => void) => void
+ /** Pending resolvers for bulk sends stalled on sink saturation. Flushed on
+ * drain, write failure, detach, setWrite, and dispose so no pump hangs. */
+ drainWaiters: Set<() => void>
+ /** Serializes bulk-lane sends per client so at most one bulk frame is
+ * admitted past the sink's high-water mark at a time. */
+ bulkChain: Promise
nextOutgoingSeq: number
highestReceivedSeq: number
generation: number
@@ -58,8 +76,8 @@ export class RelayDispatcher {
private nextClientId = 1
private nextRequestId = 1
- constructor(write: (data: Buffer) => void) {
- this.primaryClient = this.createClient(write)
+ constructor(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions) {
+ this.primaryClient = this.createClient(write, sinkOptions)
this.clients.set(this.primaryClient.id, this.primaryClient)
this.startKeepalive()
}
@@ -75,10 +93,14 @@ export class RelayDispatcher {
// never acks the new client's frames until the new client's seq catches
// up - causing the client's unacked-timeout checker to accumulate stale
// timestamps that could eventually fire a false connection-dead signal.
- setWrite(write: (data: Buffer) => void): void {
+ setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void {
this.requestAborts.abortClient(this.primaryClient.id)
this.primaryClient.write = write
+ this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain
this.primaryClient.closed = false
+ // Why: the saturated sink the waiters were parked on no longer exists;
+ // wake stalled bulk senders so they re-evaluate against the new sink.
+ this.flushDrainWaiters(this.primaryClient)
this.resetClient(this.primaryClient)
}
@@ -89,14 +111,15 @@ export class RelayDispatcher {
this.requestAborts.abortClient(this.primaryClient.id)
this.primaryClient.generation++
this.primaryClient.closed = true
+ this.flushDrainWaiters(this.primaryClient)
this.notifyClientDetached(this.primaryClient.id)
}
// Why: synced remote workspaces can have more than one Orca client attached
// to the same relay. Frame sequence numbers and JSON-RPC request ids are per
// SSH channel, so each socket client needs independent protocol state.
- attachClient(write: (data: Buffer) => void): number {
- const client = this.createClient(write)
+ attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number {
+ const client = this.createClient(write, sinkOptions)
this.clients.set(client.id, client)
return client.id
}
@@ -109,6 +132,7 @@ export class RelayDispatcher {
this.requestAborts.abortClient(clientId)
client.generation++
client.closed = true
+ this.flushDrainWaiters(client)
this.clients.delete(clientId)
this.notifyClientDetached(clientId)
}
@@ -165,6 +189,90 @@ export class RelayDispatcher {
}
}
+ /**
+ * Bulk-lane notification. Sends are serialized per client and the returned
+ * promise resolves only after the sink accepted the frame without reporting
+ * saturation (or the client went away). Bulk producers (file streams) await
+ * this between frames so interactive frames (pty.data echo) never queue
+ * behind an unbounded backlog on the shared SSH channel.
+ *
+ * With `clientId`, the frame goes only to that client — stream chunks have
+ * exactly one consumer, and broadcasting them would let one slow secondary
+ * client stall everyone. A missing/closed target resolves immediately; the
+ * caller's staleness check owns aborting the stream.
+ */
+ notifyBulk(
+ method: string,
+ params?: Record,
+ opts?: { clientId?: number }
+ ): Promise {
+ if (this.disposed) {
+ return Promise.resolve()
+ }
+ const msg: JsonRpcNotification = {
+ jsonrpc: '2.0',
+ method,
+ ...(params !== undefined ? { params } : {})
+ }
+ const targets =
+ opts?.clientId !== undefined
+ ? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined)
+ : Array.from(this.clients.values())
+ const waits: Promise[] = []
+ for (const client of targets) {
+ if (client.closed) {
+ continue
+ }
+ // Why: the frame is encoded inside the chain step, not at call time —
+ // sequence numbers must be assigned in actual write order.
+ const step = client.bulkChain.then(() => {
+ if (this.disposed || client.closed) {
+ return
+ }
+ const accepted = this.sendFrame(client, msg)
+ if (accepted === false) {
+ return this.waitForClientDrain(client)
+ }
+ return undefined
+ })
+ client.bulkChain = step.catch(() => {})
+ waits.push(step)
+ }
+ if (waits.length === 0) {
+ return Promise.resolve()
+ }
+ return Promise.all(waits).then(() => {})
+ }
+
+ private waitForClientDrain(client: RelayClient): Promise {
+ if (this.disposed || client.closed || !client.waitWriteDrain) {
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ let settled = false
+ const finish = (): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ client.drainWaiters.delete(finish)
+ resolve()
+ }
+ client.drainWaiters.add(finish)
+ try {
+ client.waitWriteDrain!(finish)
+ } catch {
+ finish()
+ }
+ })
+ }
+
+ private flushDrainWaiters(client: RelayClient): void {
+ for (const waiter of Array.from(client.drainWaiters)) {
+ waiter()
+ }
+ }
+
requestPrimary(
method: string,
params?: Record,
@@ -236,14 +344,20 @@ export class RelayDispatcher {
// Why: dispose means this relay instance cannot send responses anymore;
// abort in-flight request work so stale SSH-side scans/watchers release.
this.requestAborts.abortAll()
+ for (const client of this.clients.values()) {
+ this.flushDrainWaiters(client)
+ }
}
- private createClient(write: (data: Buffer) => void): RelayClient {
+ private createClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): RelayClient {
const id = this.nextClientId++
const client: RelayClient = {
id,
decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)),
write,
+ waitWriteDrain: sinkOptions?.waitWriteDrain,
+ drainWaiters: new Set(),
+ bulkChain: Promise.resolve(),
nextOutgoingSeq: 1,
highestReceivedSeq: 0,
generation: 0,
@@ -387,13 +501,13 @@ export class RelayDispatcher {
private sendFrame(
client: RelayClient,
msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
- ): void {
+ ): boolean | void {
if (this.disposed || client.closed) {
return
}
const seq = client.nextOutgoingSeq++
const frame = encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq)
- this.writeFrame(client, frame)
+ return this.writeFrame(client, frame)
}
private startKeepalive(): void {
@@ -416,12 +530,13 @@ export class RelayDispatcher {
this.keepaliveTimer.unref()
}
- private writeFrame(client: RelayClient, frame: Buffer): void {
+ private writeFrame(client: RelayClient, frame: Buffer): boolean | void {
try {
- client.write(frame)
+ return client.write(frame)
} catch (err) {
client.closed = true
client.generation++
+ this.flushDrainWaiters(client)
if (client !== this.primaryClient) {
this.clients.delete(client.id)
this.notifyClientDetached(client.id)
diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts
index 45a0685b8ac..003172c08fd 100644
--- a/src/relay/fs-handler-file-read.ts
+++ b/src/relay/fs-handler-file-read.ts
@@ -1,7 +1,7 @@
import { open, readFile, stat } from 'node:fs/promises'
import { extname } from 'node:path'
import type { RelayDispatcher, RequestContext } from './dispatcher'
-import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol'
+import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol'
import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry'
import {
BINARY_PROBE_BYTES,
@@ -61,11 +61,21 @@ type StreamChunkReader = {
): Promise<{ bytesRead: number }>
}
+export type StreamPumpOptions = {
+ /** Client that requested the stream. Chunks go only to it — broadcasting
+ * bulk frames would let one slow secondary client stall the requester. */
+ clientId?: number
+ /** True when the client declared `flowControl: 'ack'` — it sends
+ * fs.streamAck per processed chunk and the pump caps unacked chunks. */
+ paceWithAcks: boolean
+}
+
export async function readRelayFileStreamMetadata(
filePath: string,
dispatcher: RelayDispatcher,
registry: RelayStreamRegistry,
- context: RequestContext
+ context: RequestContext,
+ pumpOptions?: StreamPumpOptions
): Promise {
const stats = await stat(filePath)
const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()]
@@ -106,8 +116,9 @@ export async function readRelayFileStreamMetadata(
// Why: pumpChunks owns its own try/finally for handle release; the outer
// setImmediate kicks the pump off the metadata-response task so the client
// sees the response before the first chunk frame.
+ const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false }
setImmediate(() => {
- void pumpChunks(streamId, stats.size, dispatcher, registry, context)
+ void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions)
})
return {
@@ -126,7 +137,8 @@ async function pumpChunks(
totalSize: number,
dispatcher: RelayDispatcher,
registry: RelayStreamRegistry,
- context: RequestContext
+ context: RequestContext,
+ pumpOptions: StreamPumpOptions
): Promise {
const entry = registry.get(streamId)
if (!entry) {
@@ -150,6 +162,27 @@ async function pumpChunks(
endReason = 'aborted'
break
}
+ // Why: credit window — bulk chunks share one ordered SSH channel with
+ // interactive pty.data frames. Waiting for client acks bounds how many
+ // stream bytes a keystroke echo can queue behind, and yields the relay
+ // event loop so incoming keystrokes are handled between chunks.
+ if (pumpOptions.paceWithAcks) {
+ while (
+ seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS &&
+ !context.isStale() &&
+ !registry.isAborted(streamId)
+ ) {
+ await registry.waitForAck(streamId)
+ }
+ if (context.isStale()) {
+ endReason = 'stale'
+ break
+ }
+ if (registry.isAborted(streamId)) {
+ endReason = 'aborted'
+ break
+ }
+ }
const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset)
const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset)
if (bytesRead !== want) {
@@ -167,7 +200,14 @@ async function pumpChunks(
break
}
const data = buffer.subarray(0, bytesRead).toString('base64')
- dispatcher.notify('fs.streamChunk', { streamId, seq, data })
+ // Why: the bulk lane waits out sink saturation, so a flood of chunk
+ // frames cannot pile up in the outbound pipe ahead of interactive
+ // pty.data frames written via plain notify().
+ await dispatcher.notifyBulk(
+ 'fs.streamChunk',
+ { streamId, seq, data },
+ pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined
+ )
offset += bytesRead
seq += 1
}
diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts
index d71dbf8c076..c7624afce65 100644
--- a/src/relay/fs-handler-stream.test.ts
+++ b/src/relay/fs-handler-stream.test.ts
@@ -34,6 +34,9 @@ function createMockDispatcher() {
notify: vi.fn((method: string, params?: Record) => {
notifications.push({ method, params })
}),
+ notifyBulk: vi.fn(async (method: string, params?: Record): Promise => {
+ notifications.push({ method, params })
+ }),
_notifications: notifications,
callRequest(
method: string,
@@ -278,6 +281,61 @@ describe('FsHandler readFileStream', () => {
expect(err).toBeNull()
})
+ it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => {
+ const filePath = path.join(tmpDir, 'paced.png')
+ const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks
+ writeFileSync(filePath, content)
+
+ const meta = (await dispatcher.callRequest(
+ 'fs.readFileStream',
+ { filePath, flowControl: 'ack' },
+ { isStale: () => false }
+ )) as { streamId: number }
+
+ // Window of 4 admits seqs 0..3; seq 4 must wait for an ack.
+ await waitFor(() => collectStream(dispatcher).chunks.length === 4)
+ await flush(10)
+ expect(collectStream(dispatcher).chunks.length).toBe(4)
+ expect(collectStream(dispatcher).end).toBeNull()
+
+ // Ack one chunk → exactly one more is admitted.
+ dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 })
+ await waitFor(() => collectStream(dispatcher).chunks.length === 5)
+ await flush(10)
+ expect(collectStream(dispatcher).chunks.length).toBe(5)
+
+ for (let seq = 1; seq < 6; seq += 1) {
+ dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq })
+ }
+ await waitFor(() => collectStream(dispatcher).end !== null)
+
+ const { chunks, err } = collectStream(dispatcher)
+ expect(err).toBeNull()
+ const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64')))
+ expect(reassembled.equals(content)).toBe(true)
+ })
+
+ it('releases a pump parked on the ack window when the stream is cancelled', async () => {
+ const filePath = path.join(tmpDir, 'parked-cancel.png')
+ writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks
+
+ const meta = (await dispatcher.callRequest(
+ 'fs.readFileStream',
+ { filePath, flowControl: 'ack' },
+ { isStale: () => false }
+ )) as { streamId: number }
+
+ await waitFor(() => collectStream(dispatcher).chunks.length === 4)
+ dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId })
+
+ const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry
+ await waitFor(() => registry.size() === 0)
+
+ const { end, err } = collectStream(dispatcher)
+ expect(end).toBeNull()
+ expect(err).toBeNull()
+ })
+
it('rejects the 17th concurrent stream with TooManyStreams', async () => {
const paths: string[] = []
for (let i = 0; i < 17; i++) {
diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts
index 0c4a263f13e..c6564c51fbe 100644
--- a/src/relay/fs-handler.ts
+++ b/src/relay/fs-handler.ts
@@ -86,7 +86,13 @@ export class FsHandler {
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
this.dispatcher = dispatcher
this.registerHandlers()
- this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
+ this.dispatcher.onClientDetached?.((clientId) => {
+ this.releaseClientWatches(clientId)
+ // Why: a detached client's fs.streamAck frames will never arrive; wake
+ // any pump parked on the ack window so it re-checks staleness and exits
+ // instead of stranding its open file handle.
+ this.streamRegistry.wakeAllAckWaiters()
+ })
}
private registerHandlers(): void {
@@ -113,6 +119,7 @@ export class FsHandler {
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
+ this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
}
private async readDir(params: Record) {
@@ -148,7 +155,13 @@ export class FsHandler {
private async readFileStream(params: Record, context?: RequestContext) {
const filePath = expandTilde(params.filePath as string)
const ctx = context ?? { clientId: 0, isStale: () => false }
- return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx)
+ return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, {
+ // Why: only target the requesting client when the dispatcher actually
+ // routed this request (context present) — direct-call tests and legacy
+ // paths keep broadcast semantics.
+ ...(context ? { clientId: context.clientId } : {}),
+ paceWithAcks: params.flowControl === 'ack'
+ })
}
private async tempDir(): Promise {
@@ -162,6 +175,14 @@ export class FsHandler {
}
}
+ private streamAck(params: Record): void {
+ const streamId = params.streamId as number | undefined
+ const seq = params.seq as number | undefined
+ if (typeof streamId === 'number' && typeof seq === 'number') {
+ this.streamRegistry.recordAck(streamId, seq)
+ }
+ }
+
private async writeFile(params: Record) {
const filePath = expandTilde(params.filePath as string)
const content = params.content as string
diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts
new file mode 100644
index 00000000000..8cca2787891
--- /dev/null
+++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts
@@ -0,0 +1,295 @@
+/**
+ * Regression: SSH typing latency under bulk file-stream load.
+ *
+ * The relay and the client share ONE ordered SSH channel. If the relay
+ * enqueues an entire file's fs.streamChunk frames into the outbound pipe
+ * at once, an interactive pty.data echo emitted mid-stream queues behind
+ * megabytes of bulk data and typing feels seconds-slow.
+ *
+ * These tests model the SSH channel as a congestible in-memory pipe and
+ * assert deterministic byte bounds instead of wall-clock latency:
+ * - with a saturated sink, the relay stalls the pump on write backpressure
+ * so at most ~1 chunk frame sits ahead of a pty echo;
+ * - with a fast sink but an unresponsive client, the fs.streamAck credit
+ * window bounds the in-flight backlog;
+ * - legacy clients that never ack still receive the full stream.
+ */
+import { describe, expect, it, beforeEach, afterEach } from 'vitest'
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { rm } from 'node:fs/promises'
+import * as path from 'node:path'
+import { tmpdir } from 'node:os'
+import { randomBytes } from 'node:crypto'
+
+import {
+ SshChannelMultiplexer,
+ type MultiplexerTransport
+} from '../main/ssh/ssh-channel-multiplexer'
+import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader'
+
+import { RelayDispatcher } from './dispatcher'
+import { RelayContext } from './context'
+import { FsHandler } from './fs-handler'
+import { STREAM_CHUNK_SIZE } from './protocol'
+
+// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header.
+const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512
+// Node pipe/socket sinks report saturation via write() === false past the HWM.
+const SINK_HIGH_WATER_MARK = 64 * 1024
+
+async function waitUntil(
+ predicate: () => boolean,
+ what: string,
+ timeoutMs = 10_000
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (!predicate()) {
+ if (Date.now() > deadline) {
+ throw new Error(`waitUntil timed out: ${what}`)
+ }
+ await new Promise((r) => setImmediate(r))
+ }
+}
+
+/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */
+async function waitUntilSettled(read: () => number, stableTurns = 25): Promise {
+ let last = read()
+ let stable = 0
+ while (stable < stableTurns) {
+ await new Promise((r) => setImmediate(r))
+ const current = read()
+ if (current === last) {
+ stable += 1
+ } else {
+ stable = 0
+ last = current
+ }
+ }
+}
+
+type Harness = {
+ mux: SshChannelMultiplexer
+ dispatcher: RelayDispatcher
+ fsHandler: FsHandler
+ queuedBytes: () => number
+ /** Deliver every queued relay→client buffer to the client mux. */
+ deliverAll: () => void
+ /** Start delivering continuously on each macrotask turn. */
+ startAutoDeliver: () => void
+ dispose: () => void
+}
+
+function createHarness(opts: { congested: boolean }): Harness {
+ let relayFeed: ((data: Buffer) => void) | null = null
+ const clientDataCallbacks: ((data: Buffer) => void)[] = []
+
+ const clientTransport: MultiplexerTransport = {
+ write: (data: Buffer) => {
+ // Client → relay: keystrokes and acks flow on the opposite direction of
+ // the duplex channel; they are not blocked by relay→client congestion.
+ setImmediate(() => relayFeed?.(data))
+ },
+ onData: (cb) => {
+ clientDataCallbacks.push(cb)
+ },
+ onClose: () => {}
+ }
+
+ const outQueue: Buffer[] = []
+ let queuedBytes = 0
+ const drainWaiters = new Set<() => void>()
+ const fireDrainIfIdle = (): void => {
+ if (queuedBytes > 0) {
+ return
+ }
+ for (const cb of Array.from(drainWaiters)) {
+ drainWaiters.delete(cb)
+ cb()
+ }
+ }
+
+ const dispatcher = new RelayDispatcher(
+ (data: Buffer) => {
+ outQueue.push(data)
+ queuedBytes += data.length
+ if (!opts.congested) {
+ return true
+ }
+ return queuedBytes < SINK_HIGH_WATER_MARK
+ },
+ {
+ waitWriteDrain: (cb: () => void) => {
+ drainWaiters.add(cb)
+ fireDrainIfIdle()
+ }
+ }
+ )
+ relayFeed = (data: Buffer) => dispatcher.feed(data)
+
+ const deliverAll = (): void => {
+ while (outQueue.length > 0) {
+ const buf = outQueue.shift()!
+ queuedBytes -= buf.length
+ for (const cb of clientDataCallbacks) {
+ cb(buf)
+ }
+ }
+ fireDrainIfIdle()
+ }
+
+ let autoDeliverTimer: ReturnType | null = null
+ const startAutoDeliver = (): void => {
+ if (autoDeliverTimer) {
+ return
+ }
+ autoDeliverTimer = setInterval(deliverAll, 1)
+ }
+
+ const context = new RelayContext()
+ const fsHandler = new FsHandler(dispatcher, context)
+ const mux = new SshChannelMultiplexer(clientTransport)
+
+ return {
+ mux,
+ dispatcher,
+ fsHandler,
+ queuedBytes: () => queuedBytes,
+ deliverAll,
+ startAutoDeliver,
+ dispose: () => {
+ if (autoDeliverTimer) {
+ clearInterval(autoDeliverTimer)
+ }
+ mux.dispose()
+ dispatcher.dispose()
+ fsHandler.dispose()
+ }
+ }
+}
+
+describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => {
+ let tmpDir: string
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-'))
+ })
+
+ afterEach(async () => {
+ await rm(tmpDir, { recursive: true, force: true })
+ })
+
+ it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => {
+ const harness = createHarness({ congested: true })
+ try {
+ const filePath = path.join(tmpDir, 'big.png')
+ const original = randomBytes(3 * 1024 * 1024) // 12 chunks
+ writeFileSync(filePath, original)
+
+ // Relay-side fake PTY: echoes input back immediately, mirroring
+ // PtyHandler's interactive fast path (dispatcher.notify on echo).
+ let queuedBytesAheadOfEcho = -1
+ harness.dispatcher.onNotification('pty.data', (params) => {
+ queuedBytesAheadOfEcho = harness.queuedBytes()
+ harness.dispatcher.notify('pty.data', { id: params.id, data: params.data })
+ })
+
+ const readPromise = readFileViaStream(harness.mux, filePath)
+ // Deliver until the client has the stream metadata, then congest fully.
+ await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued')
+ harness.deliverAll()
+ // Let the pump run to whatever bound it enforces (pre-fix: whole file).
+ await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued')
+ await waitUntilSettled(() => harness.queuedBytes())
+
+ // Type one key while the stream is congested.
+ harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' })
+ await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay')
+
+ // The echo must not sit behind an unbounded chunk backlog: at most one
+ // in-flight bulk frame (the write that saturated the sink) plus slack.
+ expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES)
+
+ // Un-congest: the stream must still complete with intact content.
+ harness.startAutoDeliver()
+ const result = await readPromise
+ expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true)
+ } finally {
+ harness.dispose()
+ }
+ }, 30_000)
+
+ it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => {
+ const harness = createHarness({ congested: false })
+ try {
+ const filePath = path.join(tmpDir, 'big.png')
+ writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks
+
+ const receivedSeqs: number[] = []
+ harness.mux.onNotificationByMethod('fs.streamChunk', (params) => {
+ receivedSeqs.push(params.seq as number)
+ })
+ let streamEnded = false
+ harness.mux.onNotificationByMethod('fs.streamEnd', () => {
+ streamEnded = true
+ })
+
+ harness.startAutoDeliver()
+ // Raw ack-capable request without sending any acks: models a client
+ // whose main thread is too busy to process chunks.
+ const metadata = (await harness.mux.request('fs.readFileStream', {
+ filePath,
+ flowControl: 'ack'
+ })) as { streamId: number }
+
+ await waitUntil(() => receivedSeqs.length > 0, 'first chunk received')
+ await waitUntilSettled(() => receivedSeqs.length)
+
+ // Without acks the relay must stop at the credit window, not flood
+ // the remaining chunks.
+ expect(receivedSeqs.length).toBeLessThanOrEqual(5)
+ expect(streamEnded).toBe(false)
+
+ // Acking releases the window and the stream completes.
+ const totalChunks = 12
+ for (let seq = 0; seq < totalChunks; seq += 1) {
+ harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq })
+ }
+ await waitUntil(() => streamEnded, 'stream completed after acks')
+ expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i))
+ } finally {
+ harness.dispose()
+ }
+ }, 30_000)
+
+ it('still delivers the full stream to legacy clients that never ack', async () => {
+ const harness = createHarness({ congested: false })
+ try {
+ const filePath = path.join(tmpDir, 'legacy.png')
+ const original = randomBytes(1024 * 1024 + 12345)
+ writeFileSync(filePath, original)
+
+ const chunks = new Map()
+ let streamEnded = false
+ harness.mux.onNotificationByMethod('fs.streamChunk', (params) => {
+ chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64'))
+ })
+ harness.mux.onNotificationByMethod('fs.streamEnd', () => {
+ streamEnded = true
+ })
+
+ harness.startAutoDeliver()
+ // Legacy request shape: no flowControl param, and no acks ever sent.
+ await harness.mux.request('fs.readFileStream', { filePath })
+ await waitUntil(() => streamEnded, 'legacy stream completed')
+
+ const reassembled = Buffer.concat(
+ Array.from(chunks.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([, buf]) => buf)
+ )
+ expect(reassembled.equals(original)).toBe(true)
+ } finally {
+ harness.dispose()
+ }
+ }, 30_000)
+})
diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts
index e0bf2a5ed6f..2bca76c9495 100644
--- a/src/relay/fs-stream-registry.ts
+++ b/src/relay/fs-stream-registry.ts
@@ -1,9 +1,14 @@
import type { FileHandle } from 'node:fs/promises'
-import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol'
+import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol'
type StreamEntry = {
handle: FileHandle
aborted: boolean
+ /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
+ ackedThroughSeq: number
+ /** Pumps parked on the ack credit window. Woken by acks, abort, release,
+ * and a periodic stall recheck so a vanished client cannot strand a pump. */
+ ackWaiters: Set<() => void>
}
export class TooManyStreamsError extends Error {
@@ -22,7 +27,12 @@ export class RelayStreamRegistry {
throw new TooManyStreamsError()
}
const streamId = this.nextId++
- this.streams.set(streamId, { handle, aborted: false })
+ this.streams.set(streamId, {
+ handle,
+ aborted: false,
+ ackedThroughSeq: -1,
+ ackWaiters: new Set()
+ })
return streamId
}
@@ -30,6 +40,7 @@ export class RelayStreamRegistry {
const entry = this.streams.get(streamId)
if (entry) {
entry.aborted = true
+ this.wakeAckWaiters(entry)
}
}
@@ -41,11 +52,65 @@ export class RelayStreamRegistry {
return this.streams.get(streamId)
}
+ recordAck(streamId: number, seq: number): void {
+ const entry = this.streams.get(streamId)
+ if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) {
+ return
+ }
+ if (seq > entry.ackedThroughSeq) {
+ entry.ackedThroughSeq = seq
+ }
+ this.wakeAckWaiters(entry)
+ }
+
+ ackedThroughSeq(streamId: number): number {
+ return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER
+ }
+
+ /** Resolves on the next ack/abort/release for this stream, or after the
+ * stall-recheck interval so callers can re-evaluate staleness. */
+ waitForAck(streamId: number): Promise {
+ const entry = this.streams.get(streamId)
+ if (!entry || entry.aborted) {
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ let settled = false
+ const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS)
+ timer.unref?.()
+ const finish = (): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timer)
+ entry.ackWaiters.delete(finish)
+ resolve()
+ }
+ entry.ackWaiters.add(finish)
+ })
+ }
+
+ /** Wake every parked pump (all streams) so it re-checks staleness — used
+ * when a client detaches and its acks will never arrive. */
+ wakeAllAckWaiters(): void {
+ for (const entry of this.streams.values()) {
+ this.wakeAckWaiters(entry)
+ }
+ }
+
+ private wakeAckWaiters(entry: StreamEntry): void {
+ for (const waiter of Array.from(entry.ackWaiters)) {
+ waiter()
+ }
+ }
+
async release(streamId: number): Promise {
const entry = this.streams.get(streamId)
if (!entry) {
return
}
+ this.wakeAckWaiters(entry)
this.streams.delete(streamId)
try {
await entry.handle.close()
diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts
index 66818efaf61..267f6ef480f 100644
--- a/src/relay/protocol.ts
+++ b/src/relay/protocol.ts
@@ -48,6 +48,17 @@ export const TIMEOUT_MS = 20_000
export const STREAM_CHUNK_SIZE = 256 * 1024
export const MAX_CONCURRENT_STREAMS = 16
+/** Max unacked fs.streamChunk frames in flight per stream when the client
+ * requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive
+ * pty.data frame can queue behind on the shared SSH channel (~1MB raw) while
+ * keeping the pipe full across one ack round-trip on fast links. */
+export const STREAM_ACK_WINDOW_CHUNKS = 4
+
+/** Safety-valve poll interval for a pump stalled on acks: re-checks stream
+ * abort/staleness so a client that vanished mid-stream cannot park the pump
+ * (and its open file handle) forever. */
+export const STREAM_ACK_STALL_RECHECK_MS = 1_000
+
export const RelayErrorCode = {
TooManyStreams: -33006,
StreamProtocolError: -33007
@@ -109,7 +120,12 @@ export function encodeKeepAliveFrame(id: number, ack: number): Buffer {
}
export class FrameDecoder {
- private buffer = Buffer.alloc(0)
+ // Why: feed() sits on the hot receive path. Rebuilding one contiguous
+ // buffer per feed (Buffer.concat) re-copies every already-buffered byte for
+ // each incoming chunk — O(n²) per large frame. A chunk list assembles each
+ // frame exactly once instead.
+ private chunks: Buffer[] = []
+ private bufferedLength = 0
private onFrame: (frame: DecodedFrame) => void
private onError: ((err: Error) => void) | null
@@ -119,23 +135,32 @@ export class FrameDecoder {
}
feed(chunk: Buffer | Uint8Array): void {
- this.buffer = Buffer.concat([this.buffer, chunk])
+ const buf = Buffer.isBuffer(chunk)
+ ? chunk
+ : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
+ if (buf.length > 0) {
+ this.chunks.push(buf)
+ this.bufferedLength += buf.length
+ }
- while (this.buffer.length >= HEADER_LENGTH) {
- const length = this.buffer.readUInt32BE(9)
+ while (this.bufferedLength >= HEADER_LENGTH) {
+ const header = this.peekBytes(HEADER_LENGTH)
+ const length = header.readUInt32BE(9)
const totalLength = HEADER_LENGTH + length
+ if (this.bufferedLength < totalLength) {
+ // Not fully received yet (also holds oversized frames until they can
+ // be skipped whole, keeping the decoder synchronized).
+ break
+ }
+
if (length > MAX_MESSAGE_SIZE) {
// Why: Throwing here would leave the buffer in a partially consumed
// state — subsequent feed() calls would try to parse the leftover
// payload bytes as a new header, corrupting every future frame.
// Instead we skip the entire oversized frame so the decoder stays
// synchronized with the stream.
- if (this.buffer.length < totalLength) {
- // Haven't received the full oversized payload yet; wait for more data.
- break
- }
- this.buffer = this.buffer.subarray(totalLength)
+ this.discardBytes(totalLength)
const err = new Error(`Frame payload too large: ${length} bytes — discarded`)
if (this.onError) {
this.onError(err)
@@ -145,23 +170,20 @@ export class FrameDecoder {
continue
}
- if (this.buffer.length < totalLength) {
- break
- }
-
+ const framed = this.takeBytes(totalLength)
const frame: DecodedFrame = {
- type: this.buffer[0],
- id: this.buffer.readUInt32BE(1),
- ack: this.buffer.readUInt32BE(5),
- payload: this.buffer.subarray(HEADER_LENGTH, totalLength)
+ type: framed[0],
+ id: framed.readUInt32BE(1),
+ ack: framed.readUInt32BE(5),
+ payload: framed.subarray(HEADER_LENGTH, totalLength)
}
- this.buffer = this.buffer.subarray(totalLength)
this.onFrame(frame)
}
}
reset(): void {
- this.buffer = Buffer.alloc(0)
+ this.chunks = []
+ this.bufferedLength = 0
}
// Why: at the handshake → dispatcher transition, the next consumer must
@@ -169,10 +191,74 @@ export class FrameDecoder {
// frame. This returns and clears the decoder's internal residue so the
// caller can hand it to the dispatcher (or stdout pipe) without loss.
drain(): Buffer {
- const out = this.buffer
- this.buffer = Buffer.alloc(0)
+ const out =
+ this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.bufferedLength)
+ this.reset()
return out
}
+
+ /** View of the first `count` buffered bytes without consuming them. */
+ private peekBytes(count: number): Buffer {
+ const first = this.chunks[0]
+ if (first.length >= count) {
+ return first
+ }
+ const out = Buffer.allocUnsafe(count)
+ let copied = 0
+ for (const part of this.chunks) {
+ copied += part.copy(out, copied, 0, Math.min(part.length, count - copied))
+ if (copied >= count) {
+ break
+ }
+ }
+ return out
+ }
+
+ /** Consume and return the first `count` buffered bytes (single copy). */
+ private takeBytes(count: number): Buffer {
+ const first = this.chunks[0]
+ if (first.length === count) {
+ this.chunks.shift()
+ this.bufferedLength -= count
+ return first
+ }
+ if (first.length > count) {
+ this.chunks[0] = first.subarray(count)
+ this.bufferedLength -= count
+ return first.subarray(0, count)
+ }
+ const out = Buffer.allocUnsafe(count)
+ let copied = 0
+ while (copied < count) {
+ const part = this.chunks[0]
+ const take = Math.min(part.length, count - copied)
+ part.copy(out, copied, 0, take)
+ copied += take
+ if (take === part.length) {
+ this.chunks.shift()
+ } else {
+ this.chunks[0] = part.subarray(take)
+ }
+ }
+ this.bufferedLength -= count
+ return out
+ }
+
+ /** Consume the first `count` buffered bytes without assembling them. */
+ private discardBytes(count: number): void {
+ let remaining = count
+ while (remaining > 0) {
+ const part = this.chunks[0]
+ if (part.length <= remaining) {
+ this.chunks.shift()
+ remaining -= part.length
+ } else {
+ this.chunks[0] = part.subarray(remaining)
+ remaining = 0
+ }
+ }
+ this.bufferedLength -= count
+ }
}
export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage {
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index aa35e99571a..7d5198e87a9 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -14,7 +14,10 @@ import {
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
mockPtyInstance: {
- pid: 12345,
+ // Why: attach now proves the backing pid is alive before replaying, so the
+ // default managed PTY must report a live pid. Reuse the test runner's own
+ // pid — always alive — so unrelated attach tests are not seen as dead.
+ pid: process.pid,
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
@@ -609,6 +612,71 @@ describe('PtyHandler', () => {
}
)
+ it('reports not-found and reaps a reattach whose backing shell is dead', async () => {
+ // Why: a lingering managed entry whose child died without an onExit would
+ // otherwise attach-succeed with an empty replay and strand the pane on a
+ // black shell. A provably-dead pid must surface as not-found so the SSH
+ // provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh.
+ let onExitCb: ((evt: { exitCode: number }) => void) | undefined
+ mockPtySpawn.mockReturnValue({
+ ...mockPtyInstance,
+ onData: vi.fn(),
+ onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
+ onExitCb = cb
+ })
+ })
+ const exits: { id: string; paneKey?: string }[] = []
+ handler.setExitListener((evt) => exits.push(evt))
+
+ await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dead:0' } })
+ expect(handler.activePtyCount).toBe(1)
+ expect(onExitCb).toBeDefined()
+
+ const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
+ try {
+ await expect(
+ dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
+ ).rejects.toThrow('PTY "pty-1" not found')
+ } finally {
+ aliveSpy.mockRestore()
+ }
+
+ // The stale entry is reaped: cache-eviction observers fire and the map slot
+ // is freed so a later attach also cleanly reports not-found.
+ expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-dead:0' }])
+ expect(handler.activePtyCount).toBe(0)
+ await expect(
+ dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
+ ).rejects.toThrow('PTY "pty-1" not found')
+ })
+
+ it('replays for a reattach whose backing shell is still alive', async () => {
+ // Guard the other direction: a live pid must never be reaped, so a quiet
+ // but live session still returns its buffered replay on reattach.
+ let dataCallback: ((data: string) => void) | undefined
+ mockPtySpawn.mockReturnValue({
+ ...mockPtyInstance,
+ onData: vi.fn((cb: (data: string) => void) => {
+ dataCallback = cb
+ }),
+ onExit: vi.fn()
+ })
+ await dispatcher.callRequest('pty.spawn', {})
+ dataCallback?.('prompt$ ')
+
+ const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
+ try {
+ const result = await dispatcher.callRequest('pty.attach', {
+ id: 'pty-1',
+ suppressReplayNotification: true
+ })
+ expect(result).toEqual({ replay: 'prompt$ ' })
+ } finally {
+ aliveSpy.mockRestore()
+ }
+ expect(handler.activePtyCount).toBe(1)
+ })
+
it('terminates spawned PTY when request becomes stale before response', async () => {
const killSpy = vi.fn()
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index 7e94eb9a74f..3a812415909 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -10,6 +10,7 @@ import {
resolveProcessCwd,
processHasChildren,
getForegroundProcessName,
+ isProcessAlive,
listShellProfiles
} from './pty-shell-utils'
import { getRelayShellLaunchConfig } from './pty-shell-launch'
@@ -692,6 +693,22 @@ export class PtyHandler {
throw new Error(`PTY "${id}" not found`)
}
+ // Why: a reattach can arrive for a relay PTY whose backing shell already
+ // died without node-pty delivering onExit (e.g. the child was reaped out of
+ // band while the SSH channel was down). The map entry lingers, so attach
+ // would otherwise "succeed" with an empty replay and strand the reattached
+ // pane on a black, unresponsive shell. Prove liveness here; if the pid is
+ // provably gone, reap the stale entry and report not-found so the caller
+ // drops the dead lease and spawns fresh — the same recovery path an expired
+ // grace window already takes.
+ if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) {
+ this.notifyExitListener(managed)
+ disposeManagedPty(managed)
+ this.ptys.delete(id)
+ this.clearPtyFlowState(id)
+ throw new Error(`PTY "${id}" not found`)
+ }
+
// Why: PTY ids are a per-relay-process counter (pty-1, pty-2, …). When the
// relay changes generation — an app update deploys a new content-hashed
// relay dir, or a grace-expired relay restarts — the counter resets, so an
diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts
index e2b7adcadd0..544e1509c12 100644
--- a/src/relay/pty-shell-utils.test.ts
+++ b/src/relay/pty-shell-utils.test.ts
@@ -11,6 +11,7 @@ vi.mock('child_process', () => ({
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot'
import {
getForegroundProcessName,
+ isProcessAlive,
resolveDefaultCwd,
resolveWindowsDefaultShell
} from './pty-shell-utils'
@@ -51,6 +52,40 @@ beforeEach(() => {
resetProcessTableSnapshotForTests()
})
+describe('isProcessAlive', () => {
+ it('reports the test runner process as alive', () => {
+ expect(isProcessAlive(process.pid)).toBe(true)
+ })
+
+ it('reports a process as dead ONLY on ESRCH', () => {
+ // Why: attach() reaps a lingering managed PTY based on this, so a false
+ // positive would kill a live remote shell. Only "no such process" counts.
+ const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
+ const err = new Error('no such process') as NodeJS.ErrnoException
+ err.code = 'ESRCH'
+ throw err
+ })
+ try {
+ expect(isProcessAlive(2147483646)).toBe(false)
+ } finally {
+ spy.mockRestore()
+ }
+ })
+
+ it('treats an unsignalable process (EPERM) as alive', () => {
+ const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
+ const err = new Error('operation not permitted') as NodeJS.ErrnoException
+ err.code = 'EPERM'
+ throw err
+ })
+ try {
+ expect(isProcessAlive(1)).toBe(true)
+ } finally {
+ spy.mockRestore()
+ }
+ })
+})
+
describe('resolveWindowsDefaultShell', () => {
it('uses an existing SHELL override when one is provided', () => {
expect(
diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts
index 6a9c4507372..87035c9510d 100644
--- a/src/relay/pty-shell-utils.ts
+++ b/src/relay/pty-shell-utils.ts
@@ -147,6 +147,19 @@ export async function processHasChildren(pid: number): Promise {
}
}
+// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
+// such process") proves the pid is gone; EPERM means it exists but is
+// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
+// a liveness check can only ever declare a *provably* dead process dead.
+export function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (err) {
+ return (err as NodeJS.ErrnoException).code !== 'ESRCH'
+ }
+}
+
function collectDescendants(
rows: ProcessTableRow[],
rootPid: number
diff --git a/src/relay/relay.ts b/src/relay/relay.ts
index c930a244b65..99225227903 100644
--- a/src/relay/relay.ts
+++ b/src/relay/relay.ts
@@ -373,15 +373,44 @@ async function main(): Promise {
// write to a dead pipe, silently failing or throwing EPIPE. When a
// socket client reconnects, setWrite swaps the callback to the socket.
let stdoutAlive = true
- const dispatcher = new RelayDispatcher((data) => {
- if (stdoutAlive) {
+ // Why: one-shot waiters parked by the dispatcher's bulk lane when stdout
+ // reports saturation (write() === false). Flushed on 'drain' and on every
+ // stdout-death path so a stalled file-stream pump never outlives the pipe
+ // it was waiting on.
+ const stdoutDrainWaiters = new Set<() => void>()
+ const flushStdoutDrainWaiters = (): void => {
+ for (const cb of Array.from(stdoutDrainWaiters)) {
+ stdoutDrainWaiters.delete(cb)
+ cb()
+ }
+ }
+ process.stdout.on('drain', flushStdoutDrainWaiters)
+ const dispatcher = new RelayDispatcher(
+ (data) => {
+ if (!stdoutAlive) {
+ return
+ }
try {
- process.stdout.write(data)
+ // Why: surface Node's backpressure signal to the dispatcher so bulk
+ // frames (fs.streamChunk) wait for drain instead of queueing megabytes
+ // ahead of interactive pty.data frames on the SSH channel.
+ return process.stdout.write(data)
} catch {
stdoutAlive = false
+ flushStdoutDrainWaiters()
+ return undefined
+ }
+ },
+ {
+ waitWriteDrain: (cb) => {
+ if (!stdoutAlive) {
+ cb()
+ return
+ }
+ stdoutDrainWaiters.add(cb)
}
}
- })
+ )
const context = new RelayContext()
@@ -680,11 +709,35 @@ async function main(): Promise {
)
cancelGrace('socket client accepted')
- const clientId = dispatcher.attachClient((data) => {
- if (!sock.destroyed) {
- sock.write(data)
+ // Why: same backpressure surface as the stdout sink — bulk frames wait
+ // for the socket to drain so they cannot bury interactive PTY frames.
+ const sockDrainWaiters = new Set<() => void>()
+ const flushSockDrainWaiters = (): void => {
+ for (const cb of Array.from(sockDrainWaiters)) {
+ sockDrainWaiters.delete(cb)
+ cb()
}
- })
+ }
+ sock.on('drain', flushSockDrainWaiters)
+ sock.on('close', flushSockDrainWaiters)
+ sock.on('error', flushSockDrainWaiters)
+ const clientId = dispatcher.attachClient(
+ (data) => {
+ if (!sock.destroyed) {
+ return sock.write(data)
+ }
+ return undefined
+ },
+ {
+ waitWriteDrain: (cb) => {
+ if (sock.destroyed) {
+ cb()
+ return
+ }
+ sockDrainWaiters.add(cb)
+ }
+ }
+ )
socketClients.set(sock, clientId)
// Why: bytes that arrived in the same TCP send as the handshake frame
@@ -889,6 +942,7 @@ async function main(): Promise {
// before the grace period starts.
process.stdout.on('error', () => {
stdoutAlive = false
+ flushStdoutDrainWaiters()
dispatcher.invalidateClient()
})
@@ -934,6 +988,7 @@ async function main(): Promise {
// dead so the primary client write callback becomes a no-op while
// socket clients, if any, keep their own live transports.
stdoutAlive = false
+ flushStdoutDrainWaiters()
dispatcher.invalidateClient()
if (socketClients.size === 0) {
startGrace('stdin ended')
@@ -942,6 +997,7 @@ async function main(): Promise {
process.stdin.on('error', () => {
stdoutAlive = false
+ flushStdoutDrainWaiters()
dispatcher.invalidateClient()
if (socketClients.size === 0) {
startGrace('stdin error')
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 08f39a8b61c..b77b58a5c1a 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -101,6 +101,8 @@
--color-status-success: var(--status-success);
--color-status-success-background: var(--status-success-background);
--color-status-success-border: var(--status-success-border);
+ --color-tab-group-split-divider: var(--tab-group-split-divider);
+ --color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong);
--color-git-graph-ref: var(--git-graph-ref);
--color-git-graph-remote-ref: var(--git-graph-remote-ref);
--color-git-graph-base-ref: var(--git-graph-base-ref);
@@ -202,6 +204,11 @@
--git-graph-lane-4: #40b0a6;
--git-graph-lane-5: #b66dff;
--annotation-highlight: #f59e0b;
+ /* Why: tab-group splits use a dedicated divider — `--border` is too faint on
+ card/editor surfaces when panes are side-by-side or stacked. Default must
+ meet >=3:1 against `--card`, not only the hover/drag strong state. */
+ --tab-group-split-divider: #868690;
+ --tab-group-split-divider-strong: #71717a;
}
/* ── Dark Mode ───────────────────────────────────────── */
@@ -285,6 +292,9 @@
--git-graph-lane-4: #40b0a6;
--git-graph-lane-5: #b66dff;
--annotation-highlight: #fbbf24;
+ /* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
+ --tab-group-split-divider: #71717a;
+ --tab-group-split-divider-strong: #a1a1aa;
}
.linear-priority-bars {
@@ -618,6 +628,52 @@
overflow: hidden;
}
+/* Tab-group split resize handle — wide hit area, visible center line. */
+.tab-group-split-resize-handle {
+ flex-shrink: 0;
+ position: relative;
+ background: transparent;
+}
+
+.tab-group-split-resize-handle.is-vertical {
+ width: 6px;
+ cursor: col-resize;
+}
+
+.tab-group-split-resize-handle.is-horizontal {
+ height: 6px;
+ cursor: row-resize;
+}
+
+.tab-group-split-resize-handle::after {
+ content: '';
+ position: absolute;
+ border-radius: 1px;
+ background: var(--tab-group-split-divider);
+ transition: background 100ms ease;
+}
+
+.tab-group-split-resize-handle.is-vertical::after {
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 3px;
+ transform: translateX(-50%);
+}
+
+.tab-group-split-resize-handle.is-horizontal::after {
+ left: 0;
+ right: 0;
+ top: 50%;
+ height: 3px;
+ transform: translateY(-50%);
+}
+
+.tab-group-split-resize-handle:hover::after,
+.tab-group-split-resize-handle.is-dragging::after {
+ background: var(--tab-group-split-divider-strong);
+}
+
/* ── Titlebar ────────────────────────────────────────── */
/* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the
@@ -1904,13 +1960,14 @@
text-underline-offset: 2px;
}
-/* Why: Monaco owns Peek References scrollables and auto-hides faded needed
- vertical thumbs while the widget stays open; revalidate these classes on upgrade. */
+/* Why: Monaco's faded scrollbar uses the class `invisible`, which collides with
+ Tailwind's `.invisible` utility (visibility: hidden) — so also restore visibility. */
.monaco-editor
.reference-zone-widget
.monaco-scrollable-element
> .scrollbar.vertical.invisible.fade {
opacity: 1;
+ visibility: visible;
pointer-events: auto;
}
diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx
index fa644f79df5..960703e8759 100644
--- a/src/renderer/src/components/CodexRestartChip.tsx
+++ b/src/renderer/src/components/CodexRestartChip.tsx
@@ -1,7 +1,9 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
+import { useShallow } from 'zustand/react/shallow'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '../store'
+import { selectCodexRestartInputs } from './codex-restart-chip-inputs'
import { translate } from '@/i18n/i18n'
import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus'
import {
@@ -73,8 +75,14 @@ export default function CodexRestartChip({
worktreeId: string
}): React.JSX.Element | null {
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
- const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
- const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
+ // Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId
+ // on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on
+ // pty teardown), so subscribe to them only while a restart notice actually
+ // exists. Otherwise this per-worktree chip re-rendered on every pty event to
+ // compute "no notice → render nothing". See codex-restart-chip-inputs.
+ const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore(
+ useShallow(selectCodexRestartInputs)
+ )
const staleWorktreePtyIds = useMemo(
() =>
collectStalePtyIdsForTabs({
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index 863928204b0..5d45f1f78ea 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -3848,7 +3848,21 @@ function PRActionsPanel({
const actionItem = { ...item, state: localState }
const mergePresentation = presentGitHubPRMergeState(actionItem)
const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings)
- const sourceSettings = getTaskSourceRuntimeSettings(sourceContext)
+ const repoOwnerSettings = useAppStore(
+ useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null))
+ )
+ const sourceSettings = useMemo(() => {
+ if (sourceContext?.provider !== 'github') {
+ return repoOwnerSettings
+ }
+ const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)
+ return sourceRuntimeSettings.activeRuntimeEnvironmentId
+ ? ({
+ ...repoOwnerSettings,
+ ...sourceRuntimeSettings
+ } as typeof repoOwnerSettings)
+ : repoOwnerSettings
+ }, [repoOwnerSettings, sourceContext])
const mergeTarget = getActiveRuntimeTarget(sourceSettings)
const canMutateWithRepoContext =
!!repoPath || !!projectOrigin || mergeTarget.kind === 'environment'
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index 1bb4108e8b1..a88abafdae7 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -57,7 +57,7 @@ import {
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
-import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
+import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair'
import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility'
import {
getEffectiveLayoutForWorktree as getEffectiveLayout,
@@ -252,6 +252,7 @@ function Terminal(): React.JSX.Element | null {
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
const activeTabId = useAppStore((s) => s.activeTabId)
+ const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
const setActiveTab = useAppStore((s) => s.setActiveTab)
@@ -720,19 +721,38 @@ function Terminal(): React.JSX.Element | null {
}, [queueEditorCloseRequests])
useEffect(() => {
- if (!shouldRepairActiveTerminalTab({ activeTabType, activeTabId, tabs })) {
+ const rememberedTabId = renderedActiveWorktreeId
+ ? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null)
+ : null
+ // Why: prefer the worktree's remembered active tab over the first tab so a
+ // repair firing on a transient worktree-switch render restores the tab the
+ // user left on instead of permanently resetting the selection to Terminal 1.
+ const repairedTabId = resolveRepairedActiveTerminalTabId({
+ activeTabType,
+ activeTabId,
+ rememberedTabId,
+ tabs
+ })
+ if (!repairedTabId) {
return
}
// Why: mutating Zustand during render trips React's "Cannot update a
// component while rendering a different component" warning. Keep the repair
// terminal-only so inactive CLI-created tabs cannot steal editor/browser focus.
- setActiveTab(tabs[0].id)
+ setActiveTab(repairedTabId)
// Why: `tabs` is intentionally the dependency here because the repair must
// react to tab-order/content changes, not just scalar IDs. The list comes
// from Zustand selectors and is small in practice, so this explicit repair
// effect is preferred over duplicating reconciliation state.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [activeTabId, activeTabType, setActiveTab, tabs])
+ }, [
+ activeTabId,
+ activeTabType,
+ setActiveTab,
+ tabs,
+ activeTabIdByWorktree,
+ renderedActiveWorktreeId
+ ])
// Track which worktrees have been activated during this app session.
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
diff --git a/src/renderer/src/components/codex-restart-chip-inputs.test.ts b/src/renderer/src/components/codex-restart-chip-inputs.test.ts
new file mode 100644
index 00000000000..ad6412efd16
--- /dev/null
+++ b/src/renderer/src/components/codex-restart-chip-inputs.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { shallow } from 'zustand/shallow'
+import {
+ EMPTY_CODEX_RESTART_INPUTS,
+ selectCodexRestartInputs,
+ type CodexRestartInputsState
+} from './codex-restart-chip-inputs'
+
+describe('selectCodexRestartInputs', () => {
+ it('returns the frozen empty bundle while no restart notice exists', () => {
+ const state: CodexRestartInputsState = {
+ ptyIdsByTabId: { 'tab-1': ['pty-1'] },
+ codexRestartNoticeByPtyId: {}
+ }
+ expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS)
+
+ // Churning EITHER map while no notice exists must NOT change the selected
+ // reference, so a useShallow subscription skips the re-render. This covers
+ // the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty.
+ const churnedPty: CodexRestartInputsState = {
+ ...state,
+ ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] }
+ }
+ const churnedNotice: CodexRestartInputsState = {
+ ptyIdsByTabId: state.ptyIdsByTabId,
+ codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread
+ }
+ expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS)
+ expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS)
+ expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe(
+ true
+ )
+ })
+
+ it('exposes both live maps the instant a restart notice exists', () => {
+ const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
+ const codexRestartNoticeByPtyId = { 'pty-1': {} as never }
+ const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId }
+ const selected = selectCodexRestartInputs(state)
+ // Live references pass straight through so the stale-pty memo + notice lookup derive fully.
+ expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId)
+ expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId)
+ expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
+ })
+
+ it('shallow-changes only when a live map reference changes while a notice exists', () => {
+ const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
+ const s1: CodexRestartInputsState = {
+ ptyIdsByTabId,
+ codexRestartNoticeByPtyId: { 'pty-1': {} as never }
+ }
+ const r1 = selectCodexRestartInputs(s1)
+ expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true)
+
+ const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } }
+ expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/codex-restart-chip-inputs.ts b/src/renderer/src/components/codex-restart-chip-inputs.ts
new file mode 100644
index 00000000000..eec46f6a8e7
--- /dev/null
+++ b/src/renderer/src/components/codex-restart-chip-inputs.ts
@@ -0,0 +1,37 @@
+import type { AppState } from '@/store/types'
+
+export type CodexRestartInputsState = Pick
+
+export type CodexRestartInputs = CodexRestartInputsState
+
+// Why: shared frozen bundle returned while no Codex restart notice exists — the
+// overwhelmingly common case. CodexRestartChip is mounted once per worktree
+// Terminal (visible AND hidden-measurable) and once per split group, and reads
+// these two maps only to find panes carrying a restart notice, which appear only
+// on a Codex account switch. BOTH churn on unrelated pty lifecycle:
+// ptyIdsByTabId gets a fresh identity on every pty register/attach/detach, and
+// codexRestartNoticeByPtyId is re-spread into a new object even when empty on the
+// pty exit/teardown path (terminals.ts clearTabPtyId). Subscribing to either
+// re-rendered every mounted chip on that churn. Gate both behind notice-existence
+// so idle chips keep the same reference and stop reacting; behavior is identical
+// (no notice -> [] stale ids -> null render). Frozen so the singleton can't be
+// mutated.
+export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({
+ ptyIdsByTabId: {},
+ codexRestartNoticeByPtyId: {}
+})
+
+/**
+ * Expose the pty and restart-notice maps to the chip only while at least one
+ * Codex restart notice is live; otherwise return a stable frozen bundle so a
+ * `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn.
+ */
+export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs {
+ if (Object.keys(s.codexRestartNoticeByPtyId).length === 0) {
+ return EMPTY_CODEX_RESTART_INPUTS
+ }
+ return {
+ ptyIdsByTabId: s.ptyIdsByTabId,
+ codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId
+ }
+}
diff --git a/src/renderer/src/components/github-item-dialog-merge-routing.test.ts b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts
new file mode 100644
index 00000000000..8374fe94ba9
--- /dev/null
+++ b/src/renderer/src/components/github-item-dialog-merge-routing.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getSettingsForRepoRuntimeOwner,
+ type RepoRuntimeOwnerState
+} from '../lib/repo-runtime-owner'
+import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client'
+import type { TaskSourceContext } from '../../../shared/task-source-context'
+import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context'
+
+// Why: mirrors the merge-routing decision inside GitHubItemDialog's
+// PRActionsPanel so a revert to source-only routing (issue #6957) fails here.
+// A runtime-owned repo whose GitHub source view is local must still merge on
+// the owner runtime, not fall back to local `gh:mergePR`.
+function resolveMergeTarget(
+ state: RepoRuntimeOwnerState,
+ repoId: string | null,
+ sourceContext: TaskSourceContext | null
+): ReturnType {
+ const repoOwnerSettings = getSettingsForRepoRuntimeOwner(state, repoId)
+ const sourceSettings =
+ sourceContext?.provider !== 'github'
+ ? repoOwnerSettings
+ : (() => {
+ const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)
+ return sourceRuntimeSettings.activeRuntimeEnvironmentId
+ ? { ...repoOwnerSettings, ...sourceRuntimeSettings }
+ : repoOwnerSettings
+ })()
+ return getActiveRuntimeTarget(sourceSettings)
+}
+
+function githubSource(hostId: TaskSourceContext['hostId']): TaskSourceContext {
+ return { kind: 'task-source', provider: 'github', projectId: 'p', hostId, repoId: 'repo-1' }
+}
+
+describe('GitHubItemDialog PR merge routing', () => {
+ const runtimeOwnedRepo: RepoRuntimeOwnerState = {
+ settings: { activeRuntimeEnvironmentId: null },
+ repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }]
+ }
+
+ it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => {
+ expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('local'))).toEqual({
+ kind: 'environment',
+ environmentId: 'owner-runtime'
+ })
+ })
+
+ it('routes a runtime-owned repo to its owner runtime when there is no source context', () => {
+ expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({
+ kind: 'environment',
+ environmentId: 'owner-runtime'
+ })
+ })
+
+ it('lets a runtime source override the repo owner when both are runtimes', () => {
+ expect(
+ resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('runtime:source-runtime'))
+ ).toEqual({ kind: 'environment', environmentId: 'source-runtime' })
+ })
+
+ it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => {
+ const localRepo: RepoRuntimeOwnerState = {
+ settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
+ repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }]
+ }
+ expect(resolveMergeTarget(localRepo, 'repo-1', githubSource('local'))).toEqual({
+ kind: 'local'
+ })
+ })
+})
diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts
index deb287ca2d3..740d65f67cf 100644
--- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts
+++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts
@@ -91,8 +91,8 @@ describe('GitHubItemDialog source host boundaries', () => {
expect(source).toContain('sourceContext,')
expect(cacheKeySection).toContain('sourceCacheScope')
expect(source).toContain('getTaskSourceCacheScope(sourceContext)')
- expect(matchInvalidationSection).toContain(
- 'if (removed) {\n workItemDetailsCacheGeneration += 1'
+ expect(matchInvalidationSection).toMatch(
+ /if \(removed\) {\s+workItemDetailsCacheGeneration \+= 1/
)
})
@@ -190,7 +190,13 @@ describe('GitHubItemDialog source host boundaries', () => {
'function CommentReactions'
)
- expect(actionsSection).toContain('getTaskSourceRuntimeSettings(sourceContext)')
+ expect(actionsSection).toContain('getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId')
+ expect(actionsSection).toContain('...repoOwnerSettings')
+ expect(actionsSection).toContain(
+ 'const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)'
+ )
+ expect(actionsSection).toContain('sourceRuntimeSettings.activeRuntimeEnvironmentId')
+ expect(actionsSection).toContain('...sourceRuntimeSettings')
expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)')
expect(actionsSection).toContain(
'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ==='
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts
new file mode 100644
index 00000000000..07867e8a5c4
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest'
+import {
+ NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX,
+ setBoundedScopeCacheEntry
+} from './native-chat-composer-scope-cache'
+
+describe('setBoundedScopeCacheEntry', () => {
+ it('bounds the cache with LRU eviction, keeping re-set keys', () => {
+ const cache = new Map()
+ setBoundedScopeCacheEntry(cache, 'keep', 1)
+
+ const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 20
+ for (let i = 0; i < total; i += 1) {
+ setBoundedScopeCacheEntry(cache, `scope-${i}`, i)
+ if (i % 10 === 0) {
+ setBoundedScopeCacheEntry(cache, 'keep', 1)
+ }
+ }
+
+ expect(cache.size).toBe(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX)
+ expect(cache.has('scope-0')).toBe(false) // oldest untouched entry evicted
+ expect(cache.has('keep')).toBe(true) // periodically re-set → retained
+ expect(cache.has(`scope-${total - 1}`)).toBe(true) // most recent retained
+ })
+
+ it('moves a re-set key to most-recent and updates its value', () => {
+ const cache = new Map()
+ setBoundedScopeCacheEntry(cache, 'a', 1)
+ setBoundedScopeCacheEntry(cache, 'b', 2)
+ setBoundedScopeCacheEntry(cache, 'a', 3)
+
+ expect([...cache.keys()]).toEqual(['b', 'a'])
+ expect(cache.get('a')).toBe(3)
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts
new file mode 100644
index 00000000000..7a63f16f9fe
--- /dev/null
+++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts
@@ -0,0 +1,24 @@
+// Shared LRU bound for the native-chat composer's per-scope caches (draft text
+// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The
+// caches exist so an in-progress message survives the composer unmounting on a
+// TUI/GUI toggle, but a scope key for a permanently-removed pane is never
+// revisited, so without a bound its unsent entry would linger for the renderer's
+// whole session. delete-then-set keeps the actively-edited scope most-recent so
+// eviction only sheds the oldest untouched scopes.
+export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128
+
+export function setBoundedScopeCacheEntry(
+ cache: Map,
+ scopeKey: string,
+ value: T
+): void {
+ cache.delete(scopeKey)
+ cache.set(scopeKey, value)
+ while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) {
+ const oldest = cache.keys().next().value
+ if (oldest === undefined) {
+ break
+ }
+ cache.delete(oldest)
+ }
+}
diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts
index c12d661e39d..7aa0f658517 100644
--- a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts
@@ -4,6 +4,7 @@ import {
readNativeChatDraftCache,
writeNativeChatDraftCache
} from './native-chat-draft-cache'
+import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache'
afterEach(() => {
clearNativeChatDraftCacheForTests()
@@ -26,4 +27,21 @@ describe('native-chat draft cache', () => {
writeNativeChatDraftCache('pty-1', '')
expect(readNativeChatDraftCache('pty-1')).toBe('')
})
+
+ it('bounds the cache so unsent drafts for removed panes cannot accumulate', () => {
+ writeNativeChatDraftCache('keep', 'hot')
+
+ const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 40
+ for (let i = 0; i < total; i += 1) {
+ writeNativeChatDraftCache(`scope-${i}`, `draft-${i}`)
+ if (i % 20 === 0) {
+ writeNativeChatDraftCache('keep', 'hot')
+ }
+ }
+
+ // Oldest untouched draft evicted; the actively-edited and most-recent survive.
+ expect(readNativeChatDraftCache('scope-0')).toBe('')
+ expect(readNativeChatDraftCache('keep')).toBe('hot')
+ expect(readNativeChatDraftCache(`scope-${total - 1}`)).toBe(`draft-${total - 1}`)
+ })
})
diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts
index 2c230c0a4eb..2a753744458 100644
--- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts
+++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts
@@ -4,6 +4,8 @@
// the typed-but-unsent draft would be lost on every TUI/GUI round-trip. Mirrors
// the attachment cache so both halves of an unsent message survive the toggle.
+import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
+
const draftCache = new Map()
export function readNativeChatDraftCache(scopeKey: string): string {
@@ -17,7 +19,8 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void
draftCache.delete(scopeKey)
return
}
- draftCache.set(scopeKey, draft)
+ // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate.
+ setBoundedScopeCacheEntry(draftCache, scopeKey, draft)
}
export function clearNativeChatDraftCacheForTests(): void {
diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts
index 5083ab0c518..58393460467 100644
--- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts
@@ -347,3 +347,42 @@ describe('applyCommandMarkerBoundaries', () => {
).toEqual(['new'])
})
})
+
+describe('scope-cache key counts stay bounded (memory-leak regression)', () => {
+ // The per-key arrays were capped at 8, but the KEY count (paneKey/agent/session,
+ // all ephemeral) was unbounded, so distinct panes/sessions accumulated forever.
+ // Both caches now LRU-bound the key count at 128 (shared helper, #7566).
+ const CAP = 128
+
+ it('appendCommandMarkerCache evicts the oldest scope key past the cap', () => {
+ clearCommandMarkerCacheForTests()
+ for (let i = 0; i < CAP + 5; i++) {
+ appendCommandMarkerCache(
+ { paneKey: 'tab:leaf', agent: 'claude', sessionId: `s${i}` },
+ '/clear'
+ )
+ }
+ // Oldest sessions evicted; the most-recent CAP survive.
+ expect(
+ readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: 's0' })
+ ).toEqual([])
+ expect(
+ readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: 's4' })
+ ).toEqual([])
+ expect(
+ readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: `s${CAP + 4}` })
+ ).toHaveLength(1)
+ })
+
+ it('writePendingSendCache evicts the oldest scope key past the cap', () => {
+ clearPendingSendCacheForTests()
+ const send = (id: string): NativeChatPendingSend => ({ id, text: id, sentAt: 1 })
+ for (let i = 0; i < CAP + 5; i++) {
+ writePendingSendCache({ paneKey: `tab-${i}:leaf`, agent: 'claude' }, [send(`m${i}`)])
+ }
+ expect(readPendingSendCache({ paneKey: 'tab-0:leaf', agent: 'claude' })).toEqual([])
+ expect(readPendingSendCache({ paneKey: `tab-${CAP + 4}:leaf`, agent: 'claude' })).toHaveLength(
+ 1
+ )
+ })
+})
diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts
index 3021dc7bba4..c03f4c0b96a 100644
--- a/src/renderer/src/components/native-chat/native-chat-pending.ts
+++ b/src/renderer/src/components/native-chat/native-chat-pending.ts
@@ -5,6 +5,7 @@
import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
+import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
/** An optimistic, not-yet-confirmed composer send. */
@@ -44,7 +45,10 @@ export function writePendingSendCache(
if (next.length === 0) {
pendingSendCache.delete(key)
} else {
- pendingSendCache.set(key, next)
+ // Why: the empty-drain path above clears keys on the normal confirm flow,
+ // but a pane closed with an unconfirmed send (agent crash / early close)
+ // would strand its entry forever. LRU-bound the key count too.
+ setBoundedScopeCacheEntry(pendingSendCache, key, next)
}
return [...next]
}
@@ -233,7 +237,11 @@ export function appendCommandMarkerCache(
...(commandMarkerCache.get(key) ?? []),
{ id: `${sentAt}-${commandMarkerCounter}`, command, sentAt }
].slice(-COMMAND_MARKER_LIMIT)
- commandMarkerCache.set(key, next)
+ // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId,
+ // sessionId changes on every /clear) is ephemeral and was never evicted, so it
+ // grew one entry per (pane, session) for the renderer's whole life. LRU-bound
+ // the key count (mirrors the #7566 draft/attachment caches in this folder).
+ setBoundedScopeCacheEntry(commandMarkerCache, key, next)
return [...next]
}
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
index 926e9a2ea4d..e64512fc98d 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
@@ -7,6 +7,7 @@ import {
type NativeChatResolvedTarget
} from './native-chat-composer-target'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
+import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
export type UseNativeChatComposerAttachmentsArgs = {
attachmentScopeKey: string
@@ -153,7 +154,8 @@ function writeNativeChatAttachmentCache(
attachmentCache.delete(scopeKey)
return
}
- attachmentCache.set(scopeKey, [...attachments])
+ // LRU-bounded so pending attachments for permanently-removed panes can't accumulate.
+ setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments])
}
export function clearNativeChatAttachmentCacheForTests(): void {
diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts
index cb5a1f7a33e..5ae84dde522 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
-import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
+import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import { canToggleNativeChat } from './native-chat-availability'
import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut'
@@ -72,8 +72,8 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
agentStatusByPaneKey: state.agentStatusByPaneKey
})
const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root)
- ? (resolveTabAgentFromTitle(tab.label ?? '') ??
- (terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null))
+ ? (resolveCommittedTitleAgentType(tab.label ?? '') ??
+ (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null))
: null
if (
!canToggleNativeChat({
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
index a8de4d84313..545b4095544 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx
@@ -154,7 +154,10 @@ export function AiVaultSessionVirtualList({
})
return (
-
+
{loading && sessionsCount === 0 ?
: null}
{!loading && sessionsCount === 0 && !error ? (
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx
index 6ce6dc4f886..1bc4c291821 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx
@@ -250,6 +250,32 @@ describe('isMutablePRConversationComment', () => {
})
describe('PRCommentsList', () => {
+ it('pins the comments header while scrolling the sidebar', () => {
+ const comments: PRComment[] = [
+ {
+ id: 1,
+ author: 'AmethystLiang',
+ authorAvatarUrl: '',
+ body: 'Existing review context',
+ createdAt: '2026-05-14T00:00:00Z',
+ url: 'https://github.com/acme/widgets/pull/42#issuecomment-1'
+ }
+ ]
+
+ const markup = renderWithTooltips(
+ React.createElement(PRCommentsList, {
+ comments,
+ commentsLoading: false,
+ onAddComment: () => Promise.resolve({ ok: true as const })
+ })
+ )
+
+ const commentsHeaderIndex = markup.indexOf('Comments')
+ const stickyIndex = markup.indexOf('sticky top-0 z-10 bg-sidebar/95 backdrop-blur-sm')
+ expect(stickyIndex).toBeGreaterThan(-1)
+ expect(stickyIndex).toBeLessThan(commentsHeaderIndex)
+ })
+
it('places the collapsed add-comment action in the comments header', () => {
const comments: PRComment[] = [
{
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index 43ca52db9ea..70b53d8f9eb 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -2358,7 +2358,14 @@ export function PRCommentsList({
return (
{/* Header */}
-
+
diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx
index fb02a5b58a9..99718de46fe 100644
--- a/src/renderer/src/components/settings/AgentsPane.tsx
+++ b/src/renderer/src/components/settings/AgentsPane.tsx
@@ -13,6 +13,11 @@ import { cn } from '@/lib/utils'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { AgentCacheTimerSection } from './AgentCacheTimerSection'
import { AgentRuntimeSetting } from './AgentRuntimeSetting'
+import {
+ AgentSessionSourceHomeInput,
+ buildCodexSessionSourceHomeControl,
+ type AgentSessionSourceHomeControl
+} from './codex-session-source-home-control'
import {
getAgentGeneratedTabTitlesDescription,
getAgentGeneratedTabTitlesTitle
@@ -81,6 +86,8 @@ type AgentRowProps = {
onSaveOverride: (value: string) => void
onSaveArgs: (value: string) => void
onSaveEnv: (value: Record) => void
+ /** Codex-only: current runtime scope label + persisted history-source override. */
+ sessionSourceHome?: AgentSessionSourceHomeControl
}
type AgentCommandOverrideInputProps = {
@@ -277,42 +284,44 @@ function AgentCommandOverrideInput({
}
return (
-
-
+
)
}
@@ -330,45 +339,47 @@ function AgentDefaultArgsInput({
}
return (
-
-
+
)
}
@@ -394,11 +405,11 @@ function AgentDefaultEnvInput({
}
return (
-
+
+
+ {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')}
+
-
- {translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')}
-
{
@@ -476,7 +487,8 @@ function AgentRow({
onSetEnabled,
onSaveOverride,
onSaveArgs,
- onSaveEnv
+ onSaveEnv,
+ sessionSourceHome
}: AgentRowProps): React.JSX.Element {
const envSummary = stringifyAgentDefaultEnvDraft(envOverride)
const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv)
@@ -494,15 +506,6 @@ function AgentRow({
{label}
- {isDetected ? (
-
- {translate('auto.components.settings.AgentsPane.c8794e622e', 'Detected')}
-
- ) : (
-
- {translate('auto.components.settings.AgentsPane.df123171d1', 'Not installed')}
-
- )}
{!isEnabled && (
{translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')}
@@ -523,7 +526,7 @@ function AgentRow({
-
+
-
- {isDetected && (
- setCmdOpen((prev) => !prev)}
- title={translate(
- 'auto.components.settings.AgentsPane.db9e9e5887',
- 'Customize command'
- )}
- aria-expanded={cmdOpen}
- className={cn(
- 'size-7 text-muted-foreground hover:text-foreground',
- (cmdOpen || cmdOverride) && 'text-foreground'
- )}
- >
-
-
- )}
-
-
)}
-
+ {sessionSourceHome && (
+
+ )}
+
{translate(
'auto.components.settings.AgentsPane.f9f127d664',
'Override the binary path or name, and edit the default launch arguments or environment for this agent.'
@@ -902,6 +893,11 @@ export function AgentsPane({
onSaveOverride={(v) => saveOverride(agent.id, v)}
onSaveArgs={(v) => saveAgentArgs(agent.id, v)}
onSaveEnv={(v) => saveAgentEnv(agent.id, v)}
+ sessionSourceHome={
+ agent.id === 'codex'
+ ? buildCodexSessionSourceHomeControl(settings, updateSettings)
+ : undefined
+ }
/>
))}
diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx
index 0f691a072dd..31afc7d6843 100644
--- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx
+++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx
@@ -38,6 +38,7 @@ type AppearanceInterfaceSectionProps = {
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
isDesktopWindows: boolean
+ onRequestFontSuggestions?: () => void
forceVisiblePrimary?: boolean
}
@@ -47,6 +48,7 @@ export function AppearanceInterfaceSection({
applyTheme,
fontSuggestions,
isDesktopWindows,
+ onRequestFontSuggestions,
forceVisiblePrimary = false
}: AppearanceInterfaceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
@@ -142,6 +144,7 @@ export function AppearanceInterfaceSection({
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
+ onRequestSuggestions={onRequestFontSuggestions}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx
index 714844932f1..5fe949cea39 100644
--- a/src/renderer/src/components/settings/AppearancePane.test.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.test.tsx
@@ -129,7 +129,10 @@ function createWarpThemesStub() {
async function renderAppearancePane(
settings: GlobalSettings,
- updateSettings: (updates: Partial
) => void = vi.fn()
+ updateSettings: (updates: Partial) => void = vi.fn(),
+ options: {
+ onRequestFontSuggestions?: () => void
+ } = {}
): Promise {
const container = document.createElement('div')
document.body.appendChild(container)
@@ -146,6 +149,7 @@ async function renderAppearancePane(
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
+ onRequestFontSuggestions={options.onRequestFontSuggestions}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
@@ -296,6 +300,25 @@ describe('AppearancePane', () => {
expect(container.textContent).not.toContain('Code & Markdown')
})
+ it('requests installed font suggestions only after the IDE font picker is used', async () => {
+ mocks.state.settingsSearchQuery = ''
+ const requestFontSuggestions = vi.fn()
+ const container = await renderAppearancePane(getDefaultSettings('/tmp'), vi.fn(), {
+ onRequestFontSuggestions: requestFontSuggestions
+ })
+
+ expect(requestFontSuggestions).not.toHaveBeenCalled()
+
+ const input = container.querySelector('input[role="combobox"]')
+ expect(input).not.toBeNull()
+
+ await act(async () => {
+ input?.focus()
+ })
+
+ expect(requestFontSuggestions).toHaveBeenCalledOnce()
+ })
+
it('keeps the app icon control at the bottom of the pane, after the section rows', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx
index f7a2dd52c13..92792621533 100644
--- a/src/renderer/src/components/settings/AppearancePane.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.tsx
@@ -45,6 +45,7 @@ type AppearancePaneProps = {
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
terminalFontSuggestions: string[]
+ onRequestFontSuggestions?: () => void
systemPrefersDark: boolean
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
@@ -68,6 +69,7 @@ export function AppearancePane({
applyTheme,
fontSuggestions,
terminalFontSuggestions,
+ onRequestFontSuggestions,
systemPrefersDark,
ghostty,
warpThemes
@@ -179,6 +181,7 @@ export function AppearancePane({
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
+ onRequestFontSuggestions={onRequestFontSuggestions}
isDesktopWindows={isDesktopWindows}
forceVisiblePrimary={interfaceLabelMatches}
/>
@@ -205,6 +208,7 @@ export function AppearancePane({
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
+ onRequestFontSuggestions={onRequestFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
forceVisiblePrimary={terminalLabelMatches}
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
index 92c4a35b2c4..bfe82c9d783 100644
--- a/src/renderer/src/components/settings/Settings.tsx
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -23,7 +23,11 @@ import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-pref
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { applyDocumentTheme } from '@/lib/document-theme'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
-import { SCROLLBACK_PRESETS_ROWS, getFallbackTerminalFonts } from './SettingsConstants'
+import {
+ SCROLLBACK_PRESETS_ROWS,
+ getFallbackTerminalFonts,
+ mergeFontSuggestions
+} from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { GeneralPane } from './GeneralPane'
@@ -313,7 +317,7 @@ function Settings(): React.JSX.Element {
const ghostty = useGhosttyImport(updateSettings, settings)
const warpThemes = useWarpThemeImport(updateSettings, settings)
const [fontSuggestions, setFontSuggestions] = useState(
- Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
+ mergeFontSuggestions([], getFallbackTerminalFonts())
)
const terminalFontSuggestions = useMemo(
() => fontSuggestions.filter((font) => font !== DEFAULT_APP_FONT_FAMILY),
@@ -336,7 +340,9 @@ function Settings(): React.JSX.Element {
const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false)
const contentScrollRef = useRef(null)
const searchInputRef = useRef(null)
- const terminalFontsLoadedRef = useRef(false)
+ const installedFontsLoadedRef = useRef(false)
+ const installedFontsLoadPromiseRef = useRef | null>(null)
+ const settingsMountedRef = useRef(true)
const pendingNavSectionRef = useRef(null)
const pendingScrollTargetRef = useRef(null)
const pendingSubsectionScrollFrameRef = useRef(null)
@@ -394,6 +400,42 @@ function Settings(): React.JSX.Element {
cancelPendingSettingsSubsectionScrollFrame(pendingSubsectionScrollFrameRef)
}, [])
+ useEffect(() => {
+ // Why: React dev StrictMode replays mount effects; async font requests
+ // should still commit while the Settings view is actually mounted.
+ settingsMountedRef.current = true
+ return () => {
+ settingsMountedRef.current = false
+ }
+ }, [])
+
+ const requestFontSuggestions = useCallback((): void => {
+ if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) {
+ return
+ }
+
+ installedFontsLoadPromiseRef.current = window.api.settings
+ .listFonts()
+ .then((fonts) => {
+ if (!settingsMountedRef.current) {
+ return
+ }
+ // Latch after the first successful attempt even when empty, so a font-less
+ // system doesn't reissue listFonts() on every picker interaction.
+ installedFontsLoadedRef.current = true
+ if (fonts.length === 0) {
+ return
+ }
+ setFontSuggestions((prev) => mergeFontSuggestions(fonts, prev))
+ })
+ .catch(() => {
+ // Fall back to curated cross-platform suggestions.
+ })
+ .finally(() => {
+ installedFontsLoadPromiseRef.current = null
+ })
+ }, [])
+
// Pure "discard and leave?" prompt — no side effects. Why separate from the
// discard helper below: the window-close guard must ask without clearing the
// drafts, since a later guard/handler can still cancel the close.
@@ -754,35 +796,6 @@ function Settings(): React.JSX.Element {
setMountedSectionIds(neededSectionIds)
}
- useEffect(() => {
- if (!neededSectionIds.has('appearance') && !neededSectionIds.has('terminal')) {
- return
- }
- if (terminalFontsLoadedRef.current) {
- return
- }
-
- let stale = false
- const loadFontSuggestions = async (): Promise => {
- try {
- const fonts = await window.api.settings.listFonts()
- if (stale || fonts.length === 0) {
- return
- }
- terminalFontsLoadedRef.current = true
- setFontSuggestions((prev) =>
- Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320)
- )
- } catch {
- // Fall back to curated cross-platform suggestions.
- }
- }
- void loadFontSuggestions()
- return () => {
- stale = true
- }
- }, [neededSectionIds])
-
const neededRepoIds = useMemo(
() => deriveNeededRepoIds(repos, neededSectionIds),
[neededSectionIds, repos]
@@ -1410,6 +1423,7 @@ function Settings(): React.JSX.Element {
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
terminalFontSuggestions={terminalFontSuggestions}
+ onRequestFontSuggestions={requestFontSuggestions}
systemPrefersDark={systemPrefersDark}
ghostty={ghostty}
warpThemes={warpThemes}
diff --git a/src/renderer/src/components/settings/SettingsConstants.test.ts b/src/renderer/src/components/settings/SettingsConstants.test.ts
new file mode 100644
index 00000000000..46876796784
--- /dev/null
+++ b/src/renderer/src/components/settings/SettingsConstants.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
+import { mergeFontSuggestions } from './SettingsConstants'
+
+describe('mergeFontSuggestions', () => {
+ it('keeps installed fonts beyond the old hydration cutoff', () => {
+ const systemFonts = Array.from({ length: 350 }, (_value, index) => `System Font ${index}`)
+ const suggestions = mergeFontSuggestions(systemFonts, ['JetBrains Mono'])
+
+ expect(suggestions).toContain(DEFAULT_APP_FONT_FAMILY)
+ expect(suggestions).toContain('System Font 349')
+ expect(suggestions).toContain('JetBrains Mono')
+ })
+})
diff --git a/src/renderer/src/components/settings/SettingsConstants.ts b/src/renderer/src/components/settings/SettingsConstants.ts
index 47fb0ba281a..2c49121d31d 100644
--- a/src/renderer/src/components/settings/SettingsConstants.ts
+++ b/src/renderer/src/components/settings/SettingsConstants.ts
@@ -1,5 +1,5 @@
import type { OrcaHooks } from '../../../../shared/types'
-import { getDefaultRepoHookSettings } from '../../../../shared/constants'
+import { DEFAULT_APP_FONT_FAMILY, getDefaultRepoHookSettings } from '../../../../shared/constants'
import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS } from '../../../../shared/terminal-scrollback-policy'
export type HookName = keyof OrcaHooks['scripts']
@@ -14,6 +14,15 @@ export function zoomLevelToPercent(level: number): number {
return Math.round(100 * Math.pow(1.2, level))
}
+export function mergeFontSuggestions(
+ systemFonts: readonly string[],
+ previousFonts: readonly string[]
+): string[] {
+ // Why: picker rendering can be capped later, but the source list must keep
+ // every installed font searchable/selectable.
+ return Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...systemFonts, ...previousFonts]))
+}
+
export function getFallbackTerminalFonts(): string[] {
const nav =
typeof navigator !== 'undefined'
diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx
index 62412f2e258..4e5e3a8f235 100644
--- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx
+++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx
@@ -4,6 +4,7 @@ import { act, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FontAutocomplete } from './SettingsFormControls'
+import { FONT_SUGGESTION_RENDER_LIMIT } from './settings-form-option-filter'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, defaultValue: string) => defaultValue
@@ -186,4 +187,85 @@ describe('FontAutocomplete', () => {
expect(getOptionLabels()).toEqual(['JetBrains Mono'])
})
+
+ it('requests installed suggestions only after the picker is used', async () => {
+ const requestSuggestions = vi.fn()
+
+ function Harness(): ReactNode {
+ const [value, setValue] = useState('Geist')
+ return (
+
+ )
+ }
+
+ await act(async () => {
+ root.render( )
+ })
+
+ expect(requestSuggestions).not.toHaveBeenCalled()
+
+ await act(async () => {
+ getInput().focus()
+ })
+
+ expect(requestSuggestions).toHaveBeenCalledOnce()
+ })
+
+ it('searches suggestions past the previous 320 font cutoff', async () => {
+ const suggestions = [
+ ...Array.from({ length: 350 }, (_value, index) => `System Font ${index}`),
+ 'Zed Mono'
+ ]
+
+ function Harness(): ReactNode {
+ const [value, setValue] = useState('System Font 0')
+ return
+ }
+
+ await act(async () => {
+ root.render( )
+ })
+
+ const input = getInput()
+
+ await act(async () => {
+ input.focus()
+ })
+ expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT)
+ expect(getOptionLabels()).not.toContain('Zed Mono')
+
+ await typeIntoInput(input, 'Zed')
+
+ expect(getOptionLabels()).toEqual(['Zed Mono'])
+ })
+
+ it('renders the selected late-list font even when the full list is capped', async () => {
+ const suggestions = [
+ ...Array.from({ length: FONT_SUGGESTION_RENDER_LIMIT + 30 }, (_value, index) => {
+ return `System Font ${index}`
+ }),
+ 'Zed Mono'
+ ]
+
+ function Harness(): ReactNode {
+ const [value, setValue] = useState('Zed Mono')
+ return
+ }
+
+ await act(async () => {
+ root.render( )
+ })
+
+ await act(async () => {
+ getInput().focus()
+ })
+
+ expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT)
+ expect(getOptionLabels().at(-1)).toBe('Zed Mono')
+ })
})
diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx
index a8b5bbdef05..146e0000f62 100644
--- a/src/renderer/src/components/settings/SettingsFormControls.tsx
+++ b/src/renderer/src/components/settings/SettingsFormControls.tsx
@@ -13,6 +13,7 @@ import { MAX_THEME_RESULTS } from './SettingsConstants'
import {
filterFontSuggestions,
filterTerminalThemeOptions,
+ getRenderedFontSuggestions,
isSettingsFormOptionQueryTooLarge
} from './settings-form-option-filter'
import { cn } from '@/lib/utils'
@@ -286,6 +287,7 @@ type FontAutocompleteProps = {
suggestions: string[]
onChange: (value: string) => void
placeholder?: string
+ onRequestSuggestions?: () => void
/** Fires with whichever option the user is currently highlighting in the
* dropdown (via mouse hover or keyboard arrow), or null when nothing is
* highlighted / the dropdown is closed. Lets a consumer show a live
@@ -588,6 +590,7 @@ export function FontAutocomplete({
suggestions,
onChange,
placeholder = 'SF Mono',
+ onRequestSuggestions,
onPreviewFontFamily
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
@@ -619,8 +622,15 @@ export function FontAutocomplete({
}
}
+ const requestSuggestions = useCallback((): void => {
+ onRequestSuggestions?.()
+ }, [onRequestSuggestions])
+
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
+ if (nextOpen) {
+ requestSuggestions()
+ }
if (!nextOpen) {
setIsFilteringQuery(false)
}
@@ -636,6 +646,10 @@ export function FontAutocomplete({
// still reveal every installed font instead of only fonts sharing that name.
const visibleSuggestions =
!isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions
+ const renderedSuggestions = useMemo(
+ () => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex),
+ [visibleSuggestions, highlightedIndex]
+ )
// Why: sync the highlighted index during render rather than via useEffect so
// the correct item is highlighted on the very first paint after open/filter
@@ -699,12 +713,14 @@ export function FontAutocomplete({
value={query}
onChange={(e) => {
const next = e.target.value
+ requestSuggestions()
setQuery(next)
setIsFilteringQuery(true)
onChange(next)
setOpen(true)
}}
onFocus={() => {
+ requestSuggestions()
setIsFilteringQuery(false)
setOpen(true)
}}
@@ -795,6 +811,7 @@ export function FontAutocomplete({
setIsFilteringQuery(false)
}
if (nextOpen) {
+ requestSuggestions()
focusInput()
}
}}
@@ -831,29 +848,29 @@ export function FontAutocomplete({
}}
>
8 ? 'h-64' : undefined}
+ className={renderedSuggestions.length > 8 ? 'h-64' : undefined}
style={popoverAvailableHeightStyle}
viewportProps={{ style: popoverAvailableHeightStyle }}
>
{visibleSuggestions.length > 0 ? (
- visibleSuggestions.map((font, index) => (
+ renderedSuggestions.map(({ font, sourceIndex }) => (
{
- if (element && index === highlightedIndex) {
+ if (element && sourceIndex === highlightedIndex) {
element.scrollIntoView({ block: 'nearest' })
}
}}
onMouseDown={(e) => e.preventDefault()}
- onMouseEnter={() => setHighlightedIndex(index)}
+ onMouseEnter={() => setHighlightedIndex(sourceIndex)}
onClick={() => commitValue(font)}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
- index === highlightedIndex
+ sourceIndex === highlightedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60'
}`}
diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx
index 3a00df1d04e..1af4ba26f08 100644
--- a/src/renderer/src/components/settings/SshPane.tsx
+++ b/src/renderer/src/components/settings/SshPane.tsx
@@ -254,7 +254,10 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const handleImport = async (): Promise => {
try {
- const synced = (await window.api.ssh.importConfig()) as SshTarget[]
+ // Why: the explicit Import action re-adopts every ~/.ssh/config host,
+ // including ones the user previously deleted — clear tombstones so a
+ // deliberate re-import can bring them back.
+ const synced = (await window.api.ssh.importConfig({ reAdopt: true })) as SshTarget[]
recordFeatureInteraction('ssh')
if (mountedRef.current) {
if (synced.length === 0) {
diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx
index 4503eb545e0..8bfacae4ad1 100644
--- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx
+++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx
@@ -44,6 +44,7 @@ type TerminalAppearanceSectionProps = {
updateSettings: (updates: Partial) => void
systemPrefersDark: boolean
terminalFontSuggestions: string[]
+ onRequestFontSuggestions?: () => void
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
forceVisiblePrimary?: boolean
@@ -74,6 +75,7 @@ export function TerminalAppearanceSection({
updateSettings,
systemPrefersDark,
terminalFontSuggestions,
+ onRequestFontSuggestions,
ghostty,
warpThemes,
forceVisiblePrimary = false
@@ -225,6 +227,7 @@ export function TerminalAppearanceSection({
updateSettings({ terminalFontFamily: value })}
onPreviewFontFamily={setPreviewFontFamily}
/>
diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx
new file mode 100644
index 00000000000..731398c3d0b
--- /dev/null
+++ b/src/renderer/src/components/settings/codex-session-source-home-control.test.tsx
@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { GlobalSettings } from '../../../../shared/types'
+import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control'
+
+function settingsWith(
+ overrides: Partial>
+): Pick {
+ return {
+ codexSessionSourceHome: overrides.codexSessionSourceHome,
+ localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }
+ }
+}
+
+describe('buildCodexSessionSourceHomeControl', () => {
+ it('reads and writes the host override on the host runtime', () => {
+ const updateSettings = vi.fn()
+ const control = buildCodexSessionSourceHomeControl(
+ settingsWith({ codexSessionSourceHome: { host: '/custom/codex' } }),
+ updateSettings
+ )
+
+ expect(control.runtimeLabel).toBe('~/.codex')
+ expect(control.value).toBe('/custom/codex')
+
+ control.onSave('/new/codex')
+ expect(updateSettings).toHaveBeenCalledWith({
+ codexSessionSourceHome: { host: '/new/codex' }
+ })
+ })
+
+ it('scopes to the selected WSL distro', () => {
+ const updateSettings = vi.fn()
+ const control = buildCodexSessionSourceHomeControl(
+ settingsWith({
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
+ }),
+ updateSettings
+ )
+
+ expect(control.value).toBe('/home/me/.codex')
+ control.onSave('/home/me/.config/codex')
+ expect(updateSettings).toHaveBeenCalledWith({
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
+ })
+ })
+
+ it('reads a stored WSL value when the reported distro casing differs', () => {
+ // Stored as "Ubuntu"; the runtime reports "ubuntu". The launch-time resolver
+ // matches case-insensitively, so the UI must surface the active value too.
+ const control = buildCodexSessionSourceHomeControl(
+ settingsWith({
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' }
+ }),
+ vi.fn()
+ )
+
+ expect(control.value).toBe('/home/me/.codex')
+ })
+
+ it('updates the existing WSL key in place instead of creating a duplicate casing', () => {
+ const updateSettings = vi.fn()
+ const control = buildCodexSessionSourceHomeControl(
+ settingsWith({
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' }
+ }),
+ updateSettings
+ )
+
+ control.onSave('/home/me/.config/codex')
+ // No stray "ubuntu" key alongside "Ubuntu".
+ expect(updateSettings).toHaveBeenCalledWith({
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
+ })
+ })
+
+ it('clears the matching WSL key regardless of casing when reset', () => {
+ const updateSettings = vi.fn()
+ const control = buildCodexSessionSourceHomeControl(
+ settingsWith({
+ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' }
+ }),
+ updateSettings
+ )
+
+ control.onSave('')
+ // Removing the only key leaves no dangling wsl object.
+ expect(updateSettings).toHaveBeenCalledWith({
+ codexSessionSourceHome: { wsl: undefined }
+ })
+ })
+
+ it('falls back to the host control when a WSL scope has no selected distro', () => {
+ const control = buildCodexSessionSourceHomeControl(
+ settingsWith({
+ codexSessionSourceHome: { host: '/custom/codex' },
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: null }
+ }),
+ vi.fn()
+ )
+
+ expect(control.runtimeLabel).toBe('~/.codex')
+ expect(control.value).toBe('/custom/codex')
+ })
+})
diff --git a/src/renderer/src/components/settings/codex-session-source-home-control.tsx b/src/renderer/src/components/settings/codex-session-source-home-control.tsx
new file mode 100644
index 00000000000..6a49a7b6042
--- /dev/null
+++ b/src/renderer/src/components/settings/codex-session-source-home-control.tsx
@@ -0,0 +1,169 @@
+import { useState } from 'react'
+import { Info } from 'lucide-react'
+import type { GlobalSettings } from '../../../../shared/types'
+import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
+import { translate } from '@/i18n/i18n'
+
+export type AgentSessionSourceHomeControl = {
+ runtimeLabel: string
+ value: string
+ onSave: (value: string) => void
+}
+
+type UpdateSettings = (updates: Partial) => void | Promise
+
+/**
+ * Builds the Codex session-history source control scoped to the runtime the
+ * Agents pane is showing (host or the selected WSL distro), so it mirrors how
+ * detected agents are scoped. History-only: it never touches auth/config.
+ */
+// Why: the launch-time resolver matches distro keys case-insensitively, so the
+// UI must resolve to the SAME stored key. Otherwise a casing mismatch would show
+// an empty override for an active value and, on edit, create a duplicate key.
+function findWslSourceHomeKey(
+ wsl: Record | undefined,
+ distro: string
+): string | undefined {
+ const normalized = distro.trim().toLowerCase()
+ return Object.keys(wsl ?? {}).find((key) => key.trim().toLowerCase() === normalized)
+}
+
+export function buildCodexSessionSourceHomeControl(
+ settings: Pick,
+ updateSettings: UpdateSettings
+): AgentSessionSourceHomeControl {
+ const runtimeScope = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault)
+ const sourceHome = settings.codexSessionSourceHome
+ // Why: a WSL scope with no selected distro can't target a per-distro history
+ // home, so fall back to the host control rather than a null distro key.
+ const wslDistro = runtimeScope.kind === 'wsl' ? runtimeScope.distro?.trim() : undefined
+ if (wslDistro) {
+ const existingKey = findWslSourceHomeKey(sourceHome?.wsl, wslDistro)
+ return {
+ runtimeLabel: `${wslDistro}: ~/.codex`,
+ value: (existingKey ? sourceHome?.wsl?.[existingKey] : undefined) ?? '',
+ // Save under the existing key when one matches (case-insensitively), so
+ // editing updates it in place instead of adding a differently-cased key.
+ onSave: (value: string) =>
+ saveCodexSessionSourceHome(settings, updateSettings, {
+ runtime: 'wsl',
+ distro: existingKey ?? wslDistro,
+ value
+ })
+ }
+ }
+ return {
+ runtimeLabel: '~/.codex',
+ value: sourceHome?.host ?? '',
+ onSave: (value: string) =>
+ saveCodexSessionSourceHome(settings, updateSettings, { runtime: 'host', value })
+ }
+}
+
+function saveCodexSessionSourceHome(
+ settings: Pick,
+ updateSettings: UpdateSettings,
+ args: { runtime: 'host'; value: string } | { runtime: 'wsl'; distro: string; value: string }
+): void {
+ const current = settings.codexSessionSourceHome ?? {}
+ const trimmed = args.value.trim()
+ if (args.runtime === 'host') {
+ updateSettings({ codexSessionSourceHome: { ...current, host: trimmed || undefined } })
+ return
+ }
+ const nextWsl = { ...current.wsl }
+ // Why: reuse an existing case-insensitive match so we never leave a stale
+ // duplicate key behind when the caller passes a differently-cased distro.
+ const targetKey = findWslSourceHomeKey(nextWsl, args.distro) ?? args.distro
+ if (trimmed) {
+ nextWsl[targetKey] = trimmed
+ } else {
+ delete nextWsl[targetKey]
+ }
+ updateSettings({
+ codexSessionSourceHome: {
+ ...current,
+ wsl: Object.keys(nextWsl).length > 0 ? nextWsl : undefined
+ }
+ })
+}
+
+export function AgentSessionSourceHomeInput({
+ runtimeLabel,
+ value,
+ onSave
+}: AgentSessionSourceHomeControl): React.JSX.Element {
+ const [draft, setDraft] = useState(value)
+
+ const commit = (): void => {
+ onSave(draft.trim())
+ }
+
+ return (
+
+
+ {translate(
+ 'auto.components.settings.AgentsPane.codexSessionSource',
+ 'Codex home to import from'
+ )}
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.settings.AgentsPane.codexSessionSourceTooltip',
+ 'Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.'
+ )}
+
+
+
+
+ setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ commit()
+ e.currentTarget.blur()
+ }
+ if (e.key === 'Escape') {
+ setDraft(value)
+ e.currentTarget.blur()
+ }
+ }}
+ placeholder={runtimeLabel}
+ spellCheck={false}
+ className="h-7 flex-1 font-mono text-xs"
+ />
+ {value.trim() && (
+ {
+ onSave('')
+ setDraft('')
+ }}
+ className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
+ >
+ {translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/settings-form-option-filter.test.ts b/src/renderer/src/components/settings/settings-form-option-filter.test.ts
index 489490f828d..d24a9fe9a1f 100644
--- a/src/renderer/src/components/settings/settings-form-option-filter.test.ts
+++ b/src/renderer/src/components/settings/settings-form-option-filter.test.ts
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { TerminalThemeOption } from '@/lib/terminal-theme'
import {
+ FONT_SUGGESTION_RENDER_LIMIT,
SETTINGS_FORM_OPTION_QUERY_MAX_BYTES,
filterFontSuggestions,
filterTerminalThemeOptions,
+ getRenderedFontSuggestions,
isSettingsFormOptionQueryTooLarge
} from './settings-form-option-filter'
@@ -86,4 +88,37 @@ describe('settings-form-option-filter', () => {
expect(filterTerminalThemeOptions([createThemeOption('Solarized Dark')], query)).toEqual([])
expect(filterFontSuggestions(['Fira Code'], query)).toEqual([])
})
+
+ it('caps rendered font suggestions without changing the searchable source list', () => {
+ const suggestions = Array.from(
+ { length: FONT_SUGGESTION_RENDER_LIMIT + 40 },
+ (_value, index) => `System Font ${index}`
+ )
+
+ const rendered = getRenderedFontSuggestions(suggestions, 0)
+
+ expect(rendered).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT)
+ expect(rendered.map((option) => option.font)).not.toContain(
+ `System Font ${FONT_SUGGESTION_RENDER_LIMIT + 39}`
+ )
+ expect(
+ filterFontSuggestions(suggestions, `System Font ${FONT_SUGGESTION_RENDER_LIMIT + 39}`)
+ ).toEqual([`System Font ${FONT_SUGGESTION_RENDER_LIMIT + 39}`])
+ })
+
+ it('keeps a highlighted late font visible inside the render cap', () => {
+ const lateFontIndex = FONT_SUGGESTION_RENDER_LIMIT + 12
+ const suggestions = Array.from(
+ { length: lateFontIndex + 1 },
+ (_value, index) => `System Font ${index}`
+ )
+
+ const rendered = getRenderedFontSuggestions(suggestions, lateFontIndex)
+
+ expect(rendered).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT)
+ expect(rendered.at(-1)).toEqual({
+ font: `System Font ${lateFontIndex}`,
+ sourceIndex: lateFontIndex
+ })
+ })
})
diff --git a/src/renderer/src/components/settings/settings-form-option-filter.ts b/src/renderer/src/components/settings/settings-form-option-filter.ts
index 976bacf6a9e..c321739722e 100644
--- a/src/renderer/src/components/settings/settings-form-option-filter.ts
+++ b/src/renderer/src/components/settings/settings-form-option-filter.ts
@@ -2,6 +2,12 @@ import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard
import type { TerminalThemeOption } from '@/lib/terminal-theme'
export const SETTINGS_FORM_OPTION_QUERY_MAX_BYTES = 2 * 1024
+export const FONT_SUGGESTION_RENDER_LIMIT = 320
+
+export type RenderedFontSuggestion = {
+ font: string
+ sourceIndex: number
+}
export function isSettingsFormOptionQueryTooLarge(
query: string,
@@ -55,3 +61,20 @@ export function filterFontSuggestions(suggestions: readonly string[], query: str
}
return [...startsWith, ...includes]
}
+
+export function getRenderedFontSuggestions(
+ suggestions: readonly string[],
+ highlightedIndex: number,
+ limit = FONT_SUGGESTION_RENDER_LIMIT
+): RenderedFontSuggestion[] {
+ const cappedLength = Math.min(suggestions.length, limit)
+ if (cappedLength <= 0) {
+ return []
+ }
+
+ const sourceIndexes = Array.from({ length: cappedLength }, (_value, index) => index)
+ if (highlightedIndex >= cappedLength && highlightedIndex < suggestions.length) {
+ sourceIndexes[cappedLength - 1] = highlightedIndex
+ }
+ return sourceIndexes.map((sourceIndex) => ({ font: suggestions[sourceIndex] ?? '', sourceIndex }))
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts
index 36ac370dbed..abc03a3c4fd 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts
+++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts
@@ -20,7 +20,6 @@ vi.mock('@/lib/agent-status', () => ({
import { getWorktreeStatus } from '@/lib/worktree-status'
import { shouldBeginWorktreeRename } from './WorktreeCard'
-import { deriveWorktreeCardStatus } from './worktree-card-status'
function makeTerminalTab(title: string): TerminalTab {
return {
@@ -35,22 +34,6 @@ function makeTerminalTab(title: string): TerminalTab {
}
}
-function makeAgentStatusEntry(args: {
- paneKey: string
- state: AgentStatusEntry['state']
- updatedAt?: number
-}): AgentStatusEntry {
- const updatedAt = args.updatedAt ?? 1_000
- return {
- paneKey: args.paneKey,
- state: args.state,
- prompt: '',
- updatedAt,
- stateStartedAt: updatedAt,
- stateHistory: []
- }
-}
-
describe('getWorktreeStatus', () => {
it('treats browser-only worktrees as active', () => {
expect(getWorktreeStatus([], [{ id: 'browser-1' }], {})).toBe('active')
@@ -59,67 +42,22 @@ describe('getWorktreeStatus', () => {
it('keeps terminal agent states higher priority than browser presence', () => {
// Why: liveness gate now requires ptyIdsByTabId, not tab.ptyId. Pass a
// populated live-pty map so this assertion exercises the live-tab branch.
+ // Titles are real classifiable shapes: getWorktreeStatus reads the shared
+ // classifier through pane-agent-evidence, which this file does not mock.
const livePtyIds = { 'tab-1': ['pty-1'] }
expect(
- getWorktreeStatus([makeTerminalTab('permission needed')], [{ id: 'browser-1' }], livePtyIds)
+ getWorktreeStatus(
+ [makeTerminalTab('Claude - action required')],
+ [{ id: 'browser-1' }],
+ livePtyIds
+ )
).toBe('permission')
expect(
- getWorktreeStatus([makeTerminalTab('working hard')], [{ id: 'browser-1' }], livePtyIds)
+ getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds)
).toBe('working')
})
})
-describe('deriveWorktreeCardStatus', () => {
- it('keeps split-pane heuristics for panes without fresh explicit status', () => {
- const status = deriveWorktreeCardStatus({
- tabs: [makeTerminalTab('claude [done]')],
- browserTabs: [],
- worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:1', state: 'done' })],
- runtimePaneTitlesByTabId: {
- 'tab-1': {
- 1: 'claude [done]',
- 2: 'codex [working]'
- }
- },
- now: 1_000
- })
-
- expect(status).toBe('working')
- })
-
- it('lets fresh explicit status win over the matching pane title heuristic', () => {
- const status = deriveWorktreeCardStatus({
- tabs: [makeTerminalTab('codex [working]')],
- browserTabs: [],
- worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:2', state: 'done' })],
- runtimePaneTitlesByTabId: {
- 'tab-1': {
- 2: 'codex [working]'
- }
- },
- now: 1_000
- })
-
- expect(status).toBe('done')
- })
-
- it('stays active when the only live terminal signal is the Claude agents screen', () => {
- const status = deriveWorktreeCardStatus({
- tabs: [makeTerminalTab('claude agents')],
- browserTabs: [],
- worktreeAgentEntries: [],
- runtimePaneTitlesByTabId: {
- 'tab-1': {
- 1: 'claude agents'
- }
- },
- now: 1_000
- })
-
- expect(status).toBe('active')
- })
-})
-
describe('shouldBeginWorktreeRename', () => {
it('matches unscoped legacy rename requests by worktree id', () => {
expect(shouldBeginWorktreeRename({ worktreeId: 'wt-1' }, 'wt-1', 'all:wt-1')).toBe(true)
diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts
index 4ca27a7c47d..ae0d7542da3 100644
--- a/src/renderer/src/components/sidebar/smart-attention.ts
+++ b/src/renderer/src/components/sidebar/smart-attention.ts
@@ -1,4 +1,4 @@
-import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
+import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
@@ -308,7 +308,7 @@ export function buildAttentionByWorktree(
}
panes.push({
kind: 'title',
- status: detectAgentStatusFromTitle(title),
+ status: classifyTitleActivity(title),
worktreeLastActivityAt: worktree.lastActivityAt
})
}
@@ -318,7 +318,7 @@ export function buildAttentionByWorktree(
// titles or hook entries exist for this tab.
panes.push({
kind: 'title',
- status: detectAgentStatusFromTitle(tab.title),
+ status: classifyTitleActivity(tab.title),
worktreeLastActivityAt: worktree.lastActivityAt
})
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-status.ts b/src/renderer/src/components/sidebar/worktree-card-status.ts
deleted file mode 100644
index 61daeb74c9a..00000000000
--- a/src/renderer/src/components/sidebar/worktree-card-status.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
-import type { WorktreeStatus } from '@/lib/worktree-status'
-import {
- AGENT_STATUS_STALE_AFTER_MS,
- type AgentStatusEntry
-} from '../../../../shared/agent-status-types'
-import type { TerminalTab } from '../../../../shared/types'
-
-type WorktreeCardStatusInput = {
- tabs: Pick[]
- browserTabs: { id: string }[]
- worktreeAgentEntries: AgentStatusEntry[]
- runtimePaneTitlesByTabId: Record>
- now?: number
-}
-
-export function deriveWorktreeCardStatus({
- tabs,
- browserTabs,
- worktreeAgentEntries,
- runtimePaneTitlesByTabId,
- now = Date.now()
-}: WorktreeCardStatusInput): WorktreeStatus {
- const liveTabs = tabs.filter((tab) => tab.ptyId)
- // Why: browser-only worktrees are still active from the user's point of
- // view even when they have no PTY-backed terminal. The sidebar filter
- // already treats them as active, so every navigation surface must reuse
- // that rule instead of showing a misleading inactive dot.
- const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0
- if (!hasTerminals) {
- return 'inactive'
- }
-
- const freshByTabId = new Map()
- const explicitPaneIdsByTabId = new Map>()
- for (const entry of worktreeAgentEntries) {
- if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
- continue
- }
- const colonIdx = entry.paneKey.indexOf(':')
- // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
- // colon or leading colon) rather than bucketing under "".
- if (colonIdx <= 0) {
- continue
- }
- const tabId = entry.paneKey.slice(0, colonIdx)
- const paneId = entry.paneKey.slice(colonIdx + 1)
- const bucket = freshByTabId.get(tabId)
- if (bucket) {
- bucket.push(entry)
- } else {
- freshByTabId.set(tabId, [entry])
- }
- const explicitPaneIds = explicitPaneIdsByTabId.get(tabId) ?? new Set()
- explicitPaneIds.add(paneId)
- explicitPaneIdsByTabId.set(tabId, explicitPaneIds)
- }
-
- let hasPermission = false
- let hasWorking = false
- let hasDone = false
- for (const tab of liveTabs) {
- const fresh = freshByTabId.get(tab.id)
- if (fresh && fresh.length > 0) {
- if (fresh.some((e) => e.state === 'blocked' || e.state === 'waiting')) {
- hasPermission = true
- } else if (fresh.some((e) => e.state === 'working')) {
- hasWorking = true
- } else if (fresh.some((e) => e.state === 'done')) {
- hasDone = true
- }
- }
-
- const explicitPaneIds = explicitPaneIdsByTabId.get(tab.id)
- const paneTitles = runtimePaneTitlesByTabId[tab.id]
- if (paneTitles && Object.keys(paneTitles).length > 0) {
- for (const [paneId, title] of Object.entries(paneTitles)) {
- // Why: explicit hook status only supersedes the matching pane. Other
- // split panes still need the title heuristic when hooks are absent.
- if (explicitPaneIds?.has(paneId)) {
- continue
- }
- const heuristic = detectAgentStatusFromTitle(title)
- if (heuristic === 'permission') {
- hasPermission = true
- break
- }
- if (heuristic === 'working') {
- hasWorking = true
- }
- }
- continue
- }
-
- if (fresh && fresh.length > 0) {
- continue
- }
-
- const heuristic = detectAgentStatusFromTitle(tab.title)
- if (heuristic === 'permission') {
- hasPermission = true
- } else if (heuristic === 'working') {
- hasWorking = true
- }
- }
-
- if (hasPermission) {
- return 'permission'
- }
- if (hasWorking) {
- return 'working'
- }
- // Why: surface 'done' as its own status so the sidebar dot turns blue
- // (sky-500/80) — matching the dashboard's done color. A completed agent
- // still has a live terminal, so 'inactive' would be misleading; calling
- // it 'done' keeps the two surfaces in agreement on what the agent is.
- if (hasDone) {
- return 'done'
- }
- return 'active'
-}
diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
index 2b5efddebee..1c862b70505 100644
--- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
@@ -1,9 +1,6 @@
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
-import {
- detectAgentStatusFromTitle,
- getAgentLabel,
- isClaudeManagementTitle
-} from '@/lib/agent-status'
+import { isClaudeManagementTitle } from '@/lib/agent-status'
+import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type {
AgentStatusEntry,
@@ -135,8 +132,8 @@ function buildTitleDerivedAgentRow(args: {
// Why: `claude agents` is a live Claude Code Agent Teams surface, but the
// shared detector keeps it neutral so runtime liveness probes do not treat
// the management/list screen as active work.
- const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(title)
- const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(title)
+ const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title)
+ const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title)
if (!status || !label) {
return null
}
@@ -199,7 +196,7 @@ export function resolveAgentTypeFromTerminalTitle(
return null
}
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType)
- const label = getAgentLabel(normalizedTitle)
+ const label = resolveTitleActivityLabel(normalizedTitle)
return label
? (resolveCompatibleAgentTypeForOwner(
resolveTitleDerivedAgentType(normalizedTitle, label),
diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts
index 85274483d96..ad09532a652 100644
--- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts
+++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts
@@ -1,4 +1,4 @@
-import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
+import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import {
AGENT_STATUS_STALE_AFTER_MS,
@@ -82,12 +82,12 @@ function countTitleActiveAgentsForTab(
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
return Object.values(paneTitles).filter((title) => {
- const status = detectAgentStatusFromTitle(title)
+ const status = classifyTitleActivity(title)
return status === 'working' || status === 'permission'
}).length
}
- const status = detectAgentStatusFromTitle(tab.title)
+ const status = classifyTitleActivity(tab.title)
return status === 'working' || status === 'permission' ? 1 : 0
}
diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx
index f4dc863f8e8..2dfd84d87e5 100644
--- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx
+++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx
@@ -42,6 +42,18 @@ vi.mock('@dnd-kit/sortable', () => ({
}))
vi.mock('lucide-react', () => ({
+ ArrowDown: function ArrowDown(props: Record) {
+ return { type: 'ArrowDown', props }
+ },
+ ArrowLeft: function ArrowLeft(props: Record) {
+ return { type: 'ArrowLeft', props }
+ },
+ ArrowRight: function ArrowRight(props: Record) {
+ return { type: 'ArrowRight', props }
+ },
+ ArrowUp: function ArrowUp(props: Record) {
+ return { type: 'ArrowUp', props }
+ },
Columns2: function Columns2(props: Record) {
return { type: 'Columns2', props }
},
@@ -60,6 +72,9 @@ vi.mock('lucide-react', () => ({
PinOff: function PinOff(props: Record) {
return { type: 'PinOff', props }
},
+ PanelRightClose: function PanelRightClose(props: Record) {
+ return { type: 'PanelRightClose', props }
+ },
Rows2: function Rows2(props: Record) {
return { type: 'Rows2', props }
},
diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx
index 0dfb97a9af6..45bc82e7989 100644
--- a/src/renderer/src/components/tab-bar/BrowserTab.tsx
+++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
-import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react'
+import { Globe, X, ExternalLink, Copy, Pin, PinOff, PanelRightClose } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -279,37 +279,36 @@ export default function BrowserTab({
sideOffset={0}
align="start"
>
+
-
+
{translate('auto.components.tab.bar.BrowserTab.5d6e89891f', 'Duplicate Tab')}
- {isPinned ? (
-
- ) : (
-
- )}
+ {isPinned ? : }
{isPinned
? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab')
: translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')}
-
!isPinned && onClose()} disabled={isPinned}>
+
{translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')}
+
{translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')}
void window.api.shell.openUrl(openInBrowserUrl)}
disabled={!isHttpUrl}
>
-
+
{translate('auto.components.tab.bar.BrowserTab.6e0bc8f3a8', 'Open In Browser')}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx
index 59a1c18bcee..66071a7da1d 100644
--- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx
+++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx
@@ -58,6 +58,18 @@ vi.mock('@dnd-kit/sortable', () => ({
}))
vi.mock('lucide-react', () => ({
+ ArrowDown: function ArrowDown(props: Record) {
+ return { type: 'ArrowDown', props }
+ },
+ ArrowLeft: function ArrowLeft(props: Record) {
+ return { type: 'ArrowLeft', props }
+ },
+ ArrowRight: function ArrowRight(props: Record) {
+ return { type: 'ArrowRight', props }
+ },
+ ArrowUp: function ArrowUp(props: Record) {
+ return { type: 'ArrowUp', props }
+ },
Columns2: function Columns2(props: Record) {
return { type: 'Columns2', props }
},
@@ -70,6 +82,12 @@ vi.mock('lucide-react', () => ({
Eye: function Eye(props: Record) {
return { type: 'Eye', props }
},
+ ListX: function ListX(props: Record) {
+ return { type: 'ListX', props }
+ },
+ PanelRightClose: function PanelRightClose(props: Record) {
+ return { type: 'PanelRightClose', props }
+ },
GitCompareArrows: function GitCompareArrows(props: Record) {
return { type: 'GitCompareArrows', props }
},
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx
index 8167bdbda5c..09257498698 100644
--- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx
+++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx
@@ -36,12 +36,33 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
}))
vi.mock('lucide-react', () => ({
+ ArrowDown: function ArrowDown(props: Record) {
+ return { type: 'ArrowDown', props }
+ },
+ ArrowLeft: function ArrowLeft(props: Record) {
+ return { type: 'ArrowLeft', props }
+ },
+ ArrowRight: function ArrowRight(props: Record) {
+ return { type: 'ArrowRight', props }
+ },
+ ArrowUp: function ArrowUp(props: Record) {
+ return { type: 'ArrowUp', props }
+ },
Copy: function Copy(props: Record) {
return { type: 'Copy', props }
},
ExternalLink: function ExternalLink(props: Record) {
return { type: 'ExternalLink', props }
},
+ Eye: function Eye(props: Record) {
+ return { type: 'Eye', props }
+ },
+ ListX: function ListX(props: Record) {
+ return { type: 'ListX', props }
+ },
+ PanelRightClose: function PanelRightClose(props: Record) {
+ return { type: 'PanelRightClose', props }
+ },
Columns2: function Columns2(props: Record) {
return { type: 'Columns2', props }
},
@@ -56,6 +77,9 @@ vi.mock('lucide-react', () => ({
},
PinOff: function PinOff(props: Record) {
return { type: 'PinOff', props }
+ },
+ X: function X(props: Record) {
+ return { type: 'X', props }
}
}))
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx
index 184c05d2d97..bbf9a75d74a 100644
--- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx
+++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx
@@ -1,4 +1,14 @@
-import { Copy, ExternalLink, Pencil, Pin, PinOff } from 'lucide-react'
+import {
+ Copy,
+ ExternalLink,
+ Eye,
+ ListX,
+ PanelRightClose,
+ Pencil,
+ Pin,
+ PinOff,
+ X
+} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -107,6 +117,11 @@ export function EditorFileTabContextMenu({
event.preventDefault()
}}
>
+
{
@@ -115,24 +130,25 @@ export function EditorFileTabContextMenu({
onOpenRenameInput()
}}
>
-
+
{translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')}
{renameShortcut ? {renameShortcut} : null}
- {isPinned ? : }
+ {isPinned ? : }
{isPinned
? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab')
: translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')}
-
!isPinned && onClose()} disabled={isPinned}>
+
{translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')}
{closeShortcut ? {closeShortcut} : null}
+
{translate(
'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24',
'Close All Editor Tabs'
@@ -142,6 +158,7 @@ export function EditorFileTabContextMenu({
) : null}
+
{translate(
'auto.components.tab.bar.EditorFileTabContextMenu.e5ff31ccaf',
'Close Tabs To The Right'
@@ -165,6 +182,7 @@ export function EditorFileTabContextMenu({
)
}}
>
+
{translate(
'auto.components.tab.bar.EditorFileTabContextMenu.bfd5797ef4',
'Open Markdown Preview'
@@ -178,7 +196,7 @@ export function EditorFileTabContextMenu({
void window.api.ui.writeClipboardText(file.filePath)
}}
>
-
+
{translate('auto.components.tab.bar.EditorFileTabContextMenu.5b85754786', 'Copy Path')}
-
+
{translate(
'auto.components.tab.bar.EditorFileTabContextMenu.52ce4f4605',
'Copy Relative Path'
@@ -208,7 +226,7 @@ export function EditorFileTabContextMenu({
window.api.shell.openPath(file.filePath)
}}
>
-
+
{revealLabel}
diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx
index bb4e757d1c8..d183891b6de 100644
--- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx
+++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx
@@ -72,6 +72,18 @@ vi.mock('@dnd-kit/sortable', () => ({
}))
vi.mock('lucide-react', () => ({
+ ArrowDown: function ArrowDown(props: Record) {
+ return { type: 'ArrowDown', props }
+ },
+ ArrowLeft: function ArrowLeft(props: Record) {
+ return { type: 'ArrowLeft', props }
+ },
+ ArrowRight: function ArrowRight(props: Record) {
+ return { type: 'ArrowRight', props }
+ },
+ ArrowUp: function ArrowUp(props: Record) {
+ return { type: 'ArrowUp', props }
+ },
Columns2: function Columns2(props: Record) {
return { type: 'Columns2', props }
},
@@ -84,6 +96,15 @@ vi.mock('lucide-react', () => ({
PanelRightClose: function PanelRightClose(props: Record) {
return { type: 'PanelRightClose', props }
},
+ ListX: function ListX(props: Record) {
+ return { type: 'ListX', props }
+ },
+ MessageSquare: function MessageSquare(props: Record) {
+ return { type: 'MessageSquare', props }
+ },
+ Pencil: function Pencil(props: Record) {
+ return { type: 'Pencil', props }
+ },
Pin: function Pin(props: Record) {
return { type: 'Pin', props }
},
@@ -95,6 +116,9 @@ vi.mock('lucide-react', () => ({
},
X: function X(props: Record) {
return { type: 'X', props }
+ },
+ SquareTerminal: function SquareTerminal(props: Record) {
+ return { type: 'SquareTerminal', props }
}
}))
diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx
index 71b067fa898..6fb14821294 100644
--- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx
+++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx
@@ -50,10 +50,20 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
}))
vi.mock('lucide-react', () => ({
+ ArrowDown: () => null,
+ ArrowLeft: () => null,
+ ArrowRight: () => null,
+ ArrowUp: () => null,
+ Columns2: () => null,
+ ListX: () => null,
+ MessageSquare: () => null,
PanelBottomClose: () => null,
PanelRightClose: () => null,
+ Pencil: () => null,
Pin: () => null,
- PinOff: () => null
+ PinOff: () => null,
+ SquareTerminal: () => null,
+ X: () => null
}))
vi.mock('@/i18n/i18n', () => ({
@@ -218,6 +228,7 @@ describe('SortableTabContextMenu', () => {
const { container } = renderMenu()
expect(container.textContent).toContain('Move Tab to Split')
+ expect(container.textContent).toContain('Split terminal')
act(() => getButton(container, 'Right').click())
expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', {
@@ -226,7 +237,7 @@ describe('SortableTabContextMenu', () => {
})
})
- it('hides split actions for a single-tab group', () => {
+ it('hides move-tab split actions for a single-tab group', () => {
storeMock.state = {
...storeMock.state,
groupsByWorktree: {
@@ -243,5 +254,6 @@ describe('SortableTabContextMenu', () => {
const { container } = renderMenu()
expect(container.textContent).not.toContain('Move Tab to Split')
+ expect(container.textContent).toContain('Split terminal right')
})
})
diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx
index f93afd46d89..469c4f7159a 100644
--- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx
+++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx
@@ -1,10 +1,12 @@
import {
MessageSquare,
- PanelBottomClose,
PanelRightClose,
Pin,
PinOff,
- SquareTerminal
+ Pencil,
+ SquareTerminal,
+ X,
+ ListX
} from 'lucide-react'
import {
DropdownMenu,
@@ -18,8 +20,7 @@ import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
-import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
-import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
+import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection'
const TAB_COLORS = [
{
@@ -138,12 +139,6 @@ export function SortableTabContextMenu({
const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings)
const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings)
- const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => {
- if (!isActive) {
- onActivate(tab.id)
- }
- requestActiveTerminalPaneSplit({ tabId: tab.id, direction })
- }
const closeShortcut = useOptionalShortcutLabel('tab.close')
const renameShortcut = useOptionalShortcutLabel('tab.rename')
@@ -158,13 +153,23 @@ export function SortableTabContextMenu({
/>
- {canToggleViewMode && onToggleViewMode && (
+