From 6345505628581c30dc631544f10ba3ef4b800e20 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 13:00:08 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .env.example | 5 + .gitattributes | 28 + .githooks/pre-push | 5 + .github/CODEOWNERS | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 60 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 29 + .github/dependabot.yml | 49 + .github/labeler.yml | 51 + .github/pull_request_template.md | 19 + .github/sponsor/wechat-pay.jpg | Bin 0 -> 141819 bytes .github/workflows/cache-impact.yml | 37 + .github/workflows/ci.yml | 283 + .github/workflows/codeql.yml | 40 + .github/workflows/deploy-accounts-worker.yml | 49 + .github/workflows/deploy-crash-worker.yml | 34 + .github/workflows/deploy-forum-worker.yml | 38 + .github/workflows/e2e-bot.yml | 160 + .github/workflows/issue-auto-label.yml | 99 + .github/workflows/issue-version-label.yml | 48 + .github/workflows/pages.yml | 47 + .github/workflows/pr-auto-label.yml | 28 + .github/workflows/pr-version-label.yml | 49 + .github/workflows/release-desktop.yml | 485 + .github/workflows/release-npm.yml | 114 + .github/workflows/release.yml | 173 + .github/workflows/update-acknowledgments.yml | 37 + .gitignore | 64 + .golangci.yml | 48 + .goreleaser.yaml | 62 + .reasonix/commands/review.md | 5 + .../windows-installer.xml | 13 + CHANGELOG.md | 60 + CONTRIBUTING.md | 190 + LICENSE | 21 + Makefile | 42 + README.md | 209 + README.wehub.md | 7 + README.zh-CN.md | 195 + REASONIX.md | 73 + SECURITY.md | 137 + benchmarks/context-maintenance-e2e/main.go | 255 + benchmarks/e2e/tasks/compaction/task.toml | 3 + benchmarks/e2e/tasks/compaction/verify.sh | 4 + .../compaction/workdir/story/chapter-1.md | 220 + .../compaction/workdir/story/chapter-2.md | 220 + .../compaction/workdir/story/chapter-3.md | 220 + .../compaction/workdir/story/chapter-4.md | 220 + .../compaction/workdir/story/chapter-5.md | 220 + .../compaction/workdir/story/chapter-6.md | 220 + benchmarks/e2e/tasks/fix-add-bug/task.toml | 3 + benchmarks/e2e/tasks/fix-add-bug/verify.sh | 7 + .../e2e/tasks/fix-add-bug/workdir/calc.py | 6 + benchmarks/e2e/tasks/fizzbuzz/task.toml | 3 + benchmarks/e2e/tasks/fizzbuzz/verify.sh | 8 + benchmarks/e2e/tasks/palindrome/task.toml | 3 + benchmarks/e2e/tasks/palindrome/verify.sh | 7 + .../e2e/tasks/subagent-delegation/task.toml | 3 + .../e2e/tasks/subagent-delegation/verify.sh | 12 + .../workdir/data/alpha.txt | 1 + .../subagent-delegation/workdir/data/beta.txt | 1 + .../workdir/data/gamma.txt | 1 + cmd/e2ebench/diff.go | 658 + cmd/e2ebench/diff_test.go | 48 + cmd/e2ebench/main.go | 430 + cmd/e2ebench/mutation.go | 143 + cmd/e2ebench/profile.go | 29 + cmd/e2ebench/profile_test.go | 36 + cmd/reasonix-plugin-example/main.go | 349 + cmd/reasonix/main.go | 20 + desktop/.gitignore | 45 + desktop/README.md | 283 + desktop/app.go | 9047 +++++ desktop/app_autosave_test.go | 660 + desktop/app_platform_test.go | 14 + desktop/app_session_dedup_test.go | 799 + desktop/app_test.go | 8718 +++++ desktop/appicon_asset_test.go | 183 + desktop/attach_dropped_test.go | 427 + desktop/autosave_warn_test.go | 51 + desktop/bot_bridge.go | 707 + desktop/bot_bridge_app.go | 184 + desktop/bot_bridge_test.go | 605 + desktop/bot_connection_app.go | 982 + desktop/bot_connection_app_test.go | 620 + desktop/bot_event_sink.go | 179 + desktop/bot_event_sink_test.go | 133 + desktop/bot_runtime_app.go | 415 + desktop/bot_runtime_app_test.go | 504 + desktop/bound_array_contract_test.go | 135 + desktop/build/appicon.png | Bin 0 -> 62398 bytes desktop/build/appicon.svg | 7 + desktop/build/darwin/entitlements.plist | 17 + desktop/build/darwin/icon.icns | Bin 0 -> 318836 bytes .../hicolor/128x128/apps/reasonix-desktop.png | Bin 0 -> 12244 bytes .../hicolor/16x16/apps/reasonix-desktop.png | Bin 0 -> 1456 bytes .../hicolor/24x24/apps/reasonix-desktop.png | Bin 0 -> 2153 bytes .../hicolor/256x256/apps/reasonix-desktop.png | Bin 0 -> 25141 bytes .../hicolor/32x32/apps/reasonix-desktop.png | Bin 0 -> 2844 bytes .../hicolor/48x48/apps/reasonix-desktop.png | Bin 0 -> 4448 bytes .../hicolor/512x512/apps/reasonix-desktop.png | Bin 0 -> 53710 bytes .../hicolor/64x64/apps/reasonix-desktop.png | Bin 0 -> 5958 bytes .../scalable/apps/reasonix-desktop.svg | 7 + desktop/build/linux/nfpm.yaml | 77 + desktop/build/linux/reasonix.desktop | 9 + desktop/build/windows/installer/project.nsi | 213 + desktop/capdiag_app.go | 41 + desktop/capdiag_app_test.go | 106 + desktop/checkpoints_cancode_test.go | 154 + desktop/cmd/sign/main.go | 242 + desktop/cmd/sign/main_test.go | 126 + desktop/cmd/update-helper/args.go | 11 + desktop/cmd/update-helper/args_test.go | 17 + desktop/cmd/update-helper/main_other.go | 13 + desktop/cmd/update-helper/main_windows.go | 108 + desktop/crash_app.go | 275 + desktop/crash_app_test.go | 207 + desktop/crash_pending.go | 113 + desktop/crash_pending_test.go | 191 + desktop/deferred_rebuild.go | 384 + desktop/devinfo.go | 61 + desktop/devinfo_darwin.go | 35 + desktop/devinfo_linux.go | 26 + desktop/devinfo_test.go | 40 + desktop/devinfo_windows.go | 37 + desktop/dotenv.go | 133 + desktop/dotenv_test.go | 85 + desktop/encoded_file.go | 7 + desktop/frontend/.gitignore | 18 + desktop/frontend/.npmrc | 3 + desktop/frontend/dist/.gitkeep | 1 + desktop/frontend/index.html | 12 + desktop/frontend/package.json | 50 + desktop/frontend/pnpm-lock.yaml | 3348 ++ desktop/frontend/pnpm-workspace.yaml | 5 + .../mixkit-correct-answer-tone-2870.wav | Bin 0 -> 345970 bytes .../mixkit-positive-notification-951.wav | Bin 0 -> 493892 bytes .../mixkit-software-interface-back-2575.wav | Bin 0 -> 234556 bytes .../mixkit-software-interface-start-2574.wav | Bin 0 -> 414076 bytes desktop/frontend/scripts/check-css-syntax.mjs | 140 + .../frontend/scripts/check-z-index-tokens.mjs | 31 + .../frontend/scripts/test-todo-visibility.mjs | 172 + desktop/frontend/src/App.tsx | 4111 ++ .../anchored-popover-scroll.test.tsx | 129 + .../src/__tests__/app-chrome-tabs.test.ts | 446 + .../approval-modal-file-reference.test.tsx | 564 + .../src/__tests__/ask-card-layout.test.ts | 375 + .../src/__tests__/asset-stub-for-tests.ts | 3 + .../frontend/src/__tests__/at-matches.test.ts | 121 + .../src/__tests__/attachment-display.test.ts | 72 + .../src/__tests__/bridge-breadcrumbs.test.ts | 85 + .../__tests__/bridge-drag-rejection.test.ts | 78 + .../bridge-mock-approval-mode.test.ts | 42 + .../src/__tests__/bundle-contract.test.ts | 77 + .../capabilities-panel-actions.test.ts | 666 + .../code-block-copy-position.test.tsx | 86 + .../src/__tests__/command-palette-css.test.ts | 88 + .../command-palette-interactions.test.tsx | 138 + .../composer-context-menu-clipboard.test.tsx | 289 + .../__tests__/composer-goal-toggle.test.tsx | 1799 + .../src/__tests__/composer-history.test.ts | 258 + .../composer-image-capability.test.tsx | 387 + .../src/__tests__/composer-keyboard.test.ts | 69 + .../src/__tests__/composer-profile.test.ts | 213 + .../src/__tests__/composer-run-strip.test.tsx | 462 + .../__tests__/composer-session-draft.test.tsx | 680 + .../__tests__/context-panel-breakdown.test.ts | 320 + .../__tests__/context-window-ring.test.tsx | 206 + .../src/__tests__/crash-reporting.test.ts | 278 + .../src/__tests__/decision-surface.test.tsx | 260 + .../__tests__/diagnostics-settings.test.tsx | 247 + .../src/__tests__/diff-rendering.test.ts | 295 + .../src/__tests__/edit-replay.test.ts | 139 + .../src/__tests__/font-availability.test.ts | 71 + .../src/__tests__/heartbeat-next-run.test.ts | 91 + .../history-performance-benchmark.tsx | 162 + .../history-recovery-copies.test.tsx | 223 + .../src/__tests__/history-tool-status.test.ts | 162 + .../src/__tests__/keyboard-shortcuts.test.ts | 101 + .../__tests__/layout-style-defaults.test.ts | 50 + .../src/__tests__/math-golden.test.ts | 675 + .../memory-citation-visibility.test.ts | 39 + .../__tests__/memory-compiler-display.test.ts | 44 + .../src/__tests__/mermaid-rendering.test.tsx | 248 + .../__tests__/message-pasted-blocks.test.ts | 39 + .../message-reasoning-panel.test.tsx | 80 + .../__tests__/message-selection-copy.test.ts | 93 + .../__tests__/new-session-load-race.test.tsx | 291 + .../__tests__/open-topic-coalescing.test.tsx | 227 + .../__tests__/project-tree-runtime.test.ts | 260 + .../__tests__/provider-model-refresh.test.ts | 171 + .../__tests__/ready-event-meta-sync.test.tsx | 210 + .../__tests__/ready-meta-reconcile.test.tsx | 220 + .../src/__tests__/reasoning-display.test.ts | 45 + .../__tests__/recovery-banner-privacy.test.ts | 31 + .../recovery-quiet-notifications.test.ts | 62 + .../src/__tests__/render-optimization.test.ts | 207 + .../src/__tests__/resize-drag.test.ts | 105 + .../__tests__/scroll-content-observer.test.ts | 85 + .../src/__tests__/scroll-manager.test.tsx | 151 + .../src/__tests__/send-failed.test.ts | 194 + .../__tests__/session-title-contract.test.ts | 113 + .../settings-refresh-snapshot.test.tsx | 440 + desktop/frontend/src/__tests__/sound.test.ts | 64 + .../startup-settings-contract.test.ts | 44 + .../__tests__/statusbar-workspace.test.tsx | 93 + .../__tests__/tab-switch-hydration.test.tsx | 707 + .../frontend/src/__tests__/text-size.test.ts | 31 + .../__tests__/theme-auto-background.test.ts | 118 + .../src/__tests__/tool-approval-mode.test.ts | 39 + .../tool-card-error-details.test.tsx | 114 + .../src/__tests__/tool-data-archive.test.ts | 340 + .../src/__tests__/tool-subject.test.ts | 27 + .../transcript-fold-preference.test.tsx | 132 + .../src/__tests__/transcript-grouping.test.ts | 181 + .../__tests__/transcript-process-fold.test.ts | 231 + .../transcript-selection-menu.test.tsx | 229 + .../src/__tests__/turn-action-copy.test.ts | 51 + .../typography-overflow-contract.test.ts | 223 + .../use-controller-cancel-reconcile.test.tsx | 201 + .../src/__tests__/use-controller-meta.test.ts | 591 + .../use-controller-send-fallback.test.tsx | 163 + .../use-controller-stream-progress.test.ts | 96 + .../src/__tests__/wails-resize-fix.test.tsx | 124 + .../workspace-changes-errors.test.tsx | 378 + .../src/__tests__/workspace-layout.test.ts | 156 + .../__tests__/workspace-preview-css.test.ts | 99 + .../src/__tests__/workspace-split.test.ts | 257 + desktop/frontend/src/assets/logo-symbol.svg | 16 + desktop/frontend/src/assets/logo-wordmark.svg | 24 + desktop/frontend/src/assets/logo.svg | 32 + .../src/components/AnchoredPopover.tsx | 187 + desktop/frontend/src/components/AppChrome.tsx | 198 + .../frontend/src/components/ApprovalModal.tsx | 574 + desktop/frontend/src/components/ArgMenu.tsx | 45 + desktop/frontend/src/components/AskCard.tsx | 358 + .../src/components/CapabilitiesPanel.tsx | 2520 ++ .../src/components/ClearContextCard.tsx | 138 + .../frontend/src/components/CodeViewer.tsx | 37 + .../src/components/CommandPalette.tsx | 320 + desktop/frontend/src/components/Composer.tsx | 3735 ++ .../src/components/ComposerContextCard.tsx | 86 + .../frontend/src/components/ContextMenu.tsx | 139 + .../frontend/src/components/ContextPanel.tsx | 731 + .../src/components/ContextWindowRing.tsx | 218 + .../frontend/src/components/CopyButton.tsx | 114 + .../components/DiagnosticsSettingsPage.tsx | 324 + desktop/frontend/src/components/DiffView.tsx | 28 + .../src/components/EffortSwitcher.tsx | 97 + .../frontend/src/components/ErrorBoundary.tsx | 18 + .../src/components/FileReferenceMenu.tsx | 217 + .../frontend/src/components/FloatingMenu.tsx | 73 + .../frontend/src/components/HistoryPanel.tsx | 727 + .../frontend/src/components/ImageViewer.tsx | 99 + .../src/components/InlineConfirmButton.tsx | 53 + .../frontend/src/components/InlineDiff.tsx | 107 + .../src/components/InvocationBadge.tsx | 53 + desktop/frontend/src/components/Markdown.tsx | 17 + .../src/components/MarkdownRenderer.tsx | 156 + .../frontend/src/components/MemoryPanel.tsx | 1700 + .../src/components/MermaidDiagram.tsx | 657 + desktop/frontend/src/components/Message.tsx | 923 + .../src/components/ModalCloseButton.tsx | 20 + .../frontend/src/components/ModelSwitcher.tsx | 167 + .../src/components/OnboardingOverlay.tsx | 119 + .../frontend/src/components/ProcessCard.tsx | 168 + .../frontend/src/components/ProjectTree.tsx | 2113 ++ .../frontend/src/components/PromptShelf.tsx | 224 + .../frontend/src/components/ReadOnlyBatch.tsx | 47 + .../src/components/ResizableDrawer.tsx | 160 + .../src/components/RichComposerInput.tsx | 427 + .../frontend/src/components/SettingsPanel.tsx | 7065 ++++ .../src/components/ShortcutComboDisplay.tsx | 38 + .../src/components/ShortcutsCheatsheet.tsx | 107 + desktop/frontend/src/components/SlashMenu.tsx | 106 + .../frontend/src/components/SoundSelect.tsx | 85 + .../frontend/src/components/StartupSplash.tsx | 84 + desktop/frontend/src/components/StatusBar.tsx | 336 + .../src/components/SubagentsPanel.tsx | 762 + desktop/frontend/src/components/TabBar.tsx | 300 + desktop/frontend/src/components/TodoPanel.tsx | 152 + desktop/frontend/src/components/ToolCard.tsx | 296 + desktop/frontend/src/components/ToolGroup.tsx | 141 + desktop/frontend/src/components/Tooltip.tsx | 185 + .../frontend/src/components/Transcript.tsx | 1715 + .../components/TranscriptSelectionMenu.tsx | 71 + .../src/components/UndoRewindBanner.tsx | 52 + .../frontend/src/components/UpdateBanner.tsx | 95 + .../frontend/src/components/VirtualMenu.tsx | 53 + desktop/frontend/src/components/Welcome.tsx | 77 + .../src/components/WorkspacePanel.tsx | 1823 + .../src/components/editors/HljsCode.tsx | 22 + .../src/components/editors/HljsDiff.tsx | 91 + .../frontend/src/components/latexNormalize.ts | 399 + .../frontend/src/components/mathClassify.ts | 59 + .../frontend/src/components/mathNormalize.ts | 323 + .../frontend/src/components/youngDiagrams.ts | 370 + .../features/heartbeat/HeartbeatPanel.tsx | 1213 + .../features/heartbeat/heartbeat.bridge.ts | 22 + .../custom/features/heartbeat/heartbeat.css | 1231 + .../features/heartbeat/heartbeat.types.ts | 19 + desktop/frontend/src/lib/array.ts | 3 + desktop/frontend/src/lib/atMatches.ts | 42 + desktop/frontend/src/lib/attachDedup.ts | 70 + desktop/frontend/src/lib/attachmentDisplay.ts | 155 + desktop/frontend/src/lib/breadcrumbs.ts | 53 + desktop/frontend/src/lib/bridge.ts | 4034 ++ desktop/frontend/src/lib/clipboard.ts | 57 + desktop/frontend/src/lib/composerDraftKey.ts | 22 + desktop/frontend/src/lib/composerHistory.ts | 165 + desktop/frontend/src/lib/composerKeyboard.ts | 69 + desktop/frontend/src/lib/composerProfile.ts | 269 + desktop/frontend/src/lib/crash.ts | 863 + desktop/frontend/src/lib/diff.ts | 128 + desktop/frontend/src/lib/displayMode.ts | 31 + desktop/frontend/src/lib/dpiScale.ts | 79 + desktop/frontend/src/lib/editReplay.ts | 57 + desktop/frontend/src/lib/fontAvailability.ts | 95 + desktop/frontend/src/lib/fontFamily.ts | 108 + desktop/frontend/src/lib/generative-music.ts | 302 + desktop/frontend/src/lib/gsapAnimations.ts | 21 + desktop/frontend/src/lib/guardianEvents.ts | 12 + desktop/frontend/src/lib/highlight.ts | 121 + desktop/frontend/src/lib/i18n.tsx | 139 + desktop/frontend/src/lib/invocationDisplay.ts | 292 + desktop/frontend/src/lib/keyboardShortcuts.ts | 499 + desktop/frontend/src/lib/lang.ts | 50 + desktop/frontend/src/lib/layoutPreferences.ts | 98 + .../frontend/src/lib/mcpServerLifecycle.ts | 36 + .../src/lib/memoryCitationVisibility.ts | 14 + .../frontend/src/lib/memoryCompilerDisplay.ts | 18 + .../frontend/src/lib/messageSelectionCopy.ts | 128 + desktop/frontend/src/lib/money.ts | 61 + .../frontend/src/lib/openTopicCoalescing.ts | 75 + .../frontend/src/lib/processFoldPreference.ts | 21 + desktop/frontend/src/lib/projectColors.ts | 24 + desktop/frontend/src/lib/providerModels.ts | 99 + desktop/frontend/src/lib/rafBatch.ts | 49 + desktop/frontend/src/lib/reasoningDisplay.ts | 39 + desktop/frontend/src/lib/resizeDrag.ts | 60 + .../frontend/src/lib/scrollContentObserver.ts | 34 + desktop/frontend/src/lib/session.ts | 36 + desktop/frontend/src/lib/sessionExport.tsx | 470 + desktop/frontend/src/lib/shellExpand.tsx | 43 + desktop/frontend/src/lib/sound.ts | 211 + desktop/frontend/src/lib/statusBarItems.ts | 33 + desktop/frontend/src/lib/textSize.ts | 38 + desktop/frontend/src/lib/theme.ts | 213 + desktop/frontend/src/lib/toast.tsx | 78 + desktop/frontend/src/lib/todoVisibility.ts | 90 + desktop/frontend/src/lib/toolApprovalMode.ts | 17 + desktop/frontend/src/lib/tools.ts | 233 + desktop/frontend/src/lib/topicShortcuts.ts | 132 + .../frontend/src/lib/transcriptGrouping.ts | 204 + desktop/frontend/src/lib/turnActionCopy.ts | 6 + desktop/frontend/src/lib/types.ts | 1418 + desktop/frontend/src/lib/useController.ts | 2727 ++ .../frontend/src/lib/useEntranceAnimation.ts | 92 + desktop/frontend/src/lib/useGSAPCollapse.ts | 102 + .../frontend/src/lib/useMountTransition.ts | 97 + desktop/frontend/src/lib/useScrollManager.ts | 306 + desktop/frontend/src/lib/useUpdater.ts | 110 + desktop/frontend/src/lib/useWailsResizeFix.ts | 115 + desktop/frontend/src/lib/windowState.ts | 101 + desktop/frontend/src/lib/workspaceDrag.ts | 31 + desktop/frontend/src/lib/workspaceLayout.ts | 72 + .../frontend/src/lib/workspacePreviewTabs.ts | 12 + desktop/frontend/src/lib/workspaceSplit.ts | 130 + .../frontend/src/lib/workspaceTreeReveal.ts | 18 + .../frontend/src/lib/workspaceTreeSearch.ts | 30 + desktop/frontend/src/locales/en.ts | 2358 ++ desktop/frontend/src/locales/zh-TW.ts | 2354 ++ desktop/frontend/src/locales/zh.ts | 2358 ++ desktop/frontend/src/main.tsx | 92 + desktop/frontend/src/store/layout.ts | 189 + desktop/frontend/src/store/overlays.ts | 74 + desktop/frontend/src/store/setState.ts | 10 + desktop/frontend/src/styles.css | 31415 ++++++++++++++++ desktop/frontend/tsconfig.json | 24 + desktop/frontend/tsconfig.node.json | 11 + desktop/frontend/tsconfig.test.json | 8 + desktop/frontend/vite-env.d.ts | 1 + desktop/frontend/vite.config.ts | 154 + desktop/go.mod | 73 + desktop/go.sum | 201 + desktop/hang_watchdog.go | 172 + desktop/hang_watchdog_darwin.go | 55 + desktop/hang_watchdog_other.go | 11 + desktop/hang_watchdog_test.go | 99 + desktop/heartbeat.go | 885 + desktop/heartbeat_test.go | 527 + desktop/history_perf_test.go | 109 + desktop/history_test.go | 1123 + desktop/history_todo_test.go | 135 + desktop/hooks_settings_app.go | 187 + desktop/internal/update/manifest.go | 43 + desktop/internal/update/update_test.go | 75 + desktop/internal/update/verify.go | 40 + desktop/main.go | 197 + desktop/main_test.go | 102 + desktop/memory_suggestions.go | 644 + desktop/memory_suggestions_compiler.go | 82 + desktop/memory_suggestions_compiler_test.go | 240 + desktop/memory_suggestions_test.go | 338 + desktop/menu.go | 45 + desktop/metrics_app.go | 547 + desktop/metrics_app_test.go | 249 + desktop/new_session_inherit_test.go | 146 + desktop/notifications_test.go | 69 + desktop/nvidia_wayland_linux.go | 45 + desktop/open_workspace_darwin.go | 9 + desktop/open_workspace_unix.go | 9 + desktop/open_workspace_windows.go | 17 + desktop/plugin_packages_app.go | 296 + desktop/plugin_packages_app_test.go | 46 + desktop/race_regression_test.go | 480 + desktop/recovery_gc.go | 126 + desktop/recovery_gc_test.go | 155 + desktop/repeat_guard_e2e_test.go | 88 + desktop/runtime_rebuilt_event_test.go | 144 + desktop/session_errors.go | 45 + desktop/session_errors_test.go | 75 + desktop/session_errors_unix.go | 17 + desktop/session_errors_windows.go | 21 + desktop/session_key_test.go | 137 + desktop/session_prompt.go | 93 + desktop/session_prompt_bytes_test.go | 198 + desktop/session_prompt_test.go | 148 + desktop/session_recovery_cleanup_test.go | 414 + desktop/sessions.go | 1166 + desktop/sessions_lease_guard_test.go | 137 + desktop/sessions_test.go | 1214 + desktop/settings_app.go | 3160 ++ desktop/settings_app_test.go | 1459 + desktop/shared_host.go | 177 + desktop/shared_host_test.go | 55 + desktop/single_instance.go | 21 + desktop/single_instance_test.go | 36 + desktop/skills_app_test.go | 431 + desktop/subagents_app.go | 424 + desktop/subagents_app_test.go | 877 + desktop/system_quit.go | 13 + desktop/system_quit_darwin.go | 24 + desktop/system_quit_darwin.m | 44 + desktop/system_quit_stub.go | 5 + desktop/tab_event_sink_test.go | 263 + desktop/tab_goal_restore_test.go | 58 + desktop/tab_profile_test.go | 695 + desktop/tab_scoped_actions_test.go | 197 + desktop/tabs.go | 8199 ++++ desktop/tabs_order_test.go | 1190 + desktop/tabs_planner_notice_test.go | 34 + desktop/tabs_runtime_status_test.go | 274 + desktop/tabs_sink_context_test.go | 46 + desktop/tabs_telemetry_test.go | 399 + desktop/tabs_topic_test.go | 3398 ++ desktop/telemetry_app.go | 96 + desktop/telemetry_app_test.go | 66 + desktop/temphelper_test.go | 35 + desktop/tray.go | 161 + desktop/tray_icon_unix.go | 8 + desktop/tray_icon_windows.go | 11 + desktop/tray_icon_windows_test.go | 14 + desktop/tray_loop_external.go | 11 + desktop/tray_loop_windows.go | 22 + desktop/tray_loop_windows_test.go | 30 + desktop/tray_supported_darwin.go | 8 + desktop/tray_supported_unix.go | 17 + desktop/tray_supported_windows.go | 5 + desktop/tray_test.go | 20 + desktop/updater.go | 636 + desktop/updater_app.go | 225 + desktop/updater_mac.go | 138 + desktop/updater_mac_stub.go | 12 + desktop/updater_other.go | 15 + desktop/updater_test.go | 428 + desktop/updater_windows.go | 72 + desktop/updater_windows_args.go | 28 + desktop/updater_windows_test.go | 29 + desktop/wails.json | 19 + desktop/window_controls.go | 41 + desktop/window_state.go | 86 + desktop/windows_sandbox_helper_test.go | 37 + desktop/windows_update_handoff_test.go | 81 + desktop/wire_test.go | 23 + desktop/workspace.go | 186 + desktop/workspace_changes.go | 436 + desktop/workspace_changes_test.go | 213 + desktop/workspace_test.go | 1653 + desktop/workspace_unix_test.go | 57 + desktop/zoom_factor.go | 87 + dev | 42 + docs/.nojekyll | 0 docs/BOT_GUIDE.md | 475 + docs/BOT_GUIDE.zh-CN.md | 438 + docs/CAPABILITY_DIAGNOSTICS.md | 254 + docs/CAPABILITY_DIAGNOSTICS.zh-CN.md | 219 + docs/CHECKPOINTS.md | 146 + docs/COLLABORATION_MODES.zh-CN.md | 133 + docs/CONFIG_PATHS.md | 282 + docs/CONFIG_PATHS.zh-CN.md | 226 + docs/DESKTOP_HOOKS.zh-CN.md | 257 + docs/GOAL_ENFORCEMENT.zh-CN.md | 140 + docs/GUIDE.md | 939 + docs/GUIDE.zh-CN.md | 744 + docs/MIGRATING.md | 153 + docs/PLUGIN_PACKAGES.md | 301 + docs/PLUGIN_PACKAGES.zh-CN.md | 270 + docs/REASONING_LANGUAGE.md | 118 + docs/REASONING_LANGUAGE.zh-CN.md | 97 + docs/REASONING_PROVIDERS.md | 57 + docs/RELEASING.md | 83 + docs/SESSION_MEMORY_RETRIEVAL.md | 243 + docs/SESSION_REFERENCE_ARCHITECTURE.md | 370 + docs/SPEC.md | 773 + docs/SUBAGENT_PROFILES.md | 173 + docs/SUBAGENT_PROFILES.zh-CN.md | 153 + docs/TASK_CONTRACT.md | 83 + docs/TASK_CONTRACT.zh-CN.md | 69 + docs/TOOL_APPROVAL_MODES.zh-CN.md | 109 + docs/TOOL_CONTRACT.md | 93 + docs/TOOL_CONTRACT.zh-CN.md | 77 + docs/assets/agent-skill-sources.png | Bin 0 -> 92294 bytes docs/assets/approval-authorization-mocks.svg | 198 + docs/assets/approval-mock-1.html | 168 + docs/assets/bot-feishu-approval.svg | 48 + docs/assets/bot-lark-yolo.svg | 47 + docs/assets/bot-qq-approval.svg | 48 + docs/assets/bot-weixin-text-commands.svg | 36 + .../assets/issue-2605-plan-approval-shelf.png | Bin 0 -> 90290 bytes .../assets/issue-2605-tool-approval-shelf.png | Bin 0 -> 78730 bytes docs/claude-desktop-layout-preview.png | Bin 0 -> 104120 bytes docs/cli-image-paste-effect.svg | 20 + docs/cli-theme-effect.svg | 49 + docs/desktop-theme-dark.jpg | Bin 0 -> 52824 bytes docs/desktop-theme-light.jpg | Bin 0 -> 51084 bytes docs/desktop-v2-layout-preview.png | Bin 0 -> 75220 bytes docs/desktop-v2-skills-preview.png | Bin 0 -> 81977 bytes docs/favicon.svg | 11 + docs/index.html | 293 + docs/logo.svg | 95 + docs/paste-folding-effect.svg | 30 + docs/production_checklist.md | 59 + docs/production_checklist.zh-CN.md | 57 + docs/project-topic-tabs-context.png | Bin 0 -> 199773 bytes docs/slash-command-views.svg | 49 + ...06-30-autoresearch-runtime-verification.md | 90 + ...-29-autoresearch-runtime-implementation.md | 94 + .../2026-06-29-autoresearch-runtime-design.md | 477 + go.mod | 61 + go.sum | 185 + internal/acp/client_integration_test.go | 433 + internal/acp/clientio.go | 158 + internal/acp/dispatch.go | 524 + internal/acp/dispatch_partial_test.go | 23 + internal/acp/dispatch_test.go | 539 + internal/acp/e2e_test.go | 634 + internal/acp/live_test.go | 110 + internal/acp/protocol.go | 676 + internal/acp/protocol_test.go | 335 + internal/acp/server.go | 331 + internal/acp/server_test.go | 1493 + internal/acp/service.go | 2061 + internal/acp/service_lease_test.go | 93 + internal/acp/service_lock_test.go | 339 + internal/acp/session_files_test.go | 50 + internal/acp/switch_recovery_test.go | 349 + internal/agent/agent.go | 3412 ++ internal/agent/argserror_test.go | 54 + internal/agent/ask.go | 166 + internal/agent/ask_test.go | 176 + internal/agent/branch.go | 438 + internal/agent/branch_test.go | 193 + internal/agent/cache_diagnostics_test.go | 91 + internal/agent/cache_shape.go | 121 + internal/agent/cache_shape_test.go | 37 + internal/agent/cachehit_e2e_test.go | 685 + internal/agent/cancel_test.go | 458 + internal/agent/canonical_path_test.go | 39 + internal/agent/canonical_todo_test.go | 206 + internal/agent/capability_gate.go | 312 + internal/agent/capability_gate_test.go | 56 + internal/agent/compact.go | 760 + internal/agent/compact_loop_e2e_test.go | 205 + internal/agent/compact_test.go | 826 + internal/agent/complete_step_e2e_test.go | 220 + internal/agent/coordinator.go | 916 + internal/agent/coordinator_test.go | 1632 + internal/agent/delivery_hardening_test.go | 258 + internal/agent/dispatch_test.go | 54 + internal/agent/empty_final_test.go | 119 + internal/agent/errors.go | 17 + internal/agent/errors_test.go | 11 + internal/agent/evidence_flow_test.go | 1171 + internal/agent/final_readiness_test.go | 198 + internal/agent/gate_test.go | 68 + internal/agent/goal_display.go | 32 + internal/agent/guards_test.go | 353 + internal/agent/hooks_test.go | 106 + internal/agent/listsessions_bench_test.go | 185 + internal/agent/listsessions_sidecar_test.go | 170 + internal/agent/loop_e2e_test.go | 777 + internal/agent/main_test.go | 11 + internal/agent/memory_compiler_context.go | 58 + internal/agent/memory_compiler_gate_test.go | 76 + internal/agent/memory_feedback_e2e_test.go | 168 + internal/agent/migrate.go | 986 + internal/agent/migrate_test.go | 1063 + internal/agent/nil_boundary_test.go | 56 + internal/agent/normalize.go | 26 + internal/agent/outcome_test.go | 33 + internal/agent/parallel_tasks.go | 348 + internal/agent/parallel_tasks_test.go | 334 + internal/agent/planmode_test.go | 720 + internal/agent/postllmcall_flow_test.go | 142 + internal/agent/preview.go | 180 + internal/agent/preview_test.go | 180 + internal/agent/prune.go | 289 + internal/agent/prune_test.go | 428 + internal/agent/reasoning_language.go | 294 + internal/agent/reasoning_language_test.go | 71 + internal/agent/recovery_gc.go | 190 + internal/agent/recovery_gc_test.go | 229 + internal/agent/repeat_guard_test.go | 173 + internal/agent/retry_e2e_test.go | 85 + internal/agent/review_report.go | 114 + internal/agent/save.go | 1896 + internal/agent/save_test.go | 2197 ++ internal/agent/secret_redaction_test.go | 35 + internal/agent/session.go | 203 + internal/agent/session_concurrency_test.go | 45 + internal/agent/session_content.go | 94 + internal/agent/session_events.go | 499 + internal/agent/session_events_test.go | 433 + internal/agent/session_lease.go | 285 + internal/agent/session_lease_test.go | 387 + internal/agent/session_lock_unix.go | 85 + internal/agent/session_lock_windows.go | 135 + internal/agent/session_lock_windows_test.go | 50 + internal/agent/session_meta_ledger_test.go | 652 + internal/agent/session_removal.go | 98 + internal/agent/session_removal_test.go | 83 + internal/agent/session_test.go | 474 + internal/agent/stale_anchor_guard_test.go | 138 + internal/agent/steer_flush_test.go | 146 + internal/agent/steer_test.go | 117 + internal/agent/storm_test.go | 289 + internal/agent/subagent_cleanup_test.go | 69 + internal/agent/subagent_registry_test.go | 257 + internal/agent/subagent_store.go | 800 + internal/agent/subagent_store_test.go | 715 + internal/agent/task.go | 1086 + internal/agent/task_classifier.go | 337 + internal/agent/task_classifier_cache_test.go | 138 + internal/agent/task_classifier_test.go | 160 + internal/agent/task_test.go | 882 + internal/agent/testutil/mock_provider.go | 204 + internal/agent/testutil/mock_provider_test.go | 55 + internal/agent/textsink.go | 233 + internal/agent/textsink_partial_test.go | 26 + internal/agent/textsink_test.go | 87 + internal/agent/toolimages_test.go | 68 + internal/agent/usage_test.go | 72 + internal/agent/usecapability.go | 677 + internal/agent/usecapability_test.go | 491 + internal/agent/width.go | 41 + internal/agent/width_test.go | 50 + internal/autoresearch/bounded_store_test.go | 239 + internal/autoresearch/readiness.go | 72 + internal/autoresearch/schema.go | 52 + internal/autoresearch/store.go | 869 + internal/autoresearch/store_test.go | 545 + internal/autoresearch/summary.go | 75 + internal/autoresearch/task.go | 159 + internal/billing/balance.go | 128 + internal/billing/balance_extra_test.go | 145 + internal/billing/balance_test.go | 78 + internal/boot/boot.go | 2004 + internal/boot/boot_test.go | 3589 ++ internal/boot/helper_test.go | 18 + internal/boot/model_error_test.go | 191 + internal/boot/prompt_stability_test.go | 85 + internal/boot/subagent_model_test.go | 173 + internal/boot/temphelper_test.go | 37 + internal/boot/token_profile.go | 296 + internal/boot/token_profile_lock_test.go | 108 + internal/bot/connloop.go | 117 + internal/bot/connloop_test.go | 132 + internal/bot/control_server.go | 275 + internal/bot/desktop.go | 268 + internal/bot/desktop_test.go | 333 + internal/bot/feishu/feishu.go | 1011 + internal/bot/feishu/feishu_test.go | 561 + internal/bot/feishu/inbound.go | 233 + internal/bot/feishu/outbound.go | 45 + internal/bot/feishu/outbound_media.go | 244 + internal/bot/feishu/outbound_media_test.go | 150 + internal/bot/feishu/retry.go | 82 + internal/bot/gateway.go | 2379 ++ internal/bot/gateway_lock_test.go | 114 + internal/bot/gateway_test.go | 1936 + internal/bot/hash.go | 15 + internal/bot/media.go | 168 + internal/bot/media_test.go | 108 + internal/bot/pairing.go | 371 + internal/bot/pairing_test.go | 51 + internal/bot/project_index.go | 828 + internal/bot/qq/adapter.go | 84 + internal/bot/qq/gateway.go | 748 + internal/bot/qq/gateway_test.go | 355 + internal/bot/render.go | 588 + internal/bot/render_test.go | 447 + internal/bot/session.go | 419 + internal/bot/session_test.go | 224 + internal/bot/turn_dispatch_test.go | 127 + internal/bot/types.go | 204 + internal/bot/types_test.go | 21 + internal/bot/weixin/weixin.go | 665 + internal/bot/weixin/weixin_login.go | 241 + internal/bot/weixin/weixin_test.go | 176 + internal/botruntime/runtime.go | 726 + internal/botruntime/runtime_test.go | 432 + internal/capability/audit.go | 217 + internal/capability/capability.go | 395 + internal/capability/capability_test.go | 146 + internal/capability/catalog.go | 277 + internal/capability/catalog_cache_test.go | 180 + internal/capability/ledger.go | 273 + internal/capability/ledger_test.go | 76 + internal/capability/semantic.go | 292 + internal/capability/semantic_test.go | 68 + internal/capdiag/collect.go | 788 + internal/capdiag/collect_test.go | 305 + internal/capdiag/live.go | 153 + internal/capdiag/paths.go | 116 + internal/capdiag/render.go | 126 + internal/capdiag/sanitize_test.go | 57 + internal/capdiag/types.go | 191 + internal/checkpoint/checkpoint.go | 350 + internal/checkpoint/checkpoint_test.go | 349 + internal/cli/acp.go | 335 + internal/cli/acp_test.go | 291 + internal/cli/autoplan.go | 68 + internal/cli/bot.go | 582 + internal/cli/bot_test.go | 501 + internal/cli/box.go | 62 + internal/cli/box_test.go | 92 + internal/cli/branch.go | 153 + internal/cli/branch_test.go | 30 + internal/cli/cache_tag_test.go | 54 + internal/cli/chat_attachment_test.go | 94 + internal/cli/chat_ghost_test.go | 32 + internal/cli/chat_render_test.go | 444 + internal/cli/chat_tui.go | 4201 +++ internal/cli/chat_tui_paste.go | 280 + internal/cli/chat_tui_test.go | 3055 ++ internal/cli/chooser.go | 324 + internal/cli/clear_confirm.go | 80 + internal/cli/cli.go | 2018 + internal/cli/cli_test.go | 1624 + internal/cli/compaction_test.go | 46 + internal/cli/complete.go | 682 + internal/cli/complete_test.go | 691 + internal/cli/consecutive_tool_markers_test.go | 112 + internal/cli/copy_export_test.go | 195 + internal/cli/copy_picker.go | 79 + internal/cli/diffview.go | 264 + internal/cli/diffview_test.go | 82 + internal/cli/doctor.go | 209 + internal/cli/doctor_capabilities.go | 87 + internal/cli/doctor_capabilities_test.go | 95 + internal/cli/doctor_test.go | 160 + internal/cli/effort.go | 161 + internal/cli/gitstatus.go | 236 + internal/cli/gitstatus_test.go | 165 + internal/cli/help_view.go | 143 + internal/cli/hide_other.go | 6 + internal/cli/hide_windows.go | 17 + internal/cli/hooks_view.go | 36 + internal/cli/interject_test.go | 50 + internal/cli/language.go | 155 + internal/cli/latex.go | 405 + internal/cli/latex_test.go | 78 + internal/cli/math_e2e_test.go | 69 + internal/cli/mathnode.go | 66 + internal/cli/mcp.go | 416 + internal/cli/mcp_import_picker.go | 105 + internal/cli/mcp_manager.go | 439 + internal/cli/mcp_manager_actions.go | 488 + internal/cli/mcp_manager_actions_test.go | 23 + internal/cli/mcp_manager_view.go | 364 + internal/cli/mcp_test.go | 767 + internal/cli/mcp_view.go | 227 + internal/cli/md.go | 518 + internal/cli/md_cjk_test.go | 166 + internal/cli/md_test.go | 116 + internal/cli/memory.go | 34 + internal/cli/memory_v5.go | 121 + internal/cli/memory_view.go | 63 + internal/cli/model.go | 185 + internal/cli/model_test.go | 102 + internal/cli/model_view.go | 22 + internal/cli/output_style_view.go | 29 + internal/cli/plugin.go | 367 + internal/cli/plugin_slash.go | 47 + internal/cli/plugin_slash_test.go | 47 + internal/cli/plugin_test.go | 54 + internal/cli/provider.go | 135 + internal/cli/reasoning_language.go | 85 + internal/cli/rename.go | 58 + internal/cli/rename_test.go | 84 + internal/cli/render_edge_test.go | 53 + internal/cli/resume.go | 138 + internal/cli/resume_picker.go | 136 + internal/cli/resume_test.go | 318 + internal/cli/retry_indicator_test.go | 41 + internal/cli/review.go | 198 + internal/cli/review_test.go | 132 + internal/cli/rewind.go | 213 + internal/cli/rewind_test.go | 53 + internal/cli/run_metrics.go | 253 + internal/cli/run_metrics_test.go | 222 + internal/cli/select.go | 512 + internal/cli/select_test.go | 126 + internal/cli/session_lease.go | 109 + internal/cli/session_lease_test.go | 325 + internal/cli/setup_manager.go | 926 + internal/cli/setup_manager_test.go | 746 + internal/cli/skill_hooks.go | 325 + internal/cli/skill_picker.go | 691 + internal/cli/skill_picker_test.go | 963 + internal/cli/skill_picker_view.go | 437 + internal/cli/skill_view.go | 84 + internal/cli/skillslash_test.go | 70 + internal/cli/slash_test.go | 18 + internal/cli/statusline_test.go | 368 + internal/cli/style.go | 54 + internal/cli/subagent.go | 540 + internal/cli/subagent_test.go | 236 + internal/cli/switch_recovery_test.go | 478 + internal/cli/theme.go | 556 + internal/cli/theme_osc_other.go | 7 + internal/cli/theme_osc_unix.go | 70 + internal/cli/theme_test.go | 271 + internal/cli/todopanel_test.go | 50 + internal/cli/toolcard.go | 172 + internal/cli/toolcard_test.go | 40 + internal/cli/transcript.go | 270 + internal/cli/transcript_test.go | 78 + internal/cli/upgrade.go | 421 + internal/cli/upgrade_test.go | 237 + internal/cli/view_helpers.go | 84 + internal/cli/view_helpers_test.go | 183 + internal/cli/width_test.go | 63 + internal/cli/work_mode.go | 175 + internal/cli/work_mode_test.go | 266 + internal/command/command.go | 218 + internal/command/command_extra_test.go | 177 + internal/command/command_test.go | 192 + internal/command/inspect.go | 156 + internal/command/slashtool.go | 133 + internal/command/slashtool_test.go | 163 + internal/command/symlink_test.go | 55 + internal/config/backfill_test.go | 940 + internal/config/bounded_component_test.go | 50 + internal/config/ccswitch.go | 275 + internal/config/ccswitch_test.go | 122 + internal/config/chat_model_test.go | 166 + internal/config/commanddirs_test.go | 30 + internal/config/config.go | 1863 + internal/config/configured_test.go | 70 + internal/config/credentials.go | 696 + .../config/credentials_keyring_default.go | 18 + internal/config/credentials_keyring_unix.go | 50 + internal/config/decode.go | 15 + internal/config/default_test.go | 47 + internal/config/dotenv.go | 206 + internal/config/dotenv_test.go | 839 + internal/config/edit.go | 1732 + internal/config/edit_test.go | 1899 + internal/config/effort.go | 466 + internal/config/effort_test.go | 328 + internal/config/expand.go | 90 + internal/config/expand_test.go | 107 + internal/config/fetch.go | 140 + internal/config/fetch_test.go | 245 + internal/config/load.go | 1279 + internal/config/load_project_default_test.go | 184 + internal/config/loadedit_test.go | 168 + internal/config/mcp_command.go | 109 + internal/config/mcpjson.go | 530 + internal/config/mcpjson_test.go | 799 + internal/config/migrate.go | 723 + internal/config/migrate_test.go | 1066 + internal/config/model_fallback_test.go | 201 + internal/config/mutate.go | 27 + internal/config/mutate_test.go | 139 + internal/config/paths.go | 548 + internal/config/plugin_packages.go | 155 + internal/config/plugin_packages_test.go | 121 + internal/config/pricing.go | 258 + internal/config/provider_presets.go | 890 + internal/config/provider_presets_test.go | 479 + internal/config/proxy_test.go | 36 + internal/config/render.go | 1683 + internal/config/render_test.go | 1372 + internal/config/sandbox_test.go | 20 + internal/config/subagent_model_test.go | 55 + internal/config/system_prompt_test.go | 73 + internal/config/tools_test.go | 124 + internal/config/vision.go | 112 + internal/config/workspace_slug_test.go | 30 + internal/control/admission_test.go | 250 + internal/control/approval.go | 387 + internal/control/approval_e2e_test.go | 159 + internal/control/attachments.go | 527 + internal/control/attachments_test.go | 251 + internal/control/auto_plan.go | 313 + internal/control/auto_plan_classifier.go | 95 + internal/control/auto_plan_classifier_test.go | 76 + internal/control/auto_plan_e2e_test.go | 191 + internal/control/auto_plan_test.go | 169 + internal/control/branches.go | 179 + internal/control/branches_test.go | 353 + internal/control/capability.go | 125 + internal/control/capability_test.go | 168 + internal/control/checkpoint.go | 159 + internal/control/close_idempotent_test.go | 41 + .../control/config_write_approval_test.go | 55 + internal/control/controller.go | 5260 +++ internal/control/controller_lock_test.go | 83 + internal/control/controller_test.go | 4280 +++ internal/control/errmsg.go | 151 + internal/control/errmsg_test.go | 146 + internal/control/goal.go | 532 + internal/control/goal_concurrency_test.go | 71 + internal/control/goal_test.go | 1161 + internal/control/imagecompress.go | 77 + internal/control/imagecompress_test.go | 75 + internal/control/input.go | 670 + internal/control/input_test.go | 1477 + internal/control/inputimages_test.go | 140 + internal/control/main_test.go | 15 + internal/control/mcp.go | 171 + internal/control/memory.go | 175 + internal/control/memory_test.go | 104 + internal/control/plan_seed_test.go | 191 + internal/control/port.go | 244 + internal/control/refs.go | 1228 + internal/control/refs_test.go | 855 + internal/control/replay_pending_test.go | 92 + internal/control/resume_prune_test.go | 127 + internal/control/rewind_e2e_test.go | 146 + internal/control/runtime_status_test.go | 132 + internal/control/session_artifacts_test.go | 63 + internal/control/session_lease_keeper.go | 116 + internal/control/session_lease_keeper_test.go | 183 + internal/control/sessionpath.go | 45 + internal/control/shell_kill_other.go | 21 + internal/control/shell_kill_other_test.go | 20 + internal/control/shell_kill_windows.go | 27 + internal/control/shell_test.go | 287 + internal/control/skill.go | 74 + internal/control/slash.go | 815 + internal/control/slash_test.go | 355 + internal/control/steer_fallback_test.go | 87 + internal/control/synthetic_contract_test.go | 84 + internal/control/turn_orchestrator.go | 417 + internal/control/turn_orchestrator_test.go | 693 + internal/control/yolo_test.go | 1105 + internal/diff/diff.go | 214 + internal/diff/diff_extra_test.go | 182 + internal/diff/diff_test.go | 168 + internal/diff/largediff_test.go | 71 + internal/doctor/quality.go | 332 + internal/doctor/quality_test.go | 131 + internal/doctor/report.go | 412 + internal/doctor/report_test.go | 157 + internal/doctor/session_bundle.go | 451 + internal/doctor/session_bundle_test.go | 143 + internal/doctor/session_redact.go | 423 + internal/doctor/session_redact_test.go | 232 + internal/doctor/skill_health.go | 138 + internal/doctor/skill_health_test.go | 58 + internal/environment/probe.go | 474 + internal/environment/probe_test.go | 383 + internal/environment/probe_windows_test.go | 24 + internal/environment/snapshot.go | 142 + internal/environment/snapshot_test.go | 144 + internal/event/event.go | 348 + internal/event/event_test.go | 260 + internal/event/fanout.go | 28 + internal/event/sync.go | 41 + internal/eventwire/wire.go | 351 + internal/eventwire/wire_test.go | 333 + internal/evidence/child.go | 77 + internal/evidence/child_test.go | 106 + internal/evidence/commandmatch.go | 102 + internal/evidence/commandmatch_test.go | 39 + internal/evidence/evidence.go | 1594 + internal/evidence/evidence_test.go | 639 + internal/evidence/meta.go | 38 + internal/evidence/readiness_audit.go | 27 + internal/evidence/readiness_test.go | 62 + internal/evidence/review_report.go | 262 + internal/evidence/risk.go | 195 + internal/fileref/search.go | 162 + internal/fileref/search_test.go | 154 + internal/fileutil/atomicwrite.go | 135 + internal/fileutil/atomicwrite_test.go | 214 + internal/fileutil/encoding/encoding.go | 269 + internal/fileutil/encoding/encoding_test.go | 319 + internal/fileutil/globset.go | 78 + internal/fileutil/globset_test.go | 41 + internal/fileutil/replacefallback_other.go | 18 + internal/fileutil/replacefallback_windows.go | 19 + .../fileutil/replacefallback_windows_test.go | 29 + internal/frontmatter/frontmatter.go | 146 + internal/frontmatter/frontmatter_test.go | 180 + internal/frontmatter/list_test.go | 42 + internal/guardian/guardian.go | 553 + internal/guardian/guardian_policy.md | 34 + internal/guardian/guardian_test.go | 351 + internal/guardian/policy.go | 154 + internal/guardian/transcript.go | 252 + internal/history/search.go | 596 + internal/history/search_test.go | 313 + internal/history/strip.go | 28 + internal/history/tool.go | 138 + internal/history/tool_test.go | 149 + internal/hook/command_normalize.go | 396 + internal/hook/hook.go | 716 + internal/hook/hook_test.go | 762 + internal/hook/inspect.go | 256 + internal/hook/runner.go | 260 + internal/hook/runner_test.go | 449 + internal/hook/trust.go | 89 + internal/i18n/i18n.go | 601 + internal/i18n/i18n_test.go | 123 + internal/i18n/messages_en.go | 509 + internal/i18n/messages_zh.go | 510 + internal/i18n/messages_zh_tw.go | 505 + internal/installsource/apply.go | 335 + internal/installsource/doc.go | 18 + internal/installsource/errors.go | 72 + internal/installsource/http.go | 55 + internal/installsource/install_source.go | 713 + internal/installsource/install_source_test.go | 1970 + internal/installsource/mcp.go | 271 + internal/installsource/names.go | 285 + internal/installsource/plan.go | 462 + internal/installsource/plugin_package.go | 355 + internal/installsource/skill.go | 396 + internal/installsource/ssrf.go | 82 + internal/installsource/types.go | 160 + internal/instruction/instruction.go | 96 + internal/instruction/instruction_test.go | 59 + internal/jobs/artifacts.go | 121 + internal/jobs/artifacts_test.go | 425 + internal/jobs/concurrency_stress_test.go | 48 + internal/jobs/jobs.go | 1512 + internal/jobs/jobs_extra_test.go | 240 + internal/jobs/jobs_test.go | 568 + internal/jobs/main_test.go | 11 + internal/jobs/output_result_test.go | 33 + internal/lsp/client.go | 275 + internal/lsp/jsonrpc.go | 205 + internal/lsp/jsonrpc_framecap_test.go | 20 + internal/lsp/lsp_test.go | 183 + internal/lsp/main_test.go | 11 + internal/lsp/manager.go | 290 + internal/lsp/manual_test.go | 168 + internal/lsp/position.go | 78 + internal/lsp/results.go | 115 + internal/lsp/testdata/lsp/bash/script.sh | 6 + internal/lsp/testdata/lsp/golang/go.mod | 3 + internal/lsp/testdata/lsp/golang/main.go | 9 + internal/lsp/testdata/lsp/python/main.py | 5 + internal/lsp/testdata/lsp/rust/.gitignore | 1 + internal/lsp/testdata/lsp/rust/Cargo.lock | 7 + internal/lsp/testdata/lsp/rust/Cargo.toml | 8 + internal/lsp/testdata/lsp/rust/src/main.rs | 7 + internal/lsp/testdata/lsp/typescript/index.ts | 6 + .../lsp/testdata/lsp/typescript/tsconfig.json | 7 + internal/lsp/tool.go | 95 + internal/mcpdiag/auth.go | 184 + internal/mcpdiag/auth_test.go | 74 + internal/memory/bounded_name_test.go | 41 + internal/memory/convention_test.go | 66 + internal/memory/doc.go | 297 + internal/memory/forget.go | 60 + internal/memory/forget_test.go | 67 + internal/memory/memory.go | 169 + internal/memory/memory_test.go | 237 + internal/memory/queue.go | 23 + internal/memory/quickadd.go | 93 + internal/memory/quickadd_test.go | 74 + internal/memory/recall.go | 284 + internal/memory/recall_test.go | 193 + internal/memory/remember.go | 88 + internal/memory/remember_test.go | 66 + internal/memory/store.go | 655 + internal/memory/store_extra_test.go | 286 + internal/memory/store_test.go | 855 + internal/memory/writedoc_test.go | 63 + internal/memorycompiler/candidates.go | 36 + internal/memorycompiler/candidates_test.go | 60 + internal/memorycompiler/compression.go | 1638 + internal/memorycompiler/compression_test.go | 651 + internal/memorycompiler/feedback.go | 166 + internal/memorycompiler/feedback_test.go | 238 + .../hardening_regression_test.go | 75 + internal/memorycompiler/report.go | 134 + internal/memorycompiler/report_test.go | 48 + internal/memorycompiler/review_fixes_test.go | 84 + internal/memorycompiler/runtime.go | 3050 ++ internal/memorycompiler/runtime_test.go | 1076 + internal/migration/migration.go | 587 + internal/migration/migration_test.go | 285 + internal/netclient/netclient.go | 288 + internal/netclient/netclient_test.go | 589 + internal/nilutil/nil.go | 17 + internal/nilutil/nil_test.go | 31 + internal/notify/sender_darwin.go | 31 + internal/notify/sender_linux.go | 25 + internal/notify/sender_other.go | 11 + internal/notify/sender_windows.go | 23 + internal/notify/sink.go | 68 + internal/notify/sink_test.go | 126 + internal/outputstyle/outputstyle.go | 201 + internal/outputstyle/outputstyle_test.go | 111 + internal/permission/bash_decompose.go | 39 + internal/permission/bash_decompose_test.go | 314 + internal/permission/bash_readonly.go | 125 + internal/permission/bash_readonly_test.go | 153 + internal/permission/bash_redirect.go | 7 + internal/permission/bash_redirect_test.go | 147 + internal/permission/permission.go | 668 + internal/permission/permission_extra_test.go | 404 + internal/permission/permission_test.go | 264 + internal/planmode/marker_test.go | 57 + internal/planmode/policy.go | 608 + internal/planmode/policy_test.go | 424 + internal/planmode/reconcile_test.go | 64 + internal/plugin/cache.go | 254 + internal/plugin/cache_test.go | 214 + internal/plugin/canonicalize.go | 26 + internal/plugin/canonicalize_test.go | 146 + internal/plugin/codegraph_limit.go | 35 + internal/plugin/codegraph_limit_test.go | 46 + internal/plugin/example_test.go | 146 + internal/plugin/host_concurrency_test.go | 48 + internal/plugin/hotadd_test.go | 108 + internal/plugin/known_overrides.go | 141 + internal/plugin/lazy.go | 420 + internal/plugin/lazy_test.go | 882 + internal/plugin/main_test.go | 11 + internal/plugin/planmode_untrusted_test.go | 34 + internal/plugin/plugin.go | 1472 + internal/plugin/plugin_test.go | 1143 + internal/plugin/prompts.go | 96 + internal/plugin/proxy_e2e_test.go | 146 + internal/plugin/resources.go | 81 + internal/plugin/stats.go | 292 + internal/plugin/stats_test.go | 380 + internal/plugin/stdio_cancel_test.go | 98 + internal/plugin/toolresult_image_test.go | 135 + internal/plugin/transport_http.go | 230 + internal/plugin/transport_http_test.go | 283 + internal/plugin/transport_stdio.go | 634 + internal/plugin/transport_stdio_cache_test.go | 153 + internal/plugin/transport_stdio_env_test.go | 24 + internal/plugin/transport_stdio_other_test.go | 21 + internal/plugin/transport_stdio_wait_test.go | 43 + .../plugin/transport_stdio_windows_test.go | 71 + internal/pluginpkg/describe.go | 204 + internal/pluginpkg/pluginpkg.go | 997 + internal/pluginpkg/pluginpkg_test.go | 540 + internal/pluginpkg/state_lock_test.go | 138 + internal/proc/hide_other.go | 8 + internal/proc/hide_other_test.go | 16 + internal/proc/hide_windows.go | 22 + internal/proc/hide_windows_test.go | 50 + internal/proc/kill_other.go | 47 + internal/proc/kill_other_test.go | 100 + internal/proc/kill_windows.go | 119 + internal/proc/kill_windows_test.go | 93 + internal/proc/priority_other.go | 20 + internal/proc/priority_windows.go | 22 + internal/proc/priority_windows_test.go | 20 + internal/proc/run.go | 314 + internal/proc/run_test.go | 89 + internal/proc/shell_path_probe_other.go | 19 + internal/proc/shell_path_probe_windows.go | 12 + internal/proc/tree_other.go | 13 + internal/proc/tree_windows.go | 209 + internal/proc/tree_windows_test.go | 38 + internal/provider/anthropic/anthropic.go | 699 + internal/provider/anthropic/anthropic_test.go | 550 + internal/provider/anthropic/image_test.go | 109 + internal/provider/anthropic/stall_test.go | 86 + internal/provider/image_test.go | 21 + internal/provider/openai/effort_test.go | 125 + internal/provider/openai/fetch_models.go | 127 + internal/provider/openai/fetch_models_test.go | 139 + internal/provider/openai/host.go | 81 + internal/provider/openai/host_test.go | 164 + internal/provider/openai/image_test.go | 146 + internal/provider/openai/openai.go | 972 + internal/provider/openai/openai_test.go | 1378 + internal/provider/openai/realcache_test.go | 183 + internal/provider/openai/reconnect_test.go | 289 + internal/provider/openai/stall_test.go | 86 + internal/provider/openai/think.go | 101 + internal/provider/openai/think_test.go | 84 + internal/provider/pairing_probe_test.go | 56 + internal/provider/provider.go | 745 + internal/provider/provider_test.go | 521 + internal/provider/retry.go | 219 + internal/provider/retry_test.go | 188 + internal/provider/schema_canonicalize.go | 123 + internal/provider/schema_canonicalize_test.go | 164 + internal/provider/schema_dialect.go | 193 + internal/provider/schema_dialect_test.go | 202 + internal/provider/schema_error.go | 51 + internal/provider/schema_error_test.go | 45 + internal/retrieval/bm25.go | 207 + internal/retrieval/bm25_test.go | 68 + internal/sandbox/escape.go | 46 + internal/sandbox/helper_other.go | 15 + internal/sandbox/sandbox.go | 119 + internal/sandbox/sandbox_test.go | 325 + internal/sandbox/seatbelt_darwin.go | 130 + internal/sandbox/seatbelt_darwin_test.go | 268 + internal/sandbox/seatbelt_other.go | 138 + internal/sandbox/seatbelt_other_test.go | 40 + internal/sandbox/seatbelt_windows.go | 128 + internal/sandbox/seatbelt_windows_test.go | 176 + internal/sandbox/shell.go | 372 + internal/sandbox/shell_nul_test.go | 63 + internal/secrets/redact.go | 231 + internal/secrets/redact_test.go | 219 + internal/serve/auth.go | 601 + internal/serve/auth_test.go | 786 + internal/serve/broadcaster.go | 66 + internal/serve/broadcaster_test.go | 76 + internal/serve/csrf_test.go | 44 + internal/serve/effort_test.go | 54 + internal/serve/index.html | 1800 + internal/serve/login.html | 96 + internal/serve/logo-wordmark.svg | 24 + internal/serve/modelswitch_test.go | 41 + internal/serve/serve.go | 1372 + internal/serve/serve_lease_test.go | 432 + internal/serve/serve_lock_test.go | 197 + internal/serve/serve_test.go | 756 + internal/serve/session_files_test.go | 46 + internal/serve/switch_recovery_test.go | 110 + internal/serve/titlecache.go | 60 + internal/serve/titlecache_test.go | 36 + internal/shellparse/bash.go | 385 + internal/shellparse/bash_test.go | 280 + internal/shellsafe/bash_redirect.go | 143 + internal/shellsafe/shellsafe.go | 104 + internal/shellsafe/shellsafe_test.go | 58 + internal/skill/builtin_embed.go | 47 + internal/skill/builtincontent/embed.go | 94 + internal/skill/builtincontent/embed_test.go | 45 + .../builtincontent/reasonix-guide/SKILL.md | 223 + internal/skill/builtins.go | 379 + internal/skill/builtins_test.go | 153 + internal/skill/index.go | 97 + internal/skill/inspect.go | 116 + internal/skill/profile.go | 87 + internal/skill/reasonix_guide_test.go | 112 + internal/skill/skill.go | 1228 + internal/skill/skill_extra_test.go | 494 + internal/skill/skill_test.go | 1005 + internal/skill/tools.go | 675 + internal/skill/tools_test.go | 584 + internal/store/session.go | 153 + internal/store/session_test.go | 97 + internal/sysproxy/sysproxy.go | 72 + internal/sysproxy/sysproxy_test.go | 49 + internal/sysproxy/system_other.go | 13 + internal/sysproxy/system_windows.go | 134 + internal/textutil/grapheme.go | 101 + internal/textutil/grapheme_test.go | 34 + internal/tool/builtin/bash.go | 659 + internal/tool/builtin/bash_cancel_test.go | 55 + .../tool/builtin/bash_cancel_windows_test.go | 200 + internal/tool/builtin/bash_env_test.go | 144 + internal/tool/builtin/bash_heredoc_test.go | 156 + internal/tool/builtin/bash_powershell_test.go | 128 + internal/tool/builtin/bash_reap_test.go | 238 + .../tool/builtin/bash_sandbox_escape_test.go | 298 + .../builtin/bash_sandbox_lockwait_test.go | 58 + internal/tool/builtin/bash_timeout_test.go | 123 + internal/tool/builtin/bgjobs.go | 176 + internal/tool/builtin/bgjobs_test.go | 93 + internal/tool/builtin/builtin_test.go | 541 + internal/tool/builtin/clientio.go | 33 + internal/tool/builtin/clientio_test.go | 184 + internal/tool/builtin/codeindex.go | 472 + internal/tool/builtin/codeindex_test.go | 230 + internal/tool/builtin/codeindex_treesitter.go | 234 + .../tool/builtin/codeindex_treesitter_stub.go | 7 + .../tool/builtin/codeindex_treesitter_test.go | 66 + internal/tool/builtin/completestep.go | 442 + internal/tool/builtin/completestep_test.go | 576 + internal/tool/builtin/confine.go | 254 + internal/tool/builtin/confine_test.go | 550 + internal/tool/builtin/crlf_edit_test.go | 99 + internal/tool/builtin/delete_range.go | 327 + .../tool/builtin/delete_range_samedge_test.go | 33 + internal/tool/builtin/delete_range_test.go | 310 + internal/tool/builtin/delete_symbol.go | 325 + .../tool/builtin/delete_symbol_doc_test.go | 62 + internal/tool/builtin/delete_symbol_test.go | 168 + internal/tool/builtin/e2e_encoding_test.go | 166 + internal/tool/builtin/editfile.go | 78 + internal/tool/builtin/encoding_helpers.go | 393 + internal/tool/builtin/fuzzy_edit_test.go | 199 + internal/tool/builtin/gitignore.go | 345 + internal/tool/builtin/gitignore_e2e_test.go | 113 + internal/tool/builtin/gitignore_test.go | 197 + internal/tool/builtin/glob.go | 202 + internal/tool/builtin/grep.go | 415 + internal/tool/builtin/grep_engine_test.go | 140 + internal/tool/builtin/ls.go | 144 + internal/tool/builtin/main_test.go | 15 + internal/tool/builtin/managed_config.go | 84 + internal/tool/builtin/movefile.go | 186 + internal/tool/builtin/multiedit.go | 120 + internal/tool/builtin/notebookedit.go | 340 + internal/tool/builtin/notebookedit_test.go | 253 + internal/tool/builtin/preview.go | 127 + internal/tool/builtin/preview_test.go | 169 + .../tool/builtin/protected_dirs_darwin.go | 23 + .../builtin/protected_dirs_darwin_test.go | 51 + internal/tool/builtin/protected_dirs_other.go | 5 + internal/tool/builtin/readfile.go | 255 + internal/tool/builtin/readfile_stream_test.go | 70 + internal/tool/builtin/readfile_utf16_test.go | 39 + internal/tool/builtin/readfile_window_test.go | 55 + .../tool/builtin/recursive_search_test.go | 73 + internal/tool/builtin/session_guard.go | 286 + internal/tool/builtin/session_guard_test.go | 342 + internal/tool/builtin/todo.go | 123 + internal/tool/builtin/todo_test.go | 188 + internal/tool/builtin/tool_extra_test.go | 229 + internal/tool/builtin/vendorskip_test.go | 47 + internal/tool/builtin/walk_cancel_test.go | 43 + internal/tool/builtin/web_fetch_proxy_test.go | 349 + internal/tool/builtin/webfetch.go | 585 + internal/tool/builtin/webfetch_ssrf_test.go | 69 + internal/tool/builtin/workspace.go | 283 + internal/tool/builtin/workspace_test.go | 289 + internal/tool/builtin/write_tool_test.go | 507 + internal/tool/builtin/writefile.go | 88 + internal/tool/configwrite.go | 45 + internal/tool/contract.go | 102 + internal/tool/contract_test.go | 86 + internal/tool/preview_test.go | 58 + internal/tool/progress.go | 22 + internal/tool/registry_canon_test.go | 62 + internal/tool/registry_test.go | 168 + internal/tool/resolved.go | 44 + internal/tool/sessiontool/sessiontool.go | 225 + internal/tool/sessiontool/sessiontool_test.go | 375 + internal/tool/subagentguard.go | 63 + internal/tool/tool.go | 329 + internal/winsandbox/LICENSE | 21 + internal/winsandbox/NOTICE.md | 11 + internal/winsandbox/README.md | 99 + internal/winsandbox/coordination_windows.go | 578 + .../winsandbox/coordination_windows_test.go | 822 + internal/winsandbox/lockinfo.go | 126 + internal/winsandbox/lockinfo_test.go | 118 + internal/winsandbox/sandbox.go | 47 + internal/winsandbox/sandbox_other.go | 13 + internal/winsandbox/sandbox_windows.go | 1348 + internal/winsandbox/sandbox_windows_test.go | 668 + npm/build.mjs | 123 + npm/reasonix/bin/reasonix.js | 21 + npm/reasonix/package.json | 39 + prod_fast_test | 31 + prod_test | 261 + reasonix.example.toml | 259 + scripts/backfill-issue-labels.mjs | 111 + scripts/cache-guard.sh | 30 + scripts/check-cache-impact.sh | 188 + scripts/desktop-build.sh | 176 + scripts/desktop-test-times.py | 54 + scripts/resolve-desktop-release.sh | 41 + scripts/update-acknowledgments.mjs | 79 + site/astro.config.mjs | 9 + site/package-lock.json | 4844 +++ site/package.json | 21 + site/public/favicon.svg | 11 + site/public/logo.svg | 11 + site/public/og.png | Bin 0 -> 79089 bytes site/public/robots.txt | 4 + site/scripts/fetch-community.mjs | 47 + site/src/data/community.json | 271 + site/src/data/contributors.json | 48 + site/src/layouts/Account.astro | 36 + site/src/layouts/Base.astro | 44 + site/src/layouts/Community.astro | 59 + site/src/pages/404.astro | 35 + site/src/pages/account.astro | 65 + site/src/pages/community/guidelines.astro | 14 + site/src/pages/community/index.astro | 58 + site/src/pages/community/new.astro | 28 + site/src/pages/community/topic.astro | 42 + site/src/pages/device.astro | 34 + site/src/pages/docs.astro | 373 + site/src/pages/forgot.astro | 28 + site/src/pages/index.astro | 357 + site/src/pages/login.astro | 36 + site/src/pages/register.astro | 38 + site/src/pages/reset.astro | 33 + site/src/pages/skills.astro | 548 + site/src/scripts/auth.js | 301 + site/src/scripts/community.js | 314 + site/src/scripts/fx.js | 213 + site/src/scripts/safe-next.js | 34 + site/src/scripts/safe-next.test.mjs | 71 + site/src/scripts/site.js | 176 + site/src/styles/community.css | 201 + site/src/styles/global.css | 967 + site/tsconfig.json | 3 + tools/write_heartbeat/heartbeat-tasks.json | 20 + tools/write_heartbeat/main.go | 75 + workers/accounts/.gitignore | 5 + workers/accounts/README.md | 113 + workers/accounts/migrations/0001_initial.sql | 43 + .../migrations/0002_device_grants.sql | 17 + workers/accounts/package.json | 24 + workers/accounts/pnpm-lock.yaml | 906 + workers/accounts/src/app.ts | 26 + workers/accounts/src/auth/cookies.ts | 35 + workers/accounts/src/auth/crypto.ts | 89 + workers/accounts/src/config.ts | 20 + workers/accounts/src/db/deviceGrants.ts | 157 + workers/accounts/src/db/emailTokens.ts | 46 + workers/accounts/src/db/index.ts | 26 + workers/accounts/src/db/sessions.ts | 65 + workers/accounts/src/db/users.ts | 91 + workers/accounts/src/email/index.ts | 75 + workers/accounts/src/email/resend.ts | 31 + workers/accounts/src/email/types.ts | 10 + workers/accounts/src/env.ts | 31 + workers/accounts/src/http/auth.ts | 41 + workers/accounts/src/http/cors.ts | 20 + workers/accounts/src/http/errors.ts | 35 + workers/accounts/src/http/ratelimit.ts | 17 + workers/accounts/src/index.ts | 5 + workers/accounts/src/lib/handle.ts | 35 + workers/accounts/src/lib/validation.ts | 77 + workers/accounts/src/routes/auth.ts | 140 + workers/accounts/src/routes/device.ts | 85 + workers/accounts/src/routes/health.ts | 9 + workers/accounts/src/routes/me.ts | 70 + workers/accounts/src/routes/users.ts | 17 + workers/accounts/src/types.ts | 67 + workers/accounts/tsconfig.json | 16 + workers/accounts/wrangler.toml | 42 + workers/crash-report/.gitignore | 2 + workers/crash-report/migrate-access.sql | 17 + .../migrate-dashboard-indexes.sql | 12 + workers/crash-report/migrate-metric-users.sql | 12 + .../migrate-structured-reports.sql | 35 + workers/crash-report/migrate-title.sql | 5 + workers/crash-report/migrate.sql | 34 + workers/crash-report/package-lock.json | 1548 + workers/crash-report/package.json | 22 + workers/crash-report/registry-schema.sql | 68 + workers/crash-report/schema.sql | 130 + workers/crash-report/src/admin.ts | 75 + workers/crash-report/src/auth.ts | 169 + workers/crash-report/src/auth_pages.ts | 19 + workers/crash-report/src/community.ts | 95 + workers/crash-report/src/desktop_release.ts | 100 + workers/crash-report/src/env.ts | 21 + workers/crash-report/src/index.ts | 1061 + workers/crash-report/src/registry/app.ts | 22 + .../crash-report/src/registry/db/events.ts | 44 + workers/crash-report/src/registry/db/index.ts | 10 + .../crash-report/src/registry/db/packages.ts | 237 + workers/crash-report/src/registry/env.ts | 23 + .../crash-report/src/registry/http/auth.ts | 53 + .../crash-report/src/registry/http/cors.ts | 20 + .../crash-report/src/registry/http/errors.ts | 35 + .../src/registry/http/ratelimit.ts | 17 + .../src/registry/lib/validation.ts | 105 + .../src/registry/routes/activity.ts | 28 + .../crash-report/src/registry/routes/admin.ts | 67 + .../src/registry/routes/health.ts | 8 + .../src/registry/routes/packages.ts | 86 + workers/crash-report/src/registry/types.ts | 101 + workers/crash-report/src/shell.ts | 310 + workers/crash-report/src/stats.ts | 865 + workers/crash-report/tsconfig.json | 13 + workers/crash-report/wrangler.toml | 59 + workers/forum/.gitignore | 4 + workers/forum/package.json | 24 + workers/forum/pnpm-lock.yaml | 890 + workers/forum/schema.sql | 89 + workers/forum/seed.sql | 8 + workers/forum/src/antispam.ts | 55 + workers/forum/src/env.ts | 24 + workers/forum/src/identity.ts | 94 + workers/forum/src/index.ts | 178 + workers/forum/tsconfig.json | 16 + workers/forum/wrangler.toml | 32 + 1516 files changed, 473769 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100755 .githooks/pre-push create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/labeler.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/sponsor/wechat-pay.jpg create mode 100644 .github/workflows/cache-impact.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/deploy-accounts-worker.yml create mode 100644 .github/workflows/deploy-crash-worker.yml create mode 100644 .github/workflows/deploy-forum-worker.yml create mode 100644 .github/workflows/e2e-bot.yml create mode 100644 .github/workflows/issue-auto-label.yml create mode 100644 .github/workflows/issue-version-label.yml create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/pr-auto-label.yml create mode 100644 .github/workflows/pr-version-label.yml create mode 100644 .github/workflows/release-desktop.yml create mode 100644 .github/workflows/release-npm.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/update-acknowledgments.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yaml create mode 100644 .reasonix/commands/review.md create mode 100644 .signpath/artifact-configurations/windows-installer.xml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 README.zh-CN.md create mode 100644 REASONIX.md create mode 100644 SECURITY.md create mode 100644 benchmarks/context-maintenance-e2e/main.go create mode 100644 benchmarks/e2e/tasks/compaction/task.toml create mode 100644 benchmarks/e2e/tasks/compaction/verify.sh create mode 100644 benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md create mode 100644 benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md create mode 100644 benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md create mode 100644 benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md create mode 100644 benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md create mode 100644 benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md create mode 100644 benchmarks/e2e/tasks/fix-add-bug/task.toml create mode 100644 benchmarks/e2e/tasks/fix-add-bug/verify.sh create mode 100644 benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py create mode 100644 benchmarks/e2e/tasks/fizzbuzz/task.toml create mode 100644 benchmarks/e2e/tasks/fizzbuzz/verify.sh create mode 100644 benchmarks/e2e/tasks/palindrome/task.toml create mode 100644 benchmarks/e2e/tasks/palindrome/verify.sh create mode 100644 benchmarks/e2e/tasks/subagent-delegation/task.toml create mode 100644 benchmarks/e2e/tasks/subagent-delegation/verify.sh create mode 100644 benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt create mode 100644 benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt create mode 100644 benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt create mode 100644 cmd/e2ebench/diff.go create mode 100644 cmd/e2ebench/diff_test.go create mode 100644 cmd/e2ebench/main.go create mode 100644 cmd/e2ebench/mutation.go create mode 100644 cmd/e2ebench/profile.go create mode 100644 cmd/e2ebench/profile_test.go create mode 100644 cmd/reasonix-plugin-example/main.go create mode 100644 cmd/reasonix/main.go create mode 100644 desktop/.gitignore create mode 100644 desktop/README.md create mode 100644 desktop/app.go create mode 100644 desktop/app_autosave_test.go create mode 100644 desktop/app_platform_test.go create mode 100644 desktop/app_session_dedup_test.go create mode 100644 desktop/app_test.go create mode 100644 desktop/appicon_asset_test.go create mode 100644 desktop/attach_dropped_test.go create mode 100644 desktop/autosave_warn_test.go create mode 100644 desktop/bot_bridge.go create mode 100644 desktop/bot_bridge_app.go create mode 100644 desktop/bot_bridge_test.go create mode 100644 desktop/bot_connection_app.go create mode 100644 desktop/bot_connection_app_test.go create mode 100644 desktop/bot_event_sink.go create mode 100644 desktop/bot_event_sink_test.go create mode 100644 desktop/bot_runtime_app.go create mode 100644 desktop/bot_runtime_app_test.go create mode 100644 desktop/bound_array_contract_test.go create mode 100644 desktop/build/appicon.png create mode 100644 desktop/build/appicon.svg create mode 100644 desktop/build/darwin/entitlements.plist create mode 100644 desktop/build/darwin/icon.icns create mode 100644 desktop/build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png create mode 100644 desktop/build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg create mode 100644 desktop/build/linux/nfpm.yaml create mode 100644 desktop/build/linux/reasonix.desktop create mode 100644 desktop/build/windows/installer/project.nsi create mode 100644 desktop/capdiag_app.go create mode 100644 desktop/capdiag_app_test.go create mode 100644 desktop/checkpoints_cancode_test.go create mode 100644 desktop/cmd/sign/main.go create mode 100644 desktop/cmd/sign/main_test.go create mode 100644 desktop/cmd/update-helper/args.go create mode 100644 desktop/cmd/update-helper/args_test.go create mode 100644 desktop/cmd/update-helper/main_other.go create mode 100644 desktop/cmd/update-helper/main_windows.go create mode 100644 desktop/crash_app.go create mode 100644 desktop/crash_app_test.go create mode 100644 desktop/crash_pending.go create mode 100644 desktop/crash_pending_test.go create mode 100644 desktop/deferred_rebuild.go create mode 100644 desktop/devinfo.go create mode 100644 desktop/devinfo_darwin.go create mode 100644 desktop/devinfo_linux.go create mode 100644 desktop/devinfo_test.go create mode 100644 desktop/devinfo_windows.go create mode 100644 desktop/dotenv.go create mode 100644 desktop/dotenv_test.go create mode 100644 desktop/encoded_file.go create mode 100644 desktop/frontend/.gitignore create mode 100644 desktop/frontend/.npmrc create mode 100644 desktop/frontend/dist/.gitkeep create mode 100644 desktop/frontend/index.html create mode 100644 desktop/frontend/package.json create mode 100644 desktop/frontend/pnpm-lock.yaml create mode 100644 desktop/frontend/pnpm-workspace.yaml create mode 100644 desktop/frontend/public/sounds/mixkit-correct-answer-tone-2870.wav create mode 100644 desktop/frontend/public/sounds/mixkit-positive-notification-951.wav create mode 100644 desktop/frontend/public/sounds/mixkit-software-interface-back-2575.wav create mode 100644 desktop/frontend/public/sounds/mixkit-software-interface-start-2574.wav create mode 100644 desktop/frontend/scripts/check-css-syntax.mjs create mode 100644 desktop/frontend/scripts/check-z-index-tokens.mjs create mode 100644 desktop/frontend/scripts/test-todo-visibility.mjs create mode 100644 desktop/frontend/src/App.tsx create mode 100644 desktop/frontend/src/__tests__/anchored-popover-scroll.test.tsx create mode 100644 desktop/frontend/src/__tests__/app-chrome-tabs.test.ts create mode 100644 desktop/frontend/src/__tests__/approval-modal-file-reference.test.tsx create mode 100644 desktop/frontend/src/__tests__/ask-card-layout.test.ts create mode 100644 desktop/frontend/src/__tests__/asset-stub-for-tests.ts create mode 100644 desktop/frontend/src/__tests__/at-matches.test.ts create mode 100644 desktop/frontend/src/__tests__/attachment-display.test.ts create mode 100644 desktop/frontend/src/__tests__/bridge-breadcrumbs.test.ts create mode 100644 desktop/frontend/src/__tests__/bridge-drag-rejection.test.ts create mode 100644 desktop/frontend/src/__tests__/bridge-mock-approval-mode.test.ts create mode 100644 desktop/frontend/src/__tests__/bundle-contract.test.ts create mode 100644 desktop/frontend/src/__tests__/capabilities-panel-actions.test.ts create mode 100644 desktop/frontend/src/__tests__/code-block-copy-position.test.tsx create mode 100644 desktop/frontend/src/__tests__/command-palette-css.test.ts create mode 100644 desktop/frontend/src/__tests__/command-palette-interactions.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-context-menu-clipboard.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-history.test.ts create mode 100644 desktop/frontend/src/__tests__/composer-image-capability.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-keyboard.test.ts create mode 100644 desktop/frontend/src/__tests__/composer-profile.test.ts create mode 100644 desktop/frontend/src/__tests__/composer-run-strip.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-session-draft.test.tsx create mode 100644 desktop/frontend/src/__tests__/context-panel-breakdown.test.ts create mode 100644 desktop/frontend/src/__tests__/context-window-ring.test.tsx create mode 100644 desktop/frontend/src/__tests__/crash-reporting.test.ts create mode 100644 desktop/frontend/src/__tests__/decision-surface.test.tsx create mode 100644 desktop/frontend/src/__tests__/diagnostics-settings.test.tsx create mode 100644 desktop/frontend/src/__tests__/diff-rendering.test.ts create mode 100644 desktop/frontend/src/__tests__/edit-replay.test.ts create mode 100644 desktop/frontend/src/__tests__/font-availability.test.ts create mode 100644 desktop/frontend/src/__tests__/heartbeat-next-run.test.ts create mode 100644 desktop/frontend/src/__tests__/history-performance-benchmark.tsx create mode 100644 desktop/frontend/src/__tests__/history-recovery-copies.test.tsx create mode 100644 desktop/frontend/src/__tests__/history-tool-status.test.ts create mode 100644 desktop/frontend/src/__tests__/keyboard-shortcuts.test.ts create mode 100644 desktop/frontend/src/__tests__/layout-style-defaults.test.ts create mode 100644 desktop/frontend/src/__tests__/math-golden.test.ts create mode 100644 desktop/frontend/src/__tests__/memory-citation-visibility.test.ts create mode 100644 desktop/frontend/src/__tests__/memory-compiler-display.test.ts create mode 100644 desktop/frontend/src/__tests__/mermaid-rendering.test.tsx create mode 100644 desktop/frontend/src/__tests__/message-pasted-blocks.test.ts create mode 100644 desktop/frontend/src/__tests__/message-reasoning-panel.test.tsx create mode 100644 desktop/frontend/src/__tests__/message-selection-copy.test.ts create mode 100644 desktop/frontend/src/__tests__/new-session-load-race.test.tsx create mode 100644 desktop/frontend/src/__tests__/open-topic-coalescing.test.tsx create mode 100644 desktop/frontend/src/__tests__/project-tree-runtime.test.ts create mode 100644 desktop/frontend/src/__tests__/provider-model-refresh.test.ts create mode 100644 desktop/frontend/src/__tests__/ready-event-meta-sync.test.tsx create mode 100644 desktop/frontend/src/__tests__/ready-meta-reconcile.test.tsx create mode 100644 desktop/frontend/src/__tests__/reasoning-display.test.ts create mode 100644 desktop/frontend/src/__tests__/recovery-banner-privacy.test.ts create mode 100644 desktop/frontend/src/__tests__/recovery-quiet-notifications.test.ts create mode 100644 desktop/frontend/src/__tests__/render-optimization.test.ts create mode 100644 desktop/frontend/src/__tests__/resize-drag.test.ts create mode 100644 desktop/frontend/src/__tests__/scroll-content-observer.test.ts create mode 100644 desktop/frontend/src/__tests__/scroll-manager.test.tsx create mode 100644 desktop/frontend/src/__tests__/send-failed.test.ts create mode 100644 desktop/frontend/src/__tests__/session-title-contract.test.ts create mode 100644 desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx create mode 100644 desktop/frontend/src/__tests__/sound.test.ts create mode 100644 desktop/frontend/src/__tests__/startup-settings-contract.test.ts create mode 100644 desktop/frontend/src/__tests__/statusbar-workspace.test.tsx create mode 100644 desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx create mode 100644 desktop/frontend/src/__tests__/text-size.test.ts create mode 100644 desktop/frontend/src/__tests__/theme-auto-background.test.ts create mode 100644 desktop/frontend/src/__tests__/tool-approval-mode.test.ts create mode 100644 desktop/frontend/src/__tests__/tool-card-error-details.test.tsx create mode 100644 desktop/frontend/src/__tests__/tool-data-archive.test.ts create mode 100644 desktop/frontend/src/__tests__/tool-subject.test.ts create mode 100644 desktop/frontend/src/__tests__/transcript-fold-preference.test.tsx create mode 100644 desktop/frontend/src/__tests__/transcript-grouping.test.ts create mode 100644 desktop/frontend/src/__tests__/transcript-process-fold.test.ts create mode 100644 desktop/frontend/src/__tests__/transcript-selection-menu.test.tsx create mode 100644 desktop/frontend/src/__tests__/turn-action-copy.test.ts create mode 100644 desktop/frontend/src/__tests__/typography-overflow-contract.test.ts create mode 100644 desktop/frontend/src/__tests__/use-controller-cancel-reconcile.test.tsx create mode 100644 desktop/frontend/src/__tests__/use-controller-meta.test.ts create mode 100644 desktop/frontend/src/__tests__/use-controller-send-fallback.test.tsx create mode 100644 desktop/frontend/src/__tests__/use-controller-stream-progress.test.ts create mode 100644 desktop/frontend/src/__tests__/wails-resize-fix.test.tsx create mode 100644 desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx create mode 100644 desktop/frontend/src/__tests__/workspace-layout.test.ts create mode 100644 desktop/frontend/src/__tests__/workspace-preview-css.test.ts create mode 100644 desktop/frontend/src/__tests__/workspace-split.test.ts create mode 100644 desktop/frontend/src/assets/logo-symbol.svg create mode 100644 desktop/frontend/src/assets/logo-wordmark.svg create mode 100644 desktop/frontend/src/assets/logo.svg create mode 100644 desktop/frontend/src/components/AnchoredPopover.tsx create mode 100644 desktop/frontend/src/components/AppChrome.tsx create mode 100644 desktop/frontend/src/components/ApprovalModal.tsx create mode 100644 desktop/frontend/src/components/ArgMenu.tsx create mode 100644 desktop/frontend/src/components/AskCard.tsx create mode 100644 desktop/frontend/src/components/CapabilitiesPanel.tsx create mode 100644 desktop/frontend/src/components/ClearContextCard.tsx create mode 100644 desktop/frontend/src/components/CodeViewer.tsx create mode 100644 desktop/frontend/src/components/CommandPalette.tsx create mode 100644 desktop/frontend/src/components/Composer.tsx create mode 100644 desktop/frontend/src/components/ComposerContextCard.tsx create mode 100644 desktop/frontend/src/components/ContextMenu.tsx create mode 100644 desktop/frontend/src/components/ContextPanel.tsx create mode 100644 desktop/frontend/src/components/ContextWindowRing.tsx create mode 100644 desktop/frontend/src/components/CopyButton.tsx create mode 100644 desktop/frontend/src/components/DiagnosticsSettingsPage.tsx create mode 100644 desktop/frontend/src/components/DiffView.tsx create mode 100644 desktop/frontend/src/components/EffortSwitcher.tsx create mode 100644 desktop/frontend/src/components/ErrorBoundary.tsx create mode 100644 desktop/frontend/src/components/FileReferenceMenu.tsx create mode 100644 desktop/frontend/src/components/FloatingMenu.tsx create mode 100644 desktop/frontend/src/components/HistoryPanel.tsx create mode 100644 desktop/frontend/src/components/ImageViewer.tsx create mode 100644 desktop/frontend/src/components/InlineConfirmButton.tsx create mode 100644 desktop/frontend/src/components/InlineDiff.tsx create mode 100644 desktop/frontend/src/components/InvocationBadge.tsx create mode 100644 desktop/frontend/src/components/Markdown.tsx create mode 100644 desktop/frontend/src/components/MarkdownRenderer.tsx create mode 100644 desktop/frontend/src/components/MemoryPanel.tsx create mode 100644 desktop/frontend/src/components/MermaidDiagram.tsx create mode 100644 desktop/frontend/src/components/Message.tsx create mode 100644 desktop/frontend/src/components/ModalCloseButton.tsx create mode 100644 desktop/frontend/src/components/ModelSwitcher.tsx create mode 100644 desktop/frontend/src/components/OnboardingOverlay.tsx create mode 100644 desktop/frontend/src/components/ProcessCard.tsx create mode 100644 desktop/frontend/src/components/ProjectTree.tsx create mode 100644 desktop/frontend/src/components/PromptShelf.tsx create mode 100644 desktop/frontend/src/components/ReadOnlyBatch.tsx create mode 100644 desktop/frontend/src/components/ResizableDrawer.tsx create mode 100644 desktop/frontend/src/components/RichComposerInput.tsx create mode 100644 desktop/frontend/src/components/SettingsPanel.tsx create mode 100644 desktop/frontend/src/components/ShortcutComboDisplay.tsx create mode 100644 desktop/frontend/src/components/ShortcutsCheatsheet.tsx create mode 100644 desktop/frontend/src/components/SlashMenu.tsx create mode 100644 desktop/frontend/src/components/SoundSelect.tsx create mode 100644 desktop/frontend/src/components/StartupSplash.tsx create mode 100644 desktop/frontend/src/components/StatusBar.tsx create mode 100644 desktop/frontend/src/components/SubagentsPanel.tsx create mode 100644 desktop/frontend/src/components/TabBar.tsx create mode 100644 desktop/frontend/src/components/TodoPanel.tsx create mode 100644 desktop/frontend/src/components/ToolCard.tsx create mode 100644 desktop/frontend/src/components/ToolGroup.tsx create mode 100644 desktop/frontend/src/components/Tooltip.tsx create mode 100644 desktop/frontend/src/components/Transcript.tsx create mode 100644 desktop/frontend/src/components/TranscriptSelectionMenu.tsx create mode 100644 desktop/frontend/src/components/UndoRewindBanner.tsx create mode 100644 desktop/frontend/src/components/UpdateBanner.tsx create mode 100644 desktop/frontend/src/components/VirtualMenu.tsx create mode 100644 desktop/frontend/src/components/Welcome.tsx create mode 100644 desktop/frontend/src/components/WorkspacePanel.tsx create mode 100644 desktop/frontend/src/components/editors/HljsCode.tsx create mode 100644 desktop/frontend/src/components/editors/HljsDiff.tsx create mode 100644 desktop/frontend/src/components/latexNormalize.ts create mode 100644 desktop/frontend/src/components/mathClassify.ts create mode 100644 desktop/frontend/src/components/mathNormalize.ts create mode 100644 desktop/frontend/src/components/youngDiagrams.ts create mode 100644 desktop/frontend/src/custom/features/heartbeat/HeartbeatPanel.tsx create mode 100644 desktop/frontend/src/custom/features/heartbeat/heartbeat.bridge.ts create mode 100644 desktop/frontend/src/custom/features/heartbeat/heartbeat.css create mode 100644 desktop/frontend/src/custom/features/heartbeat/heartbeat.types.ts create mode 100644 desktop/frontend/src/lib/array.ts create mode 100644 desktop/frontend/src/lib/atMatches.ts create mode 100644 desktop/frontend/src/lib/attachDedup.ts create mode 100644 desktop/frontend/src/lib/attachmentDisplay.ts create mode 100644 desktop/frontend/src/lib/breadcrumbs.ts create mode 100644 desktop/frontend/src/lib/bridge.ts create mode 100644 desktop/frontend/src/lib/clipboard.ts create mode 100644 desktop/frontend/src/lib/composerDraftKey.ts create mode 100644 desktop/frontend/src/lib/composerHistory.ts create mode 100644 desktop/frontend/src/lib/composerKeyboard.ts create mode 100644 desktop/frontend/src/lib/composerProfile.ts create mode 100644 desktop/frontend/src/lib/crash.ts create mode 100644 desktop/frontend/src/lib/diff.ts create mode 100644 desktop/frontend/src/lib/displayMode.ts create mode 100644 desktop/frontend/src/lib/dpiScale.ts create mode 100644 desktop/frontend/src/lib/editReplay.ts create mode 100644 desktop/frontend/src/lib/fontAvailability.ts create mode 100644 desktop/frontend/src/lib/fontFamily.ts create mode 100644 desktop/frontend/src/lib/generative-music.ts create mode 100644 desktop/frontend/src/lib/gsapAnimations.ts create mode 100644 desktop/frontend/src/lib/guardianEvents.ts create mode 100644 desktop/frontend/src/lib/highlight.ts create mode 100644 desktop/frontend/src/lib/i18n.tsx create mode 100644 desktop/frontend/src/lib/invocationDisplay.ts create mode 100644 desktop/frontend/src/lib/keyboardShortcuts.ts create mode 100644 desktop/frontend/src/lib/lang.ts create mode 100644 desktop/frontend/src/lib/layoutPreferences.ts create mode 100644 desktop/frontend/src/lib/mcpServerLifecycle.ts create mode 100644 desktop/frontend/src/lib/memoryCitationVisibility.ts create mode 100644 desktop/frontend/src/lib/memoryCompilerDisplay.ts create mode 100644 desktop/frontend/src/lib/messageSelectionCopy.ts create mode 100644 desktop/frontend/src/lib/money.ts create mode 100644 desktop/frontend/src/lib/openTopicCoalescing.ts create mode 100644 desktop/frontend/src/lib/processFoldPreference.ts create mode 100644 desktop/frontend/src/lib/projectColors.ts create mode 100644 desktop/frontend/src/lib/providerModels.ts create mode 100644 desktop/frontend/src/lib/rafBatch.ts create mode 100644 desktop/frontend/src/lib/reasoningDisplay.ts create mode 100644 desktop/frontend/src/lib/resizeDrag.ts create mode 100644 desktop/frontend/src/lib/scrollContentObserver.ts create mode 100644 desktop/frontend/src/lib/session.ts create mode 100644 desktop/frontend/src/lib/sessionExport.tsx create mode 100644 desktop/frontend/src/lib/shellExpand.tsx create mode 100644 desktop/frontend/src/lib/sound.ts create mode 100644 desktop/frontend/src/lib/statusBarItems.ts create mode 100644 desktop/frontend/src/lib/textSize.ts create mode 100644 desktop/frontend/src/lib/theme.ts create mode 100644 desktop/frontend/src/lib/toast.tsx create mode 100644 desktop/frontend/src/lib/todoVisibility.ts create mode 100644 desktop/frontend/src/lib/toolApprovalMode.ts create mode 100644 desktop/frontend/src/lib/tools.ts create mode 100644 desktop/frontend/src/lib/topicShortcuts.ts create mode 100644 desktop/frontend/src/lib/transcriptGrouping.ts create mode 100644 desktop/frontend/src/lib/turnActionCopy.ts create mode 100644 desktop/frontend/src/lib/types.ts create mode 100644 desktop/frontend/src/lib/useController.ts create mode 100644 desktop/frontend/src/lib/useEntranceAnimation.ts create mode 100644 desktop/frontend/src/lib/useGSAPCollapse.ts create mode 100644 desktop/frontend/src/lib/useMountTransition.ts create mode 100644 desktop/frontend/src/lib/useScrollManager.ts create mode 100644 desktop/frontend/src/lib/useUpdater.ts create mode 100644 desktop/frontend/src/lib/useWailsResizeFix.ts create mode 100644 desktop/frontend/src/lib/windowState.ts create mode 100644 desktop/frontend/src/lib/workspaceDrag.ts create mode 100644 desktop/frontend/src/lib/workspaceLayout.ts create mode 100644 desktop/frontend/src/lib/workspacePreviewTabs.ts create mode 100644 desktop/frontend/src/lib/workspaceSplit.ts create mode 100644 desktop/frontend/src/lib/workspaceTreeReveal.ts create mode 100644 desktop/frontend/src/lib/workspaceTreeSearch.ts create mode 100644 desktop/frontend/src/locales/en.ts create mode 100644 desktop/frontend/src/locales/zh-TW.ts create mode 100644 desktop/frontend/src/locales/zh.ts create mode 100644 desktop/frontend/src/main.tsx create mode 100644 desktop/frontend/src/store/layout.ts create mode 100644 desktop/frontend/src/store/overlays.ts create mode 100644 desktop/frontend/src/store/setState.ts create mode 100644 desktop/frontend/src/styles.css create mode 100644 desktop/frontend/tsconfig.json create mode 100644 desktop/frontend/tsconfig.node.json create mode 100644 desktop/frontend/tsconfig.test.json create mode 100644 desktop/frontend/vite-env.d.ts create mode 100644 desktop/frontend/vite.config.ts create mode 100644 desktop/go.mod create mode 100644 desktop/go.sum create mode 100644 desktop/hang_watchdog.go create mode 100644 desktop/hang_watchdog_darwin.go create mode 100644 desktop/hang_watchdog_other.go create mode 100644 desktop/hang_watchdog_test.go create mode 100644 desktop/heartbeat.go create mode 100644 desktop/heartbeat_test.go create mode 100644 desktop/history_perf_test.go create mode 100644 desktop/history_test.go create mode 100644 desktop/history_todo_test.go create mode 100644 desktop/hooks_settings_app.go create mode 100644 desktop/internal/update/manifest.go create mode 100644 desktop/internal/update/update_test.go create mode 100644 desktop/internal/update/verify.go create mode 100644 desktop/main.go create mode 100644 desktop/main_test.go create mode 100644 desktop/memory_suggestions.go create mode 100644 desktop/memory_suggestions_compiler.go create mode 100644 desktop/memory_suggestions_compiler_test.go create mode 100644 desktop/memory_suggestions_test.go create mode 100644 desktop/menu.go create mode 100644 desktop/metrics_app.go create mode 100644 desktop/metrics_app_test.go create mode 100644 desktop/new_session_inherit_test.go create mode 100644 desktop/notifications_test.go create mode 100644 desktop/nvidia_wayland_linux.go create mode 100644 desktop/open_workspace_darwin.go create mode 100644 desktop/open_workspace_unix.go create mode 100644 desktop/open_workspace_windows.go create mode 100644 desktop/plugin_packages_app.go create mode 100644 desktop/plugin_packages_app_test.go create mode 100644 desktop/race_regression_test.go create mode 100644 desktop/recovery_gc.go create mode 100644 desktop/recovery_gc_test.go create mode 100644 desktop/repeat_guard_e2e_test.go create mode 100644 desktop/runtime_rebuilt_event_test.go create mode 100644 desktop/session_errors.go create mode 100644 desktop/session_errors_test.go create mode 100644 desktop/session_errors_unix.go create mode 100644 desktop/session_errors_windows.go create mode 100644 desktop/session_key_test.go create mode 100644 desktop/session_prompt.go create mode 100644 desktop/session_prompt_bytes_test.go create mode 100644 desktop/session_prompt_test.go create mode 100644 desktop/session_recovery_cleanup_test.go create mode 100644 desktop/sessions.go create mode 100644 desktop/sessions_lease_guard_test.go create mode 100644 desktop/sessions_test.go create mode 100644 desktop/settings_app.go create mode 100644 desktop/settings_app_test.go create mode 100644 desktop/shared_host.go create mode 100644 desktop/shared_host_test.go create mode 100644 desktop/single_instance.go create mode 100644 desktop/single_instance_test.go create mode 100644 desktop/skills_app_test.go create mode 100644 desktop/subagents_app.go create mode 100644 desktop/subagents_app_test.go create mode 100644 desktop/system_quit.go create mode 100644 desktop/system_quit_darwin.go create mode 100644 desktop/system_quit_darwin.m create mode 100644 desktop/system_quit_stub.go create mode 100644 desktop/tab_event_sink_test.go create mode 100644 desktop/tab_goal_restore_test.go create mode 100644 desktop/tab_profile_test.go create mode 100644 desktop/tab_scoped_actions_test.go create mode 100644 desktop/tabs.go create mode 100644 desktop/tabs_order_test.go create mode 100644 desktop/tabs_planner_notice_test.go create mode 100644 desktop/tabs_runtime_status_test.go create mode 100644 desktop/tabs_sink_context_test.go create mode 100644 desktop/tabs_telemetry_test.go create mode 100644 desktop/tabs_topic_test.go create mode 100644 desktop/telemetry_app.go create mode 100644 desktop/telemetry_app_test.go create mode 100644 desktop/temphelper_test.go create mode 100644 desktop/tray.go create mode 100644 desktop/tray_icon_unix.go create mode 100644 desktop/tray_icon_windows.go create mode 100644 desktop/tray_icon_windows_test.go create mode 100644 desktop/tray_loop_external.go create mode 100644 desktop/tray_loop_windows.go create mode 100644 desktop/tray_loop_windows_test.go create mode 100644 desktop/tray_supported_darwin.go create mode 100644 desktop/tray_supported_unix.go create mode 100644 desktop/tray_supported_windows.go create mode 100644 desktop/tray_test.go create mode 100644 desktop/updater.go create mode 100644 desktop/updater_app.go create mode 100644 desktop/updater_mac.go create mode 100644 desktop/updater_mac_stub.go create mode 100644 desktop/updater_other.go create mode 100644 desktop/updater_test.go create mode 100644 desktop/updater_windows.go create mode 100644 desktop/updater_windows_args.go create mode 100644 desktop/updater_windows_test.go create mode 100644 desktop/wails.json create mode 100644 desktop/window_controls.go create mode 100644 desktop/window_state.go create mode 100644 desktop/windows_sandbox_helper_test.go create mode 100644 desktop/windows_update_handoff_test.go create mode 100644 desktop/wire_test.go create mode 100644 desktop/workspace.go create mode 100644 desktop/workspace_changes.go create mode 100644 desktop/workspace_changes_test.go create mode 100644 desktop/workspace_test.go create mode 100644 desktop/workspace_unix_test.go create mode 100644 desktop/zoom_factor.go create mode 100755 dev create mode 100644 docs/.nojekyll create mode 100644 docs/BOT_GUIDE.md create mode 100644 docs/BOT_GUIDE.zh-CN.md create mode 100644 docs/CAPABILITY_DIAGNOSTICS.md create mode 100644 docs/CAPABILITY_DIAGNOSTICS.zh-CN.md create mode 100644 docs/CHECKPOINTS.md create mode 100644 docs/COLLABORATION_MODES.zh-CN.md create mode 100644 docs/CONFIG_PATHS.md create mode 100644 docs/CONFIG_PATHS.zh-CN.md create mode 100644 docs/DESKTOP_HOOKS.zh-CN.md create mode 100644 docs/GOAL_ENFORCEMENT.zh-CN.md create mode 100644 docs/GUIDE.md create mode 100644 docs/GUIDE.zh-CN.md create mode 100644 docs/MIGRATING.md create mode 100644 docs/PLUGIN_PACKAGES.md create mode 100644 docs/PLUGIN_PACKAGES.zh-CN.md create mode 100644 docs/REASONING_LANGUAGE.md create mode 100644 docs/REASONING_LANGUAGE.zh-CN.md create mode 100644 docs/REASONING_PROVIDERS.md create mode 100644 docs/RELEASING.md create mode 100644 docs/SESSION_MEMORY_RETRIEVAL.md create mode 100644 docs/SESSION_REFERENCE_ARCHITECTURE.md create mode 100644 docs/SPEC.md create mode 100644 docs/SUBAGENT_PROFILES.md create mode 100644 docs/SUBAGENT_PROFILES.zh-CN.md create mode 100644 docs/TASK_CONTRACT.md create mode 100644 docs/TASK_CONTRACT.zh-CN.md create mode 100644 docs/TOOL_APPROVAL_MODES.zh-CN.md create mode 100644 docs/TOOL_CONTRACT.md create mode 100644 docs/TOOL_CONTRACT.zh-CN.md create mode 100644 docs/assets/agent-skill-sources.png create mode 100644 docs/assets/approval-authorization-mocks.svg create mode 100644 docs/assets/approval-mock-1.html create mode 100644 docs/assets/bot-feishu-approval.svg create mode 100644 docs/assets/bot-lark-yolo.svg create mode 100644 docs/assets/bot-qq-approval.svg create mode 100644 docs/assets/bot-weixin-text-commands.svg create mode 100644 docs/assets/issue-2605-plan-approval-shelf.png create mode 100644 docs/assets/issue-2605-tool-approval-shelf.png create mode 100644 docs/claude-desktop-layout-preview.png create mode 100644 docs/cli-image-paste-effect.svg create mode 100644 docs/cli-theme-effect.svg create mode 100644 docs/desktop-theme-dark.jpg create mode 100644 docs/desktop-theme-light.jpg create mode 100644 docs/desktop-v2-layout-preview.png create mode 100644 docs/desktop-v2-skills-preview.png create mode 100644 docs/favicon.svg create mode 100644 docs/index.html create mode 100644 docs/logo.svg create mode 100644 docs/paste-folding-effect.svg create mode 100644 docs/production_checklist.md create mode 100644 docs/production_checklist.zh-CN.md create mode 100644 docs/project-topic-tabs-context.png create mode 100644 docs/slash-command-views.svg create mode 100644 docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md create mode 100644 docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md create mode 100644 docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/acp/client_integration_test.go create mode 100644 internal/acp/clientio.go create mode 100644 internal/acp/dispatch.go create mode 100644 internal/acp/dispatch_partial_test.go create mode 100644 internal/acp/dispatch_test.go create mode 100644 internal/acp/e2e_test.go create mode 100644 internal/acp/live_test.go create mode 100644 internal/acp/protocol.go create mode 100644 internal/acp/protocol_test.go create mode 100644 internal/acp/server.go create mode 100644 internal/acp/server_test.go create mode 100644 internal/acp/service.go create mode 100644 internal/acp/service_lease_test.go create mode 100644 internal/acp/service_lock_test.go create mode 100644 internal/acp/session_files_test.go create mode 100644 internal/acp/switch_recovery_test.go create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/argserror_test.go create mode 100644 internal/agent/ask.go create mode 100644 internal/agent/ask_test.go create mode 100644 internal/agent/branch.go create mode 100644 internal/agent/branch_test.go create mode 100644 internal/agent/cache_diagnostics_test.go create mode 100644 internal/agent/cache_shape.go create mode 100644 internal/agent/cache_shape_test.go create mode 100644 internal/agent/cachehit_e2e_test.go create mode 100644 internal/agent/cancel_test.go create mode 100644 internal/agent/canonical_path_test.go create mode 100644 internal/agent/canonical_todo_test.go create mode 100644 internal/agent/capability_gate.go create mode 100644 internal/agent/capability_gate_test.go create mode 100644 internal/agent/compact.go create mode 100644 internal/agent/compact_loop_e2e_test.go create mode 100644 internal/agent/compact_test.go create mode 100644 internal/agent/complete_step_e2e_test.go create mode 100644 internal/agent/coordinator.go create mode 100644 internal/agent/coordinator_test.go create mode 100644 internal/agent/delivery_hardening_test.go create mode 100644 internal/agent/dispatch_test.go create mode 100644 internal/agent/empty_final_test.go create mode 100644 internal/agent/errors.go create mode 100644 internal/agent/errors_test.go create mode 100644 internal/agent/evidence_flow_test.go create mode 100644 internal/agent/final_readiness_test.go create mode 100644 internal/agent/gate_test.go create mode 100644 internal/agent/goal_display.go create mode 100644 internal/agent/guards_test.go create mode 100644 internal/agent/hooks_test.go create mode 100644 internal/agent/listsessions_bench_test.go create mode 100644 internal/agent/listsessions_sidecar_test.go create mode 100644 internal/agent/loop_e2e_test.go create mode 100644 internal/agent/main_test.go create mode 100644 internal/agent/memory_compiler_context.go create mode 100644 internal/agent/memory_compiler_gate_test.go create mode 100644 internal/agent/memory_feedback_e2e_test.go create mode 100644 internal/agent/migrate.go create mode 100644 internal/agent/migrate_test.go create mode 100644 internal/agent/nil_boundary_test.go create mode 100644 internal/agent/normalize.go create mode 100644 internal/agent/outcome_test.go create mode 100644 internal/agent/parallel_tasks.go create mode 100644 internal/agent/parallel_tasks_test.go create mode 100644 internal/agent/planmode_test.go create mode 100644 internal/agent/postllmcall_flow_test.go create mode 100644 internal/agent/preview.go create mode 100644 internal/agent/preview_test.go create mode 100644 internal/agent/prune.go create mode 100644 internal/agent/prune_test.go create mode 100644 internal/agent/reasoning_language.go create mode 100644 internal/agent/reasoning_language_test.go create mode 100644 internal/agent/recovery_gc.go create mode 100644 internal/agent/recovery_gc_test.go create mode 100644 internal/agent/repeat_guard_test.go create mode 100644 internal/agent/retry_e2e_test.go create mode 100644 internal/agent/review_report.go create mode 100644 internal/agent/save.go create mode 100644 internal/agent/save_test.go create mode 100644 internal/agent/secret_redaction_test.go create mode 100644 internal/agent/session.go create mode 100644 internal/agent/session_concurrency_test.go create mode 100644 internal/agent/session_content.go create mode 100644 internal/agent/session_events.go create mode 100644 internal/agent/session_events_test.go create mode 100644 internal/agent/session_lease.go create mode 100644 internal/agent/session_lease_test.go create mode 100644 internal/agent/session_lock_unix.go create mode 100644 internal/agent/session_lock_windows.go create mode 100644 internal/agent/session_lock_windows_test.go create mode 100644 internal/agent/session_meta_ledger_test.go create mode 100644 internal/agent/session_removal.go create mode 100644 internal/agent/session_removal_test.go create mode 100644 internal/agent/session_test.go create mode 100644 internal/agent/stale_anchor_guard_test.go create mode 100644 internal/agent/steer_flush_test.go create mode 100644 internal/agent/steer_test.go create mode 100644 internal/agent/storm_test.go create mode 100644 internal/agent/subagent_cleanup_test.go create mode 100644 internal/agent/subagent_registry_test.go create mode 100644 internal/agent/subagent_store.go create mode 100644 internal/agent/subagent_store_test.go create mode 100644 internal/agent/task.go create mode 100644 internal/agent/task_classifier.go create mode 100644 internal/agent/task_classifier_cache_test.go create mode 100644 internal/agent/task_classifier_test.go create mode 100644 internal/agent/task_test.go create mode 100644 internal/agent/testutil/mock_provider.go create mode 100644 internal/agent/testutil/mock_provider_test.go create mode 100644 internal/agent/textsink.go create mode 100644 internal/agent/textsink_partial_test.go create mode 100644 internal/agent/textsink_test.go create mode 100644 internal/agent/toolimages_test.go create mode 100644 internal/agent/usage_test.go create mode 100644 internal/agent/usecapability.go create mode 100644 internal/agent/usecapability_test.go create mode 100644 internal/agent/width.go create mode 100644 internal/agent/width_test.go create mode 100644 internal/autoresearch/bounded_store_test.go create mode 100644 internal/autoresearch/readiness.go create mode 100644 internal/autoresearch/schema.go create mode 100644 internal/autoresearch/store.go create mode 100644 internal/autoresearch/store_test.go create mode 100644 internal/autoresearch/summary.go create mode 100644 internal/autoresearch/task.go create mode 100644 internal/billing/balance.go create mode 100644 internal/billing/balance_extra_test.go create mode 100644 internal/billing/balance_test.go create mode 100644 internal/boot/boot.go create mode 100644 internal/boot/boot_test.go create mode 100644 internal/boot/helper_test.go create mode 100644 internal/boot/model_error_test.go create mode 100644 internal/boot/prompt_stability_test.go create mode 100644 internal/boot/subagent_model_test.go create mode 100644 internal/boot/temphelper_test.go create mode 100644 internal/boot/token_profile.go create mode 100644 internal/boot/token_profile_lock_test.go create mode 100644 internal/bot/connloop.go create mode 100644 internal/bot/connloop_test.go create mode 100644 internal/bot/control_server.go create mode 100644 internal/bot/desktop.go create mode 100644 internal/bot/desktop_test.go create mode 100644 internal/bot/feishu/feishu.go create mode 100644 internal/bot/feishu/feishu_test.go create mode 100644 internal/bot/feishu/inbound.go create mode 100644 internal/bot/feishu/outbound.go create mode 100644 internal/bot/feishu/outbound_media.go create mode 100644 internal/bot/feishu/outbound_media_test.go create mode 100644 internal/bot/feishu/retry.go create mode 100644 internal/bot/gateway.go create mode 100644 internal/bot/gateway_lock_test.go create mode 100644 internal/bot/gateway_test.go create mode 100644 internal/bot/hash.go create mode 100644 internal/bot/media.go create mode 100644 internal/bot/media_test.go create mode 100644 internal/bot/pairing.go create mode 100644 internal/bot/pairing_test.go create mode 100644 internal/bot/project_index.go create mode 100644 internal/bot/qq/adapter.go create mode 100644 internal/bot/qq/gateway.go create mode 100644 internal/bot/qq/gateway_test.go create mode 100644 internal/bot/render.go create mode 100644 internal/bot/render_test.go create mode 100644 internal/bot/session.go create mode 100644 internal/bot/session_test.go create mode 100644 internal/bot/turn_dispatch_test.go create mode 100644 internal/bot/types.go create mode 100644 internal/bot/types_test.go create mode 100644 internal/bot/weixin/weixin.go create mode 100644 internal/bot/weixin/weixin_login.go create mode 100644 internal/bot/weixin/weixin_test.go create mode 100644 internal/botruntime/runtime.go create mode 100644 internal/botruntime/runtime_test.go create mode 100644 internal/capability/audit.go create mode 100644 internal/capability/capability.go create mode 100644 internal/capability/capability_test.go create mode 100644 internal/capability/catalog.go create mode 100644 internal/capability/catalog_cache_test.go create mode 100644 internal/capability/ledger.go create mode 100644 internal/capability/ledger_test.go create mode 100644 internal/capability/semantic.go create mode 100644 internal/capability/semantic_test.go create mode 100644 internal/capdiag/collect.go create mode 100644 internal/capdiag/collect_test.go create mode 100644 internal/capdiag/live.go create mode 100644 internal/capdiag/paths.go create mode 100644 internal/capdiag/render.go create mode 100644 internal/capdiag/sanitize_test.go create mode 100644 internal/capdiag/types.go create mode 100644 internal/checkpoint/checkpoint.go create mode 100644 internal/checkpoint/checkpoint_test.go create mode 100644 internal/cli/acp.go create mode 100644 internal/cli/acp_test.go create mode 100644 internal/cli/autoplan.go create mode 100644 internal/cli/bot.go create mode 100644 internal/cli/bot_test.go create mode 100644 internal/cli/box.go create mode 100644 internal/cli/box_test.go create mode 100644 internal/cli/branch.go create mode 100644 internal/cli/branch_test.go create mode 100644 internal/cli/cache_tag_test.go create mode 100644 internal/cli/chat_attachment_test.go create mode 100644 internal/cli/chat_ghost_test.go create mode 100644 internal/cli/chat_render_test.go create mode 100644 internal/cli/chat_tui.go create mode 100644 internal/cli/chat_tui_paste.go create mode 100644 internal/cli/chat_tui_test.go create mode 100644 internal/cli/chooser.go create mode 100644 internal/cli/clear_confirm.go create mode 100644 internal/cli/cli.go create mode 100644 internal/cli/cli_test.go create mode 100644 internal/cli/compaction_test.go create mode 100644 internal/cli/complete.go create mode 100644 internal/cli/complete_test.go create mode 100644 internal/cli/consecutive_tool_markers_test.go create mode 100644 internal/cli/copy_export_test.go create mode 100644 internal/cli/copy_picker.go create mode 100644 internal/cli/diffview.go create mode 100644 internal/cli/diffview_test.go create mode 100644 internal/cli/doctor.go create mode 100644 internal/cli/doctor_capabilities.go create mode 100644 internal/cli/doctor_capabilities_test.go create mode 100644 internal/cli/doctor_test.go create mode 100644 internal/cli/effort.go create mode 100644 internal/cli/gitstatus.go create mode 100644 internal/cli/gitstatus_test.go create mode 100644 internal/cli/help_view.go create mode 100644 internal/cli/hide_other.go create mode 100644 internal/cli/hide_windows.go create mode 100644 internal/cli/hooks_view.go create mode 100644 internal/cli/interject_test.go create mode 100644 internal/cli/language.go create mode 100644 internal/cli/latex.go create mode 100644 internal/cli/latex_test.go create mode 100644 internal/cli/math_e2e_test.go create mode 100644 internal/cli/mathnode.go create mode 100644 internal/cli/mcp.go create mode 100644 internal/cli/mcp_import_picker.go create mode 100644 internal/cli/mcp_manager.go create mode 100644 internal/cli/mcp_manager_actions.go create mode 100644 internal/cli/mcp_manager_actions_test.go create mode 100644 internal/cli/mcp_manager_view.go create mode 100644 internal/cli/mcp_test.go create mode 100644 internal/cli/mcp_view.go create mode 100644 internal/cli/md.go create mode 100644 internal/cli/md_cjk_test.go create mode 100644 internal/cli/md_test.go create mode 100644 internal/cli/memory.go create mode 100644 internal/cli/memory_v5.go create mode 100644 internal/cli/memory_view.go create mode 100644 internal/cli/model.go create mode 100644 internal/cli/model_test.go create mode 100644 internal/cli/model_view.go create mode 100644 internal/cli/output_style_view.go create mode 100644 internal/cli/plugin.go create mode 100644 internal/cli/plugin_slash.go create mode 100644 internal/cli/plugin_slash_test.go create mode 100644 internal/cli/plugin_test.go create mode 100644 internal/cli/provider.go create mode 100644 internal/cli/reasoning_language.go create mode 100644 internal/cli/rename.go create mode 100644 internal/cli/rename_test.go create mode 100644 internal/cli/render_edge_test.go create mode 100644 internal/cli/resume.go create mode 100644 internal/cli/resume_picker.go create mode 100644 internal/cli/resume_test.go create mode 100644 internal/cli/retry_indicator_test.go create mode 100644 internal/cli/review.go create mode 100644 internal/cli/review_test.go create mode 100644 internal/cli/rewind.go create mode 100644 internal/cli/rewind_test.go create mode 100644 internal/cli/run_metrics.go create mode 100644 internal/cli/run_metrics_test.go create mode 100644 internal/cli/select.go create mode 100644 internal/cli/select_test.go create mode 100644 internal/cli/session_lease.go create mode 100644 internal/cli/session_lease_test.go create mode 100644 internal/cli/setup_manager.go create mode 100644 internal/cli/setup_manager_test.go create mode 100644 internal/cli/skill_hooks.go create mode 100644 internal/cli/skill_picker.go create mode 100644 internal/cli/skill_picker_test.go create mode 100644 internal/cli/skill_picker_view.go create mode 100644 internal/cli/skill_view.go create mode 100644 internal/cli/skillslash_test.go create mode 100644 internal/cli/slash_test.go create mode 100644 internal/cli/statusline_test.go create mode 100644 internal/cli/style.go create mode 100644 internal/cli/subagent.go create mode 100644 internal/cli/subagent_test.go create mode 100644 internal/cli/switch_recovery_test.go create mode 100644 internal/cli/theme.go create mode 100644 internal/cli/theme_osc_other.go create mode 100644 internal/cli/theme_osc_unix.go create mode 100644 internal/cli/theme_test.go create mode 100644 internal/cli/todopanel_test.go create mode 100644 internal/cli/toolcard.go create mode 100644 internal/cli/toolcard_test.go create mode 100644 internal/cli/transcript.go create mode 100644 internal/cli/transcript_test.go create mode 100644 internal/cli/upgrade.go create mode 100644 internal/cli/upgrade_test.go create mode 100644 internal/cli/view_helpers.go create mode 100644 internal/cli/view_helpers_test.go create mode 100644 internal/cli/width_test.go create mode 100644 internal/cli/work_mode.go create mode 100644 internal/cli/work_mode_test.go create mode 100644 internal/command/command.go create mode 100644 internal/command/command_extra_test.go create mode 100644 internal/command/command_test.go create mode 100644 internal/command/inspect.go create mode 100644 internal/command/slashtool.go create mode 100644 internal/command/slashtool_test.go create mode 100644 internal/command/symlink_test.go create mode 100644 internal/config/backfill_test.go create mode 100644 internal/config/bounded_component_test.go create mode 100644 internal/config/ccswitch.go create mode 100644 internal/config/ccswitch_test.go create mode 100644 internal/config/chat_model_test.go create mode 100644 internal/config/commanddirs_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/configured_test.go create mode 100644 internal/config/credentials.go create mode 100644 internal/config/credentials_keyring_default.go create mode 100644 internal/config/credentials_keyring_unix.go create mode 100644 internal/config/decode.go create mode 100644 internal/config/default_test.go create mode 100644 internal/config/dotenv.go create mode 100644 internal/config/dotenv_test.go create mode 100644 internal/config/edit.go create mode 100644 internal/config/edit_test.go create mode 100644 internal/config/effort.go create mode 100644 internal/config/effort_test.go create mode 100644 internal/config/expand.go create mode 100644 internal/config/expand_test.go create mode 100644 internal/config/fetch.go create mode 100644 internal/config/fetch_test.go create mode 100644 internal/config/load.go create mode 100644 internal/config/load_project_default_test.go create mode 100644 internal/config/loadedit_test.go create mode 100644 internal/config/mcp_command.go create mode 100644 internal/config/mcpjson.go create mode 100644 internal/config/mcpjson_test.go create mode 100644 internal/config/migrate.go create mode 100644 internal/config/migrate_test.go create mode 100644 internal/config/model_fallback_test.go create mode 100644 internal/config/mutate.go create mode 100644 internal/config/mutate_test.go create mode 100644 internal/config/paths.go create mode 100644 internal/config/plugin_packages.go create mode 100644 internal/config/plugin_packages_test.go create mode 100644 internal/config/pricing.go create mode 100644 internal/config/provider_presets.go create mode 100644 internal/config/provider_presets_test.go create mode 100644 internal/config/proxy_test.go create mode 100644 internal/config/render.go create mode 100644 internal/config/render_test.go create mode 100644 internal/config/sandbox_test.go create mode 100644 internal/config/subagent_model_test.go create mode 100644 internal/config/system_prompt_test.go create mode 100644 internal/config/tools_test.go create mode 100644 internal/config/vision.go create mode 100644 internal/config/workspace_slug_test.go create mode 100644 internal/control/admission_test.go create mode 100644 internal/control/approval.go create mode 100644 internal/control/approval_e2e_test.go create mode 100644 internal/control/attachments.go create mode 100644 internal/control/attachments_test.go create mode 100644 internal/control/auto_plan.go create mode 100644 internal/control/auto_plan_classifier.go create mode 100644 internal/control/auto_plan_classifier_test.go create mode 100644 internal/control/auto_plan_e2e_test.go create mode 100644 internal/control/auto_plan_test.go create mode 100644 internal/control/branches.go create mode 100644 internal/control/branches_test.go create mode 100644 internal/control/capability.go create mode 100644 internal/control/capability_test.go create mode 100644 internal/control/checkpoint.go create mode 100644 internal/control/close_idempotent_test.go create mode 100644 internal/control/config_write_approval_test.go create mode 100644 internal/control/controller.go create mode 100644 internal/control/controller_lock_test.go create mode 100644 internal/control/controller_test.go create mode 100644 internal/control/errmsg.go create mode 100644 internal/control/errmsg_test.go create mode 100644 internal/control/goal.go create mode 100644 internal/control/goal_concurrency_test.go create mode 100644 internal/control/goal_test.go create mode 100644 internal/control/imagecompress.go create mode 100644 internal/control/imagecompress_test.go create mode 100644 internal/control/input.go create mode 100644 internal/control/input_test.go create mode 100644 internal/control/inputimages_test.go create mode 100644 internal/control/main_test.go create mode 100644 internal/control/mcp.go create mode 100644 internal/control/memory.go create mode 100644 internal/control/memory_test.go create mode 100644 internal/control/plan_seed_test.go create mode 100644 internal/control/port.go create mode 100644 internal/control/refs.go create mode 100644 internal/control/refs_test.go create mode 100644 internal/control/replay_pending_test.go create mode 100644 internal/control/resume_prune_test.go create mode 100644 internal/control/rewind_e2e_test.go create mode 100644 internal/control/runtime_status_test.go create mode 100644 internal/control/session_artifacts_test.go create mode 100644 internal/control/session_lease_keeper.go create mode 100644 internal/control/session_lease_keeper_test.go create mode 100644 internal/control/sessionpath.go create mode 100644 internal/control/shell_kill_other.go create mode 100644 internal/control/shell_kill_other_test.go create mode 100644 internal/control/shell_kill_windows.go create mode 100644 internal/control/shell_test.go create mode 100644 internal/control/skill.go create mode 100644 internal/control/slash.go create mode 100644 internal/control/slash_test.go create mode 100644 internal/control/steer_fallback_test.go create mode 100644 internal/control/synthetic_contract_test.go create mode 100644 internal/control/turn_orchestrator.go create mode 100644 internal/control/turn_orchestrator_test.go create mode 100644 internal/control/yolo_test.go create mode 100644 internal/diff/diff.go create mode 100644 internal/diff/diff_extra_test.go create mode 100644 internal/diff/diff_test.go create mode 100644 internal/diff/largediff_test.go create mode 100644 internal/doctor/quality.go create mode 100644 internal/doctor/quality_test.go create mode 100644 internal/doctor/report.go create mode 100644 internal/doctor/report_test.go create mode 100644 internal/doctor/session_bundle.go create mode 100644 internal/doctor/session_bundle_test.go create mode 100644 internal/doctor/session_redact.go create mode 100644 internal/doctor/session_redact_test.go create mode 100644 internal/doctor/skill_health.go create mode 100644 internal/doctor/skill_health_test.go create mode 100644 internal/environment/probe.go create mode 100644 internal/environment/probe_test.go create mode 100644 internal/environment/probe_windows_test.go create mode 100644 internal/environment/snapshot.go create mode 100644 internal/environment/snapshot_test.go create mode 100644 internal/event/event.go create mode 100644 internal/event/event_test.go create mode 100644 internal/event/fanout.go create mode 100644 internal/event/sync.go create mode 100644 internal/eventwire/wire.go create mode 100644 internal/eventwire/wire_test.go create mode 100644 internal/evidence/child.go create mode 100644 internal/evidence/child_test.go create mode 100644 internal/evidence/commandmatch.go create mode 100644 internal/evidence/commandmatch_test.go create mode 100644 internal/evidence/evidence.go create mode 100644 internal/evidence/evidence_test.go create mode 100644 internal/evidence/meta.go create mode 100644 internal/evidence/readiness_audit.go create mode 100644 internal/evidence/readiness_test.go create mode 100644 internal/evidence/review_report.go create mode 100644 internal/evidence/risk.go create mode 100644 internal/fileref/search.go create mode 100644 internal/fileref/search_test.go create mode 100644 internal/fileutil/atomicwrite.go create mode 100644 internal/fileutil/atomicwrite_test.go create mode 100644 internal/fileutil/encoding/encoding.go create mode 100644 internal/fileutil/encoding/encoding_test.go create mode 100644 internal/fileutil/globset.go create mode 100644 internal/fileutil/globset_test.go create mode 100644 internal/fileutil/replacefallback_other.go create mode 100644 internal/fileutil/replacefallback_windows.go create mode 100644 internal/fileutil/replacefallback_windows_test.go create mode 100644 internal/frontmatter/frontmatter.go create mode 100644 internal/frontmatter/frontmatter_test.go create mode 100644 internal/frontmatter/list_test.go create mode 100644 internal/guardian/guardian.go create mode 100644 internal/guardian/guardian_policy.md create mode 100644 internal/guardian/guardian_test.go create mode 100644 internal/guardian/policy.go create mode 100644 internal/guardian/transcript.go create mode 100644 internal/history/search.go create mode 100644 internal/history/search_test.go create mode 100644 internal/history/strip.go create mode 100644 internal/history/tool.go create mode 100644 internal/history/tool_test.go create mode 100644 internal/hook/command_normalize.go create mode 100644 internal/hook/hook.go create mode 100644 internal/hook/hook_test.go create mode 100644 internal/hook/inspect.go create mode 100644 internal/hook/runner.go create mode 100644 internal/hook/runner_test.go create mode 100644 internal/hook/trust.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/i18n/messages_en.go create mode 100644 internal/i18n/messages_zh.go create mode 100644 internal/i18n/messages_zh_tw.go create mode 100644 internal/installsource/apply.go create mode 100644 internal/installsource/doc.go create mode 100644 internal/installsource/errors.go create mode 100644 internal/installsource/http.go create mode 100644 internal/installsource/install_source.go create mode 100644 internal/installsource/install_source_test.go create mode 100644 internal/installsource/mcp.go create mode 100644 internal/installsource/names.go create mode 100644 internal/installsource/plan.go create mode 100644 internal/installsource/plugin_package.go create mode 100644 internal/installsource/skill.go create mode 100644 internal/installsource/ssrf.go create mode 100644 internal/installsource/types.go create mode 100644 internal/instruction/instruction.go create mode 100644 internal/instruction/instruction_test.go create mode 100644 internal/jobs/artifacts.go create mode 100644 internal/jobs/artifacts_test.go create mode 100644 internal/jobs/concurrency_stress_test.go create mode 100644 internal/jobs/jobs.go create mode 100644 internal/jobs/jobs_extra_test.go create mode 100644 internal/jobs/jobs_test.go create mode 100644 internal/jobs/main_test.go create mode 100644 internal/jobs/output_result_test.go create mode 100644 internal/lsp/client.go create mode 100644 internal/lsp/jsonrpc.go create mode 100644 internal/lsp/jsonrpc_framecap_test.go create mode 100644 internal/lsp/lsp_test.go create mode 100644 internal/lsp/main_test.go create mode 100644 internal/lsp/manager.go create mode 100644 internal/lsp/manual_test.go create mode 100644 internal/lsp/position.go create mode 100644 internal/lsp/results.go create mode 100644 internal/lsp/testdata/lsp/bash/script.sh create mode 100644 internal/lsp/testdata/lsp/golang/go.mod create mode 100644 internal/lsp/testdata/lsp/golang/main.go create mode 100644 internal/lsp/testdata/lsp/python/main.py create mode 100644 internal/lsp/testdata/lsp/rust/.gitignore create mode 100644 internal/lsp/testdata/lsp/rust/Cargo.lock create mode 100644 internal/lsp/testdata/lsp/rust/Cargo.toml create mode 100644 internal/lsp/testdata/lsp/rust/src/main.rs create mode 100644 internal/lsp/testdata/lsp/typescript/index.ts create mode 100644 internal/lsp/testdata/lsp/typescript/tsconfig.json create mode 100644 internal/lsp/tool.go create mode 100644 internal/mcpdiag/auth.go create mode 100644 internal/mcpdiag/auth_test.go create mode 100644 internal/memory/bounded_name_test.go create mode 100644 internal/memory/convention_test.go create mode 100644 internal/memory/doc.go create mode 100644 internal/memory/forget.go create mode 100644 internal/memory/forget_test.go create mode 100644 internal/memory/memory.go create mode 100644 internal/memory/memory_test.go create mode 100644 internal/memory/queue.go create mode 100644 internal/memory/quickadd.go create mode 100644 internal/memory/quickadd_test.go create mode 100644 internal/memory/recall.go create mode 100644 internal/memory/recall_test.go create mode 100644 internal/memory/remember.go create mode 100644 internal/memory/remember_test.go create mode 100644 internal/memory/store.go create mode 100644 internal/memory/store_extra_test.go create mode 100644 internal/memory/store_test.go create mode 100644 internal/memory/writedoc_test.go create mode 100644 internal/memorycompiler/candidates.go create mode 100644 internal/memorycompiler/candidates_test.go create mode 100644 internal/memorycompiler/compression.go create mode 100644 internal/memorycompiler/compression_test.go create mode 100644 internal/memorycompiler/feedback.go create mode 100644 internal/memorycompiler/feedback_test.go create mode 100644 internal/memorycompiler/hardening_regression_test.go create mode 100644 internal/memorycompiler/report.go create mode 100644 internal/memorycompiler/report_test.go create mode 100644 internal/memorycompiler/review_fixes_test.go create mode 100644 internal/memorycompiler/runtime.go create mode 100644 internal/memorycompiler/runtime_test.go create mode 100644 internal/migration/migration.go create mode 100644 internal/migration/migration_test.go create mode 100644 internal/netclient/netclient.go create mode 100644 internal/netclient/netclient_test.go create mode 100644 internal/nilutil/nil.go create mode 100644 internal/nilutil/nil_test.go create mode 100644 internal/notify/sender_darwin.go create mode 100644 internal/notify/sender_linux.go create mode 100644 internal/notify/sender_other.go create mode 100644 internal/notify/sender_windows.go create mode 100644 internal/notify/sink.go create mode 100644 internal/notify/sink_test.go create mode 100644 internal/outputstyle/outputstyle.go create mode 100644 internal/outputstyle/outputstyle_test.go create mode 100644 internal/permission/bash_decompose.go create mode 100644 internal/permission/bash_decompose_test.go create mode 100644 internal/permission/bash_readonly.go create mode 100644 internal/permission/bash_readonly_test.go create mode 100644 internal/permission/bash_redirect.go create mode 100644 internal/permission/bash_redirect_test.go create mode 100644 internal/permission/permission.go create mode 100644 internal/permission/permission_extra_test.go create mode 100644 internal/permission/permission_test.go create mode 100644 internal/planmode/marker_test.go create mode 100644 internal/planmode/policy.go create mode 100644 internal/planmode/policy_test.go create mode 100644 internal/planmode/reconcile_test.go create mode 100644 internal/plugin/cache.go create mode 100644 internal/plugin/cache_test.go create mode 100644 internal/plugin/canonicalize.go create mode 100644 internal/plugin/canonicalize_test.go create mode 100644 internal/plugin/codegraph_limit.go create mode 100644 internal/plugin/codegraph_limit_test.go create mode 100644 internal/plugin/example_test.go create mode 100644 internal/plugin/host_concurrency_test.go create mode 100644 internal/plugin/hotadd_test.go create mode 100644 internal/plugin/known_overrides.go create mode 100644 internal/plugin/lazy.go create mode 100644 internal/plugin/lazy_test.go create mode 100644 internal/plugin/main_test.go create mode 100644 internal/plugin/planmode_untrusted_test.go create mode 100644 internal/plugin/plugin.go create mode 100644 internal/plugin/plugin_test.go create mode 100644 internal/plugin/prompts.go create mode 100644 internal/plugin/proxy_e2e_test.go create mode 100644 internal/plugin/resources.go create mode 100644 internal/plugin/stats.go create mode 100644 internal/plugin/stats_test.go create mode 100644 internal/plugin/stdio_cancel_test.go create mode 100644 internal/plugin/toolresult_image_test.go create mode 100644 internal/plugin/transport_http.go create mode 100644 internal/plugin/transport_http_test.go create mode 100644 internal/plugin/transport_stdio.go create mode 100644 internal/plugin/transport_stdio_cache_test.go create mode 100644 internal/plugin/transport_stdio_env_test.go create mode 100644 internal/plugin/transport_stdio_other_test.go create mode 100644 internal/plugin/transport_stdio_wait_test.go create mode 100644 internal/plugin/transport_stdio_windows_test.go create mode 100644 internal/pluginpkg/describe.go create mode 100644 internal/pluginpkg/pluginpkg.go create mode 100644 internal/pluginpkg/pluginpkg_test.go create mode 100644 internal/pluginpkg/state_lock_test.go create mode 100644 internal/proc/hide_other.go create mode 100644 internal/proc/hide_other_test.go create mode 100644 internal/proc/hide_windows.go create mode 100644 internal/proc/hide_windows_test.go create mode 100644 internal/proc/kill_other.go create mode 100644 internal/proc/kill_other_test.go create mode 100644 internal/proc/kill_windows.go create mode 100644 internal/proc/kill_windows_test.go create mode 100644 internal/proc/priority_other.go create mode 100644 internal/proc/priority_windows.go create mode 100644 internal/proc/priority_windows_test.go create mode 100644 internal/proc/run.go create mode 100644 internal/proc/run_test.go create mode 100644 internal/proc/shell_path_probe_other.go create mode 100644 internal/proc/shell_path_probe_windows.go create mode 100644 internal/proc/tree_other.go create mode 100644 internal/proc/tree_windows.go create mode 100644 internal/proc/tree_windows_test.go create mode 100644 internal/provider/anthropic/anthropic.go create mode 100644 internal/provider/anthropic/anthropic_test.go create mode 100644 internal/provider/anthropic/image_test.go create mode 100644 internal/provider/anthropic/stall_test.go create mode 100644 internal/provider/image_test.go create mode 100644 internal/provider/openai/effort_test.go create mode 100644 internal/provider/openai/fetch_models.go create mode 100644 internal/provider/openai/fetch_models_test.go create mode 100644 internal/provider/openai/host.go create mode 100644 internal/provider/openai/host_test.go create mode 100644 internal/provider/openai/image_test.go create mode 100644 internal/provider/openai/openai.go create mode 100644 internal/provider/openai/openai_test.go create mode 100644 internal/provider/openai/realcache_test.go create mode 100644 internal/provider/openai/reconnect_test.go create mode 100644 internal/provider/openai/stall_test.go create mode 100644 internal/provider/openai/think.go create mode 100644 internal/provider/openai/think_test.go create mode 100644 internal/provider/pairing_probe_test.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go create mode 100644 internal/provider/retry.go create mode 100644 internal/provider/retry_test.go create mode 100644 internal/provider/schema_canonicalize.go create mode 100644 internal/provider/schema_canonicalize_test.go create mode 100644 internal/provider/schema_dialect.go create mode 100644 internal/provider/schema_dialect_test.go create mode 100644 internal/provider/schema_error.go create mode 100644 internal/provider/schema_error_test.go create mode 100644 internal/retrieval/bm25.go create mode 100644 internal/retrieval/bm25_test.go create mode 100644 internal/sandbox/escape.go create mode 100644 internal/sandbox/helper_other.go create mode 100644 internal/sandbox/sandbox.go create mode 100644 internal/sandbox/sandbox_test.go create mode 100644 internal/sandbox/seatbelt_darwin.go create mode 100644 internal/sandbox/seatbelt_darwin_test.go create mode 100644 internal/sandbox/seatbelt_other.go create mode 100644 internal/sandbox/seatbelt_other_test.go create mode 100644 internal/sandbox/seatbelt_windows.go create mode 100644 internal/sandbox/seatbelt_windows_test.go create mode 100644 internal/sandbox/shell.go create mode 100644 internal/sandbox/shell_nul_test.go create mode 100644 internal/secrets/redact.go create mode 100644 internal/secrets/redact_test.go create mode 100644 internal/serve/auth.go create mode 100644 internal/serve/auth_test.go create mode 100644 internal/serve/broadcaster.go create mode 100644 internal/serve/broadcaster_test.go create mode 100644 internal/serve/csrf_test.go create mode 100644 internal/serve/effort_test.go create mode 100644 internal/serve/index.html create mode 100644 internal/serve/login.html create mode 100644 internal/serve/logo-wordmark.svg create mode 100644 internal/serve/modelswitch_test.go create mode 100644 internal/serve/serve.go create mode 100644 internal/serve/serve_lease_test.go create mode 100644 internal/serve/serve_lock_test.go create mode 100644 internal/serve/serve_test.go create mode 100644 internal/serve/session_files_test.go create mode 100644 internal/serve/switch_recovery_test.go create mode 100644 internal/serve/titlecache.go create mode 100644 internal/serve/titlecache_test.go create mode 100644 internal/shellparse/bash.go create mode 100644 internal/shellparse/bash_test.go create mode 100644 internal/shellsafe/bash_redirect.go create mode 100644 internal/shellsafe/shellsafe.go create mode 100644 internal/shellsafe/shellsafe_test.go create mode 100644 internal/skill/builtin_embed.go create mode 100644 internal/skill/builtincontent/embed.go create mode 100644 internal/skill/builtincontent/embed_test.go create mode 100644 internal/skill/builtincontent/reasonix-guide/SKILL.md create mode 100644 internal/skill/builtins.go create mode 100644 internal/skill/builtins_test.go create mode 100644 internal/skill/index.go create mode 100644 internal/skill/inspect.go create mode 100644 internal/skill/profile.go create mode 100644 internal/skill/reasonix_guide_test.go create mode 100644 internal/skill/skill.go create mode 100644 internal/skill/skill_extra_test.go create mode 100644 internal/skill/skill_test.go create mode 100644 internal/skill/tools.go create mode 100644 internal/skill/tools_test.go create mode 100644 internal/store/session.go create mode 100644 internal/store/session_test.go create mode 100644 internal/sysproxy/sysproxy.go create mode 100644 internal/sysproxy/sysproxy_test.go create mode 100644 internal/sysproxy/system_other.go create mode 100644 internal/sysproxy/system_windows.go create mode 100644 internal/textutil/grapheme.go create mode 100644 internal/textutil/grapheme_test.go create mode 100644 internal/tool/builtin/bash.go create mode 100644 internal/tool/builtin/bash_cancel_test.go create mode 100644 internal/tool/builtin/bash_cancel_windows_test.go create mode 100644 internal/tool/builtin/bash_env_test.go create mode 100644 internal/tool/builtin/bash_heredoc_test.go create mode 100644 internal/tool/builtin/bash_powershell_test.go create mode 100644 internal/tool/builtin/bash_reap_test.go create mode 100644 internal/tool/builtin/bash_sandbox_escape_test.go create mode 100644 internal/tool/builtin/bash_sandbox_lockwait_test.go create mode 100644 internal/tool/builtin/bash_timeout_test.go create mode 100644 internal/tool/builtin/bgjobs.go create mode 100644 internal/tool/builtin/bgjobs_test.go create mode 100644 internal/tool/builtin/builtin_test.go create mode 100644 internal/tool/builtin/clientio.go create mode 100644 internal/tool/builtin/clientio_test.go create mode 100644 internal/tool/builtin/codeindex.go create mode 100644 internal/tool/builtin/codeindex_test.go create mode 100644 internal/tool/builtin/codeindex_treesitter.go create mode 100644 internal/tool/builtin/codeindex_treesitter_stub.go create mode 100644 internal/tool/builtin/codeindex_treesitter_test.go create mode 100644 internal/tool/builtin/completestep.go create mode 100644 internal/tool/builtin/completestep_test.go create mode 100644 internal/tool/builtin/confine.go create mode 100644 internal/tool/builtin/confine_test.go create mode 100644 internal/tool/builtin/crlf_edit_test.go create mode 100644 internal/tool/builtin/delete_range.go create mode 100644 internal/tool/builtin/delete_range_samedge_test.go create mode 100644 internal/tool/builtin/delete_range_test.go create mode 100644 internal/tool/builtin/delete_symbol.go create mode 100644 internal/tool/builtin/delete_symbol_doc_test.go create mode 100644 internal/tool/builtin/delete_symbol_test.go create mode 100644 internal/tool/builtin/e2e_encoding_test.go create mode 100644 internal/tool/builtin/editfile.go create mode 100644 internal/tool/builtin/encoding_helpers.go create mode 100644 internal/tool/builtin/fuzzy_edit_test.go create mode 100644 internal/tool/builtin/gitignore.go create mode 100644 internal/tool/builtin/gitignore_e2e_test.go create mode 100644 internal/tool/builtin/gitignore_test.go create mode 100644 internal/tool/builtin/glob.go create mode 100644 internal/tool/builtin/grep.go create mode 100644 internal/tool/builtin/grep_engine_test.go create mode 100644 internal/tool/builtin/ls.go create mode 100644 internal/tool/builtin/main_test.go create mode 100644 internal/tool/builtin/managed_config.go create mode 100644 internal/tool/builtin/movefile.go create mode 100644 internal/tool/builtin/multiedit.go create mode 100644 internal/tool/builtin/notebookedit.go create mode 100644 internal/tool/builtin/notebookedit_test.go create mode 100644 internal/tool/builtin/preview.go create mode 100644 internal/tool/builtin/preview_test.go create mode 100644 internal/tool/builtin/protected_dirs_darwin.go create mode 100644 internal/tool/builtin/protected_dirs_darwin_test.go create mode 100644 internal/tool/builtin/protected_dirs_other.go create mode 100644 internal/tool/builtin/readfile.go create mode 100644 internal/tool/builtin/readfile_stream_test.go create mode 100644 internal/tool/builtin/readfile_utf16_test.go create mode 100644 internal/tool/builtin/readfile_window_test.go create mode 100644 internal/tool/builtin/recursive_search_test.go create mode 100644 internal/tool/builtin/session_guard.go create mode 100644 internal/tool/builtin/session_guard_test.go create mode 100644 internal/tool/builtin/todo.go create mode 100644 internal/tool/builtin/todo_test.go create mode 100644 internal/tool/builtin/tool_extra_test.go create mode 100644 internal/tool/builtin/vendorskip_test.go create mode 100644 internal/tool/builtin/walk_cancel_test.go create mode 100644 internal/tool/builtin/web_fetch_proxy_test.go create mode 100644 internal/tool/builtin/webfetch.go create mode 100644 internal/tool/builtin/webfetch_ssrf_test.go create mode 100644 internal/tool/builtin/workspace.go create mode 100644 internal/tool/builtin/workspace_test.go create mode 100644 internal/tool/builtin/write_tool_test.go create mode 100644 internal/tool/builtin/writefile.go create mode 100644 internal/tool/configwrite.go create mode 100644 internal/tool/contract.go create mode 100644 internal/tool/contract_test.go create mode 100644 internal/tool/preview_test.go create mode 100644 internal/tool/progress.go create mode 100644 internal/tool/registry_canon_test.go create mode 100644 internal/tool/registry_test.go create mode 100644 internal/tool/resolved.go create mode 100644 internal/tool/sessiontool/sessiontool.go create mode 100644 internal/tool/sessiontool/sessiontool_test.go create mode 100644 internal/tool/subagentguard.go create mode 100644 internal/tool/tool.go create mode 100644 internal/winsandbox/LICENSE create mode 100644 internal/winsandbox/NOTICE.md create mode 100644 internal/winsandbox/README.md create mode 100644 internal/winsandbox/coordination_windows.go create mode 100644 internal/winsandbox/coordination_windows_test.go create mode 100644 internal/winsandbox/lockinfo.go create mode 100644 internal/winsandbox/lockinfo_test.go create mode 100644 internal/winsandbox/sandbox.go create mode 100644 internal/winsandbox/sandbox_other.go create mode 100644 internal/winsandbox/sandbox_windows.go create mode 100644 internal/winsandbox/sandbox_windows_test.go create mode 100644 npm/build.mjs create mode 100644 npm/reasonix/bin/reasonix.js create mode 100644 npm/reasonix/package.json create mode 100755 prod_fast_test create mode 100755 prod_test create mode 100644 reasonix.example.toml create mode 100644 scripts/backfill-issue-labels.mjs create mode 100755 scripts/cache-guard.sh create mode 100755 scripts/check-cache-impact.sh create mode 100755 scripts/desktop-build.sh create mode 100644 scripts/desktop-test-times.py create mode 100644 scripts/resolve-desktop-release.sh create mode 100644 scripts/update-acknowledgments.mjs create mode 100644 site/astro.config.mjs create mode 100644 site/package-lock.json create mode 100644 site/package.json create mode 100644 site/public/favicon.svg create mode 100644 site/public/logo.svg create mode 100644 site/public/og.png create mode 100644 site/public/robots.txt create mode 100644 site/scripts/fetch-community.mjs create mode 100644 site/src/data/community.json create mode 100644 site/src/data/contributors.json create mode 100644 site/src/layouts/Account.astro create mode 100644 site/src/layouts/Base.astro create mode 100644 site/src/layouts/Community.astro create mode 100644 site/src/pages/404.astro create mode 100644 site/src/pages/account.astro create mode 100644 site/src/pages/community/guidelines.astro create mode 100644 site/src/pages/community/index.astro create mode 100644 site/src/pages/community/new.astro create mode 100644 site/src/pages/community/topic.astro create mode 100644 site/src/pages/device.astro create mode 100644 site/src/pages/docs.astro create mode 100644 site/src/pages/forgot.astro create mode 100644 site/src/pages/index.astro create mode 100644 site/src/pages/login.astro create mode 100644 site/src/pages/register.astro create mode 100644 site/src/pages/reset.astro create mode 100644 site/src/pages/skills.astro create mode 100644 site/src/scripts/auth.js create mode 100644 site/src/scripts/community.js create mode 100644 site/src/scripts/fx.js create mode 100644 site/src/scripts/safe-next.js create mode 100644 site/src/scripts/safe-next.test.mjs create mode 100644 site/src/scripts/site.js create mode 100644 site/src/styles/community.css create mode 100644 site/src/styles/global.css create mode 100644 site/tsconfig.json create mode 100644 tools/write_heartbeat/heartbeat-tasks.json create mode 100644 tools/write_heartbeat/main.go create mode 100644 workers/accounts/.gitignore create mode 100644 workers/accounts/README.md create mode 100644 workers/accounts/migrations/0001_initial.sql create mode 100644 workers/accounts/migrations/0002_device_grants.sql create mode 100644 workers/accounts/package.json create mode 100644 workers/accounts/pnpm-lock.yaml create mode 100644 workers/accounts/src/app.ts create mode 100644 workers/accounts/src/auth/cookies.ts create mode 100644 workers/accounts/src/auth/crypto.ts create mode 100644 workers/accounts/src/config.ts create mode 100644 workers/accounts/src/db/deviceGrants.ts create mode 100644 workers/accounts/src/db/emailTokens.ts create mode 100644 workers/accounts/src/db/index.ts create mode 100644 workers/accounts/src/db/sessions.ts create mode 100644 workers/accounts/src/db/users.ts create mode 100644 workers/accounts/src/email/index.ts create mode 100644 workers/accounts/src/email/resend.ts create mode 100644 workers/accounts/src/email/types.ts create mode 100644 workers/accounts/src/env.ts create mode 100644 workers/accounts/src/http/auth.ts create mode 100644 workers/accounts/src/http/cors.ts create mode 100644 workers/accounts/src/http/errors.ts create mode 100644 workers/accounts/src/http/ratelimit.ts create mode 100644 workers/accounts/src/index.ts create mode 100644 workers/accounts/src/lib/handle.ts create mode 100644 workers/accounts/src/lib/validation.ts create mode 100644 workers/accounts/src/routes/auth.ts create mode 100644 workers/accounts/src/routes/device.ts create mode 100644 workers/accounts/src/routes/health.ts create mode 100644 workers/accounts/src/routes/me.ts create mode 100644 workers/accounts/src/routes/users.ts create mode 100644 workers/accounts/src/types.ts create mode 100644 workers/accounts/tsconfig.json create mode 100644 workers/accounts/wrangler.toml create mode 100644 workers/crash-report/.gitignore create mode 100644 workers/crash-report/migrate-access.sql create mode 100644 workers/crash-report/migrate-dashboard-indexes.sql create mode 100644 workers/crash-report/migrate-metric-users.sql create mode 100644 workers/crash-report/migrate-structured-reports.sql create mode 100644 workers/crash-report/migrate-title.sql create mode 100644 workers/crash-report/migrate.sql create mode 100644 workers/crash-report/package-lock.json create mode 100644 workers/crash-report/package.json create mode 100644 workers/crash-report/registry-schema.sql create mode 100644 workers/crash-report/schema.sql create mode 100644 workers/crash-report/src/admin.ts create mode 100644 workers/crash-report/src/auth.ts create mode 100644 workers/crash-report/src/auth_pages.ts create mode 100644 workers/crash-report/src/community.ts create mode 100644 workers/crash-report/src/desktop_release.ts create mode 100644 workers/crash-report/src/env.ts create mode 100644 workers/crash-report/src/index.ts create mode 100644 workers/crash-report/src/registry/app.ts create mode 100644 workers/crash-report/src/registry/db/events.ts create mode 100644 workers/crash-report/src/registry/db/index.ts create mode 100644 workers/crash-report/src/registry/db/packages.ts create mode 100644 workers/crash-report/src/registry/env.ts create mode 100644 workers/crash-report/src/registry/http/auth.ts create mode 100644 workers/crash-report/src/registry/http/cors.ts create mode 100644 workers/crash-report/src/registry/http/errors.ts create mode 100644 workers/crash-report/src/registry/http/ratelimit.ts create mode 100644 workers/crash-report/src/registry/lib/validation.ts create mode 100644 workers/crash-report/src/registry/routes/activity.ts create mode 100644 workers/crash-report/src/registry/routes/admin.ts create mode 100644 workers/crash-report/src/registry/routes/health.ts create mode 100644 workers/crash-report/src/registry/routes/packages.ts create mode 100644 workers/crash-report/src/registry/types.ts create mode 100644 workers/crash-report/src/shell.ts create mode 100644 workers/crash-report/src/stats.ts create mode 100644 workers/crash-report/tsconfig.json create mode 100644 workers/crash-report/wrangler.toml create mode 100644 workers/forum/.gitignore create mode 100644 workers/forum/package.json create mode 100644 workers/forum/pnpm-lock.yaml create mode 100644 workers/forum/schema.sql create mode 100644 workers/forum/seed.sql create mode 100644 workers/forum/src/antispam.ts create mode 100644 workers/forum/src/env.ts create mode 100644 workers/forum/src/identity.ts create mode 100644 workers/forum/src/index.ts create mode 100644 workers/forum/tsconfig.json create mode 100644 workers/forum/wrangler.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2478fe0 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env and fill in. Only secrets belong here — a provider's base_url and +# model are configured in reasonix.toml, where api_key_env points at these variables. + +DEEPSEEK_API_KEY= +MIMO_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..53072d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,28 @@ +.gitattributes text eol=lf +.gitignore text eol=lf +.npmrc text eol=lf +*.astro text eol=lf +*.css text eol=lf +*.go text eol=lf +*.html text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.mjs text eol=lf +*.mod text eol=lf +*.nsi text eol=lf +*.plist text eol=lf +*.py text eol=lf +*.rs text eol=lf +*.sh text eol=lf +*.sql text eol=lf +*.sum text eol=lf +*.toml text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.txt text eol=lf +*.xml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +.githooks/** text eol=lf +benchmarks/**/*.sh text eol=lf diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..14ea35e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +# Go-native pre-push gate replacing the retired v1 npm hook (install: make hooks). +# Full tests run in CI; vet is the fast, cross-platform local check. +set -e +go vet ./... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cb04e98 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Every pull request requests review from the maintainers below. +# Either maintainer can review and merge any change. +* @SivanCola @esengine diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..464c873 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,60 @@ +name: Bug report +description: Report a bug in DeepSeek-Reasonix +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for filing a bug. Pick which version line you're on below — it gets + labeled (`v1`/`v2`) automatically, no maintainer action needed. + - type: dropdown + id: version-line + attributes: + label: Version line + description: Which line are you running? + options: + - "v2 — Go rewrite (1.x), main-v2 (active development)" + - "v1 — Legacy TypeScript (0.x), maintenance only" + validations: + required: true + - type: input + id: version + attributes: + label: Exact version + description: Output of `reasonix --version`. + placeholder: e.g. 1.0.0 + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, and what you expected instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: input + id: os + attributes: + label: OS / platform + placeholder: e.g. macOS 15.3 (arm64), Ubuntu 24.04, Windows 11 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or output + description: Paste any error output. Automatically formatted as code. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1dc75ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & discussions + url: https://github.com/esengine/DeepSeek-Reasonix/discussions + about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a1d33f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an idea or improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: dropdown + id: version-line + attributes: + label: Version line + description: Which line is this for? + options: + - "v2 — Go rewrite (1.x), main-v2 (active development)" + - "v1 — Legacy TypeScript (0.x), maintenance only" + validations: + required: true + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How would you like it to work? + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cd9cf45 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + go: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "gomod" + directory: "/desktop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + go: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/desktop/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/site" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: ["*"] diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..9a4a2d6 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,51 @@ +# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml +# (actions/labeler), based on the changed file paths. Keep the area set in sync +# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change, +# not a bug report — and v1/v2 are handled by pr-version-label.yml. + +agent: + - changed-files: + - any-glob-to-any-file: + - 'internal/agent/**' + - 'internal/control/**' + +mcp: + - changed-files: + - any-glob-to-any-file: + - 'internal/plugin/**' + - 'internal/codegraph/**' + +config: + - changed-files: + - any-glob-to-any-file: + - 'internal/config/**' + - 'internal/boot/**' + +provider: + - changed-files: + - any-glob-to-any-file: + - 'internal/provider/**' + +skills: + - changed-files: + - any-glob-to-any-file: + - 'internal/skill/**' + - 'internal/tool/**' + +tui: + - changed-files: + - any-glob-to-any-file: + - 'internal/cli/**' + +desktop: + - changed-files: + - any-glob-to-any-file: + - 'desktop/**' + +updater: + - changed-files: + - any-glob-to-any-file: + - 'desktop/cmd/sign/**' + - 'desktop/updater*.go' + - 'scripts/desktop-build.sh' + - '.github/workflows/release*.yml' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f62dc5f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + +- + +## Verification + +- + +## Cache impact + +Cache-impact: TODO +Cache-guard: TODO +System-prompt-review: N/A + +For cache-sensitive changes, fill these lines before requesting review: + +- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason. +- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change. +- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes. diff --git a/.github/sponsor/wechat-pay.jpg b/.github/sponsor/wechat-pay.jpg new file mode 100644 index 0000000000000000000000000000000000000000..22906b3d32feb968d42060f545a77e8e8d963125 GIT binary patch literal 141819 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�-S%8Zcb|uNvPd?81mSWmr_kni-_&UKDml^ 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%0D^YB_c%LQy_^DV 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 literal 0 HcmV?d00001 diff --git a/.github/workflows/cache-impact.yml b/.github/workflows/cache-impact.yml new file mode 100644 index 0000000..6bae33e --- /dev/null +++ b/.github/workflows/cache-impact.yml @@ -0,0 +1,37 @@ +name: Cache impact guard + +on: + pull_request_target: + branches: [main-v2] + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: cache-impact-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + cache-impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + + - name: Collect PR changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt" + + - name: Check cache-sensitive PR metadata + env: + PR_BODY: ${{ github.event.pull_request.body }} + CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt + run: bash scripts/check-cache-impact.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1666cdc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,283 @@ +name: CI + +on: + push: + branches: [main-v2] + pull_request: + branches: [main-v2] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every + # file. gofmt output is OS-independent, so the Unix legs already cover it. + - name: gofmt + if: runner.os != 'Windows' + run: | + # Root module only — desktop/ is a separate module with its own tooling. + unformatted=$(gofmt -l . | grep -v '^desktop/' || true) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: vet + run: go vet ./... + + - name: build + run: go build ./... + + - name: test + if: runner.os != 'Windows' + env: + # Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a + # regression there silently tanks the cache hit rate the project is + # built around. + REASONIX_RELEASE_CACHE_GUARD: "1" + run: go test ./... + + - name: test + if: runner.os == 'Windows' + timeout-minutes: 10 + env: + # Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a + # regression there silently tanks the cache hit rate the project is + # built around. + REASONIX_RELEASE_CACHE_GUARD: "1" + # Bound sandbox helper children in Windows tests so a failed OS-level + # launch cannot pin the Actions step after Go's package timeout fires. + WINDOWS_SANDBOX_WAIT_MS: "20000" + run: go test -p 4 -timeout=3m ./... + + race: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # The matrix never runs -race (it needs cgo); the project's concurrency + # (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise + # ship without race coverage. + - name: test -race + env: + REASONIX_RELEASE_CACHE_GUARD: "1" + run: go test -race ./... + + desktop: + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: desktop + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend/pnpm-lock.yaml + + - name: Install Wails CLI + run: | + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: go.mod tidy + run: | + go mod tidy + if ! git diff --quiet -- go.mod go.sum; then + echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit." + git diff -- go.mod go.sum + exit 1 + fi + + # WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag). + - name: Install Linux build deps + run: | + sudo apt-get update + sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev + + - name: Build frontend + run: | + wails generate module + pnpm --dir frontend install --frozen-lockfile + pnpm --dir frontend build + + - name: vet + run: go vet ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + working-directory: desktop + args: --timeout=5m + + - name: build + run: go build ./... + + - name: test + run: go test ./... + + # Desktop project-root matching is case-insensitive only on Windows + # (sameDesktopPath folds case when os.PathSeparator is '\'), so the + # regression tests for that contract are named *OnWindows and can never + # run on the ubuntu leg above. + desktop-windows: + runs-on: windows-latest + defaults: + run: + shell: bash + working-directory: desktop + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend/pnpm-lock.yaml + + - name: Install Wails CLI + run: | + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + # go:embed of frontend/dist needs a built frontend before the package + # compiles, same as the ubuntu desktop leg. + - name: Build frontend + run: | + wails generate module + pnpm --dir frontend install --frozen-lockfile + pnpm --dir frontend build + + - name: test (Windows-only path semantics) + timeout-minutes: 10 + run: go test . -run 'OnWindows$' -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + args: --timeout=5m + + # The site/ auth client has security-sensitive redirect-validation logic + # (safeNext) covered by node:test unit tests. Those tests use only Node + # builtins, so no `npm install` is needed — run them directly on every PR so + # a regression in redirect validation fails the build instead of shipping. + site: + runs-on: ubuntu-latest + defaults: + run: + working-directory: site + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: test + run: npm test + + govulncheck: + runs-on: ubuntu-latest + continue-on-error: true # informational — stdlib vulns need a Go patch release + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: govulncheck + run: govulncheck ./... + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: test with coverage + run: go test -coverprofile=coverage.out -covermode=atomic ./... + + - name: upload coverage + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: coverage.out + retention-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..2e79b36 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: ["main-v2"] + pull_request: + branches: ["main-v2"] + schedule: + - cron: "27 3 * * 1" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + - language: actions + build-mode: none + steps: + - uses: actions/checkout@v7 + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deploy-accounts-worker.yml b/.github/workflows/deploy-accounts-worker.yml new file mode 100644 index 0000000..afd8d89 --- /dev/null +++ b/.github/workflows/deploy-accounts-worker.yml @@ -0,0 +1,49 @@ +name: Deploy accounts worker + +on: + push: + branches: [main-v2] + paths: + - 'workers/accounts/**' + - '.github/workflows/deploy-accounts-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-accounts-worker + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: workers/accounts/pnpm-lock.yaml + - name: Deploy + working-directory: workers/accounts + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + pnpm install --frozen-lockfile + npx wrangler deploy + - name: Sync RESEND_API_KEY to the worker + working-directory: workers/accounts + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + run: | + if [ -n "$RESEND_API_KEY" ]; then + printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY + else + echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)." + fi diff --git a/.github/workflows/deploy-crash-worker.yml b/.github/workflows/deploy-crash-worker.yml new file mode 100644 index 0000000..992fcd3 --- /dev/null +++ b/.github/workflows/deploy-crash-worker.yml @@ -0,0 +1,34 @@ +name: Deploy crash worker + +on: + push: + branches: [main-v2] + paths: + - 'workers/crash-report/**' + - '.github/workflows/deploy-crash-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-crash-worker + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: workers/crash-report/package-lock.json + - name: Deploy + working-directory: workers/crash-report + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + npm ci + npx wrangler deploy diff --git a/.github/workflows/deploy-forum-worker.yml b/.github/workflows/deploy-forum-worker.yml new file mode 100644 index 0000000..7f2ca2a --- /dev/null +++ b/.github/workflows/deploy-forum-worker.yml @@ -0,0 +1,38 @@ +name: Deploy forum worker + +on: + push: + branches: [main-v2] + paths: + - 'workers/forum/**' + - '.github/workflows/deploy-forum-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-forum-worker + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + version: 10 + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: workers/forum/pnpm-lock.yaml + - name: Deploy + working-directory: workers/forum + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + pnpm install --frozen-lockfile + npx wrangler deploy diff --git a/.github/workflows/e2e-bot.yml b/.github/workflows/e2e-bot.yml new file mode 100644 index 0000000..1e445b0 --- /dev/null +++ b/.github/workflows/e2e-bot.yml @@ -0,0 +1,160 @@ +name: e2e-bot + +# Comment "/e2e" (fixed suite) or "/e2e diff" (generate tests for the PR's diff) +# on a pull request to run the e2e benchmark against the real provider and post a +# report back. Gated to trusted authors: the job checks out PR-head code and runs +# it with the provider API key, so only the repo owner, members, and collaborators +# may trigger it. + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: e2e-bot-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + e2e: + if: >- + github.event.issue.pull_request && + contains(github.event.comment.body, '/e2e') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + # Running PR-head code with the provider secret is gated twice: the author_ + # association check above, and this deployment environment. Configure the + # `e2e-bot` environment with required reviewers in repo settings to force a + # human approval per run (actions/untrusted-checkout-toctou). + environment: e2e-bot + steps: + - name: Acknowledge + uses: actions/github-script@v9 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, repo: context.repo.repo, + comment_id: context.payload.comment.id, content: 'eyes', + }); + + # Default-branch checkout: this is where the harness (cmd/e2ebench), the + # suite, and a run --metrics-capable agent live. + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Build harness + fallback agent from the default branch + # Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken + # its own grader or tests. The agent is rebuilt from the PR head below; this + # main-v2 build is only the fallback for heads that predate `run --metrics`. + run: | + go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix + go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench + cp -r benchmarks/e2e "$RUNNER_TEMP/suite" + + - name: Check out the PR head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pin to the head commit resolved now and check it out detached, not the + # mutable PR ref: a force-push mid-run can't swap in different code after + # the trusted-author gate passed. + run: | + SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid) + git fetch -q origin "$SHA" + git checkout -q --detach "$SHA" + + - name: Build the agent from the PR head + # The whole point of the bot is to drive the PR's code, not main-v2's. Fall + # back to the main-v2 build only when the PR head can't yield a + # run --metrics-capable binary (build break or predates the flag). + id: agent + run: | + bin="$RUNNER_TEMP/reasonix-base" + src="main-v2 fallback (PR head lacks run --metrics)" + if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \ + && "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then + bin="$RUNNER_TEMP/reasonix-pr" + src="PR head ($(git rev-parse --short HEAD))" + fi + echo "bin=$bin" >> "$GITHUB_OUTPUT" + echo "src=$src" >> "$GITHUB_OUTPUT" + echo "agent under test: $src" + + - name: Write provider config + # User config covers suite tasks (they run in temp dirs); the repo-root copy + # covers diff mode (the agent runs in the repo root, where project config wins). + run: | + mkdir -p ~/.config/reasonix + cat > /tmp/reasonix-e2e.toml < report.md + exit 0 + fi + if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then + ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1) + [ -z "$ATTEMPTS" ] && ATTEMPTS=1 + [ "$ATTEMPTS" -gt 5 ] && ATTEMPTS=5 + BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName) + git fetch -q origin "$BASE_REF" + BASE=$(git merge-base "origin/$BASE_REF" HEAD) + "$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md + else + "$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000 + fi + + - name: Post report + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TRIGGER_USER: ${{ github.event.comment.user.login }} + run: | + printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md + gh pr comment ${{ github.event.issue.number }} --body-file report.md + + - name: Report failure + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr comment ${{ github.event.issue.number }} --body "🤖 e2e bot failed — see the [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml new file mode 100644 index 0000000..7b1a9c0 --- /dev/null +++ b/.github/workflows/issue-auto-label.yml @@ -0,0 +1,99 @@ +name: Auto-label issues + +# Classify new issues into area / platform / severity labels using the DeepSeek +# API (the project's own model). The model is constrained to a fixed label set — +# anything it returns outside the set is dropped — so it can't invent labels. +# Issues it can't place into any area get `needs-triage` for a human to look at. +# Version (v1/v2) labels are handled separately by issue-version-label.yml. +on: + issues: + types: [opened, reopened] + +permissions: + issues: write + +concurrency: + group: issue-autolabel-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + classify: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + with: + script: | + const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering']; + const PLATFORM = ['windows','macos','linux']; + const SEVERITY = ['crash','data-loss','security']; + const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]); + + const issue = context.payload.issue; + const title = issue.title || ''; + const body = (issue.body || '').slice(0, 4000); + + const system = [ + 'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.', + 'Pick labels ONLY from these fixed sets. Never invent labels.', + 'area (0-2, the affected subsystem):', + ' agent: core agent loop / tool-calling / reasoning', + ' mcp: MCP servers, plugins, codegraph', + ' config: configuration, setup wizard, .toml/.env', + ' updater: auto-update, installer, release packaging', + ' provider: model providers, model selection/switching', + ' desktop: Wails desktop GUI', + ' tui: terminal UI / CLI', + ' skills: skills system', + ' rendering: terminal rendering / flicker / repaint', + 'platform (only if clearly specific to one OS): windows, macos, linux', + 'severity (only if clearly applicable):', + ' crash: app crashes, hangs, or freezes', + ' data-loss: loss of sessions, config, or history', + ' security: credential/secret exposure or a security flaw', + 'Be conservative: omit a label when unsure. The issue may be in Chinese.', + 'Reply with JSON only: {"area":[],"platform":[],"severity":[]}', + ].join('\n'); + + let labels = []; + try { + const res = await fetch('https://api.deepseek.com/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-chat', + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: `Title: ${title}\n\nBody:\n${body}` }, + ], + }), + }); + if (!res.ok) { + core.warning(`DeepSeek API ${res.status}: ${await res.text()}`); + return; + } + const data = await res.json(); + const parsed = JSON.parse(data.choices[0].message.content); + labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])] + .filter(l => ALLOWED.has(l)); + } catch (e) { + core.warning(`Classification failed: ${e.message}`); + return; + } + + if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage'); + + if (labels.length) { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: issue.number, + labels, + }); + core.info(`Applied: ${labels.join(', ')}`); + } diff --git a/.github/workflows/issue-version-label.yml b/.github/workflows/issue-version-label.yml new file mode 100644 index 0000000..81c9578 --- /dev/null +++ b/.github/workflows/issue-version-label.yml @@ -0,0 +1,48 @@ +name: Label issue version + +# Issue forms can't let a reporter set a label directly (that needs triage rights), +# so the bug/feature forms carry a "Version line" dropdown and this workflow reads +# the submitted value and applies the matching v1/v2 label on their behalf. +on: + issues: + types: [opened, edited] + +permissions: + issues: write + +concurrency: + group: issue-version-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const body = context.payload.issue.body || ''; + // Pull the "Version line" section out of the rendered issue form. + const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || ''; + const m = section.match(/\bv([12])\b/i); + if (!m) { + core.info('No version line found; nothing to label.'); + return; + } + const choice = 'v' + m[1]; + const other = choice === 'v2' ? 'v1' : 'v2'; + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.issue.number, + labels: [choice], + }); + // Keep v1/v2 mutually exclusive in case an edit flipped the choice. + try { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: context.issue.number, + name: other, + }); + } catch (e) { + core.info(`No ${other} label to remove.`); + } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..f6d03d0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Deploy site + +on: + push: + branches: [main-v2] + paths: ['site/**', '.github/workflows/pages.yml'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: site/package-lock.json + - name: Build + working-directory: site + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm ci + npm run build + - uses: actions/upload-pages-artifact@v5 + with: + path: site/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml new file mode 100644 index 0000000..6f1b429 --- /dev/null +++ b/.github/workflows/pr-auto-label.yml @@ -0,0 +1,28 @@ +name: Auto-label PRs + +# Apply area labels to PRs based on changed paths (see .github/labeler.yml). +# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2) +# labels are handled separately by pr-version-label.yml. +# +# pull_request_target runs in the base repo context so it has the write token +# needed to label PRs from forks; actions/labeler only reads the diff file list +# (it never checks out or runs PR code), so this is safe. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-autolabel-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v6 + with: + sync-labels: false diff --git a/.github/workflows/pr-version-label.yml b/.github/workflows/pr-version-label.yml new file mode 100644 index 0000000..d2d764e --- /dev/null +++ b/.github/workflows/pr-version-label.yml @@ -0,0 +1,49 @@ +name: Label PR version + +# Contributors (especially from forks) can't set labels themselves — that needs +# triage rights. A PR's version line is fully determined by its base branch, so +# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs +# in the base repo's context so it has write access even for fork PRs; it only +# reads trusted metadata (the base ref) and never checks out or runs PR code. +on: + pull_request_target: + types: [opened, reopened, edited] + +permissions: + pull-requests: write + +concurrency: + group: pr-version-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + with: + script: | + const base = context.payload.pull_request.base.ref; + const label = base === 'v1' ? 'v1' + : base === 'main-v2' ? 'v2' + : null; + if (!label) { + core.info(`Base branch '${base}' isn't a release line; skipping.`); + return; + } + const other = label === 'v2' ? 'v1' : 'v2'; + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: context.payload.pull_request.number, + labels: [label], + }); + // Drop the other line's label if the base was changed after opening. + try { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: context.payload.pull_request.number, + name: other, + }); + } catch (e) { + core.info(`No ${other} label to remove.`); + } diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000..830b161 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,485 @@ +name: Release desktop + +# Desktop (Wails) release line. Tag namespace `desktop-v` triggers this — +# plain `v` tags are the CLI release (release.yml) and intentionally do NOT +# trigger here (GitHub glob `v*` does not match `desktop-v*`). Bump with: +# git tag desktop-vX.Y.Z && git push origin desktop-vX.Y.Z +# +# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native +# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a +# latest.json manifest is generated, and everything is published to a GitHub +# release and mirrored to R2 (the updater reads R2 first, then the crash worker +# release gateway; stable desktop releases own GitHub's repository-wide "latest"). +# +# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag +# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/ +# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary +# and uploads only to R2's canary/ pointer — no GitHub release, so canary never +# shows on the releases page and never touches latest/. +on: + push: + tags: ["desktop-v*"] + workflow_dispatch: + inputs: + channel: + description: "Release channel" + type: choice + options: [stable, canary] + default: stable + tag: + description: "stable: tag to publish (e.g. desktop-v1.1.0)" + required: false + type: string + base_version: + description: "canary: intended next stable version, e.g. 1.5.0" + required: false + type: string + +permissions: + contents: write # create the release and upload artifacts + +jobs: + cache-guard: + name: cache hit guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Cache hit guard + run: ./scripts/cache-guard.sh + + build: + name: build (${{ matrix.name }}) + needs: cache-guard + permissions: + contents: read # checkout only; the publish job holds contents: write + actions: read # SignPath reads run details + downloads the unsigned artifact + strategy: + fail-fast: false + matrix: + include: + # One universal macOS build (Intel + Apple Silicon) on a single arm64 + # runner — avoids the scarce/slow macos-13 Intel runner. + - { runner: macos-14, platform: darwin/universal, name: darwin-universal } + - { runner: windows-latest, platform: windows/amd64, name: windows-amd64 } + - { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 } + - { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 } + runs-on: ${{ matrix.runner }} + env: + # Windows Authenticode signing engages only when the SignPath token is set, so + # forks / token-less runs still build (unsigned), mirroring the APPLE_* gate. + HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }} + defaults: + run: + shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: actions/setup-node@v6 + with: + node-version: "22" + - uses: pnpm/action-setup@v6 + with: + version: 10 + + # Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh). + # 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+. + - name: Install Linux build deps + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev + + # Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install + # drops it in ~/go/bin, already on PATH (same place the wails CLI lands below). + - name: Install nfpm + if: runner.os == 'Linux' + run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3 + + # Windows: NSIS provides makensis for `wails build -nsis`. + - name: Install NSIS + if: runner.os == 'Windows' + run: | + choco install nsis -y --no-progress + echo "C:\Program Files (x86)\NSIS" >> "$GITHUB_PATH" + + # macOS: create-dmg packages the .app into a drag-to-Applications .dmg. + - name: Install create-dmg + if: runner.os == 'macOS' + run: brew install create-dmg + + # macOS signing: import the Developer ID cert into a throwaway keychain and + # stage the notarization key. No-ops (and the build ad-hoc signs) when the + # APPLE_* secrets aren't set, so forks still build. + - name: Import Apple signing certificate + if: runner.os == 'macOS' + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + if [ -z "$APPLE_CERT_P12" ]; then + echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)" + exit 0 + fi + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASS="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN" + echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null + # Prepend the signing keychain to the search list so codesign / find-identity see it. + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8" + rm -f "$RUNNER_TEMP/cert.p12" + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + + - name: Resolve version + id: ver + run: bash scripts/resolve-desktop-release.sh + env: + EVENT_NAME: ${{ github.event_name }} + IN_CHANNEL: ${{ inputs.channel }} + IN_TAG: ${{ inputs.tag }} + IN_BASE_VERSION: ${{ inputs.base_version }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + + - name: Build and package + env: + # macOS Developer ID + notarization path turns on only when all five + # APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs. + # Harmless on Windows/Linux runners (only the darwin branch reads these). + HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}" + + # Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign: + # the signature rewrites the installer's bytes, so the updater's minisign sig and + # the manifest SHA must be computed over the signed build. SignPath pulls the file + # from the Actions artifact API, so it must be uploaded first. Canary signs with the + # test certificate; stable/rc sign with the Foundation release certificate, but only + # once SIGNPATH_RELEASE_SIGNING_READY is set — until that cert clears CSR-pending, + # stable ships unsigned instead of failing the whole release on an invalid policy. + - name: Upload unsigned installer for SignPath + if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') + id: unsigned-installer + uses: actions/upload-artifact@v7 + with: + name: unsigned-${{ matrix.name }} + path: dist/*installer*.exe + if-no-files-found: error + retention-days: 1 + + - name: Submit installer for Authenticode signing + if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + project-slug: DeepSeek-Reasonix + signing-policy-slug: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }} + artifact-configuration-slug: windows-installer + github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }} + github-token: ${{ github.token }} + wait-for-completion: true + # The policy uses an approval process — leave headroom for a human to approve + # the request (the approver gets an email on submit) before the job gives up. + wait-for-completion-timeout-in-seconds: 1800 + output-artifact-directory: signed + + - name: Replace installer with signed build + if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true') + run: cp signed/*installer*.exe dist/ + + - name: Sign artifacts (minisign) + working-directory: desktop + env: + MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: go run ./cmd/sign sign ../dist/* + + - uses: actions/upload-artifact@v7 + with: + name: dist-${{ matrix.name }} + path: dist/* + if-no-files-found: error + + publish: + name: publish release + needs: build + runs-on: ubuntu-latest + # Stable publishes go through the `release` environment (esengine must + # approve); canary uses the open `canary` environment so maintainers self-serve. + environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: desktop/go.mod + cache: true + cache-dependency-path: desktop/go.sum + + - uses: actions/download-artifact@v8 + with: + path: dist + pattern: dist-* + merge-multiple: true + + - name: Resolve version + id: ver + run: bash scripts/resolve-desktop-release.sh + env: + EVENT_NAME: ${{ github.event_name }} + IN_CHANNEL: ${{ inputs.channel }} + IN_TAG: ${{ inputs.tag }} + IN_BASE_VERSION: ${{ inputs.base_version }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + + # Generate latest.json with GitHub release download URLs; the mirror step + # rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner. + - name: Generate manifest + working-directory: desktop + run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}" + + # Canary never appears on the GitHub releases page; the mirror job picks up + # the signed dist via the canary-dist artifact below. Stable publishes a + # GitHub release as usual. + - name: Publish GitHub release + if: steps.ver.outputs.channel != 'canary' + env: + GH_TOKEN: ${{ github.token }} + run: | + # Keep the repository homepage focused on the installable desktop app; + # the CLI release line is configured not to claim repository-wide latest. + args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes) + if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then + args+=(--prerelease --latest=false) + else + args+=(--latest) + fi + gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}" + + - name: Upload canary dist for mirror + if: steps.ver.outputs.channel == 'canary' + uses: actions/upload-artifact@v7 + with: + name: canary-dist + path: dist/* + if-no-files-found: error + + mirror: + name: mirror to R2 + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # gh release download + compatibility manifest upload + actions: write # dispatch pages.yml to re-bake the site version + # Skip cleanly when R2 isn't configured; GitHub release still works as fallback. + if: ${{ github.repository_owner == 'esengine' }} + env: + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + steps: + - uses: actions/checkout@v7 + + - name: Resolve version + id: ver + run: bash scripts/resolve-desktop-release.sh + env: + EVENT_NAME: ${{ github.event_name }} + IN_CHANNEL: ${{ inputs.channel }} + IN_TAG: ${{ inputs.tag }} + IN_BASE_VERSION: ${{ inputs.base_version }} + REF_NAME: ${{ github.ref_name }} + RUN_NUMBER: ${{ github.run_number }} + + # Canary has no GitHub release — pull the signed dist from the workflow + # artifact. Stable pulls from the published release. + - name: Download canary dist + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary' + uses: actions/download-artifact@v8 + with: + name: canary-dist + path: assets + + - name: Download release assets + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p assets + gh release download "${{ steps.ver.outputs.tag }}" -R "${{ github.repository }}" -D assets + + # Rewrite both url and sig inside latest.json from github.com to the R2 CDN, + # so the updater pulls the manifest AND the heavy artifacts from R2. + - name: Rewrite latest.json URLs to R2 + if: env.HAS_R2 == 'true' + env: + R2_PUBLIC_BASE: https://dl.reasonix.io + TAG: ${{ steps.ver.outputs.tag }} + run: | + f=assets/latest.json + jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" ' + .platforms |= with_entries( + .value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/") + | .value.sig |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/") + ) + ' "$f" > "$f.new" + mv "$f.new" "$f" + cat "$f" + + - name: Configure AWS CLI for R2 + if: env.HAS_R2 == 'true' + run: | + aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}" + aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}" + aws configure set region auto + + - name: Mirror to R2 + if: env.HAS_R2 == 'true' + env: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT" + # The pointer a release moves is its channel's alone: canary writes + # canary/, stable writes latest/. A stable prerelease (rc) moves neither. + if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then + aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT" + elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then + echo "prerelease — leaving R2 latest/ untouched" + else + aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT" + fi + + # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot + # protection), so smoke the mirrored objects over the authenticated S3 API + # instead of the public edge. This verifies the mirror landed; the public + # edge itself is not reachable from CI and is covered by end users' traffic. + - name: Smoke desktop release pointers + if: env.HAS_R2 == 'true' + env: + TAG: ${{ steps.ver.outputs.tag }} + VERSION: ${{ steps.ver.outputs.version }} + CHANNEL: ${{ steps.ver.outputs.channel }} + PRERELEASE: ${{ steps.ver.outputs.prerelease }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + f=assets/latest.json + jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" ' + .version == $version and + .download_page == "https://reasonix.io/#start" and + ([.platforms[] | (.url, .sig)] | + all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not))) + ' "$f" >/dev/null + + aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT" + jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null + if [ "$CHANNEL" = "canary" ]; then + pointer="canary" + elif [ "$PRERELEASE" = "true" ]; then + pointer="" + else + pointer="latest" + fi + if [ -n "$pointer" ]; then + aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT" + jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null + fi + + jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do + key="${asset#https://dl.reasonix.io/}" + aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null + done + + # Best-effort probe of the release gateway — the updater's second + # manifest source — over the same public edge and Go client UA end users + # hit. A 403 here is the known Cloudflare bot-protection gap (#6005: + # datacenter/proxy egress gets blocked before the worker runs) and must + # not fail the release until a WAF skip rule for /v1/desktop/releases/* + # lands; it is surfaced as a warning so the run shows whether the edge + # is open. Anything else unexpected (404, 5xx, wrong version) means the + # gateway route or pointer regressed and fails hard. + - name: Probe public release gateway + if: env.HAS_R2 == 'true' + env: + VERSION: ${{ steps.ver.outputs.version }} + CHANNEL: ${{ steps.ver.outputs.channel }} + PRERELEASE: ${{ steps.ver.outputs.prerelease }} + run: | + set -euo pipefail + if [ "$PRERELEASE" = "true" ]; then + echo "prerelease — no pointer moved; skipping gateway probe" + exit 0 + fi + chan="stable" + [ "$CHANNEL" = "canary" ] && chan="canary" + url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json" + # curl already prints 000 for a transport failure; || true keeps -e + # from killing the step so the case below can route it. + code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)" + case "$code" in + 200) + if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then + echo "gateway serves $VERSION on $chan" + else + echo "::error::gateway responded 200 but serves $(jq -r '.version // ""' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer" + exit 1 + fi + ;; + 403) + echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release" + ;; + 000|"") + echo "::warning::gateway unreachable from CI (transport error), not failing the release" + ;; + *) + echo "::error::gateway returned $code for $url — route or pointer regression" + exit 1 + ;; + esac + + - name: Attach desktop manifest to matching CLI release + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + if gh release view "$VERSION" >/dev/null 2>&1; then + gh release upload "$VERSION" assets/latest.json --clobber + else + echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes." + fi + + # Stable release moved R2 latest/ — rebuild the site so its build-time baked + # version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO). + - name: Refresh site to the new version + if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run pages.yml --ref main-v2 diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml new file mode 100644 index 0000000..7780d2a --- /dev/null +++ b/.github/workflows/release-npm.yml @@ -0,0 +1,114 @@ +name: Release npm + +# npm line. Tag namespace `npm-v` triggers this — plain `v` tags +# are the CLI binary + Homebrew release (release.yml) and intentionally do NOT +# trigger here (GitHub glob `npm-v*` does not match `v*`). Splitting npm onto its +# own tag lets a prerelease ship to npm's `next` channel without touching the +# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave +# its rc). Bump with: +# git tag npm-vX.Y.Z # stable -> publishes under `latest` +# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next` +# git push origin +# +# A manual workflow_dispatch can publish either an opt-in canary from the current +# ref or an approved stable/next version when tag creation is restricted. Canary +# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves +# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`, +# any other prerelease is `next`, and a stable version is `latest` (#5822 — the +# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded +# `npm update -g` users). The verify step below asserts the tag actually landed. +on: + push: + tags: ['npm-v*'] + workflow_dispatch: + inputs: + channel: + description: "Publish channel" + required: true + default: canary + type: choice + options: + - canary + - stable + base_version: + description: "Version to publish. Canary appends -canary.; stable publishes exactly this version." + required: true + type: string + +permissions: + contents: read + +jobs: + cache-guard: + name: cache hit guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: ./scripts/cache-guard.sh + + npm: + name: publish npm packages + needs: cache-guard + runs-on: ubuntu-latest + # A tag push (npm-v*) or manual stable dispatch publishes a stable/next + # release and goes through the `release` environment (esengine approves); + # canary dispatches run free. + environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v6 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + # Tag push uses the npm-v* tag. A canary dispatch synthesizes a + # -canary. version; a manual stable dispatch publishes + # the requested semver exactly. build.mjs strips the leading `npm-`/`v`. + - name: Resolve version + id: ver + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + base="${{ inputs.base_version }}"; base="${base#v}" + if [ "${{ inputs.channel }}" = "canary" ]; then + echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT" + else + echo "arg=v${base}" >> "$GITHUB_OUTPUT" + fi + else + echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + - run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Assert the dist-tag landed where build.mjs aimed it. This is the guard + # that #5822 lacked: `latest` sat on 0.53.2 for months while stables went + # to `next`, and nothing noticed until users got downgraded. The registry + # applies tags asynchronously after publish, hence the poll. + - name: Verify dist-tags + run: | + set -euo pipefail + version="${{ steps.ver.outputs.arg }}" + version="${version#npm-}"; version="${version#v}" + case "$version" in + *-canary.*) tag=canary ;; + *-*) tag=next ;; + *) tag=latest ;; + esac + for attempt in 1 2 3 4 5 6; do + got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)" + if [ "$got" = "$version" ]; then + echo "dist-tag $tag -> $got" + exit 0 + fi + echo "dist-tag $tag -> ${got:-}, want $version (attempt $attempt)" + sleep 10 + done + echo "::error::npm dist-tag '$tag' never landed on $version" + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ae9151a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,173 @@ +name: Release + +# CLI binary line. Tag namespace `v` triggers this — it builds the +# release archives + checksums, publishes the GitHub release, and updates the +# Homebrew cask. npm is a SEPARATE line (release-npm.yml, `npm-v*`) so an npm +# prerelease can ship without forcing the stable cask, and vice versa; desktop is +# `desktop-v*` (release-desktop.yml). A prerelease `v*-rc*` still builds archives +# and a prerelease GitHub release, but goreleaser auto-skips the cask upload +# (brew has no prerelease channel — see .goreleaser.yaml). +on: + push: + tags: ['v*'] + +permissions: + contents: write # create the release and upload archives + +jobs: + cache-guard: + name: cache hit guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: ./scripts/cache-guard.sh + + goreleaser: + name: archives + checksums + homebrew tap + needs: cache-guard + runs-on: ubuntu-latest + # CLI stable release (archives + GitHub release + Homebrew cask). It does not + # claim GitHub's repository-wide Latest badge; that belongs to desktop so the + # repository homepage points users at the installable app. Gated on the + # `release` environment so only esengine can approve it going public. + environment: release + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: goreleaser/goreleaser-action@v7 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Attach desktop manifest compatibility asset + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + case "$TAG" in + *-*) + echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset" + exit 0 + ;; + esac + if [ "$HAS_R2" != "true" ]; then + echo "R2 secrets not configured; skipping desktop manifest compatibility asset" + exit 0 + fi + # dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot + # protection), so read the manifest over the authenticated S3 API instead + # of the public edge. + aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}" + aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}" + aws configure set region auto + aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json + jq -e ' + ([.platforms[] | (.url, .sig)] | + all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not))) + ' latest.json >/dev/null + gh release upload "$TAG" latest.json --clobber + + # The compatibility asset exists for pre-v1.16 desktop updaters that + # still poll GitHub's repository-wide latest URL. Desktop releases now + # own that Latest badge, but this check still exercises the public fallback + # path exactly the way those clients fetch it: anonymously, over the public + # edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection + # 403s Actions egress — see the R2 note above), GitHub serves its own + # runners, so this can hard-fail. #5826/#5858 shipped a broken update check + # for weeks precisely because nothing exercised the public path. Retries + # cover the release CDN propagating the freshly uploaded asset. + - name: Smoke public compatibility manifest + env: + TAG: ${{ github.ref_name }} + HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} + run: | + set -euo pipefail + case "$TAG" in + *-*) + echo "prerelease $TAG — no compatibility asset uploaded; skipping" + exit 0 + ;; + esac + if [ "$HAS_R2" != "true" ]; then + echo "R2 secrets not configured; no compatibility asset uploaded; skipping" + exit 0 + fi + url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json" + for attempt in 1 2 3 4 5 6; do + if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then + jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null + echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))" + exit 0 + fi + echo "attempt $attempt failed; retrying in 10s" + sleep 10 + done + echo "::error::public compatibility manifest unreachable at $url" + exit 1 + + # A stable CLI release must never leave the npm line behind: v1.17.5 + # shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2 + # (#5822) — every `npm update -g` user was silently downgraded to a + # months-old version, and nothing noticed because the npm line + # (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the + # stable npm tag was simply never pushed. release-npm.yml's own verify + # step only guards runs that happen; this guard catches the run that + # DIDN'T. + # + # Two distinct states, two responses (the runs race: both workflows wait + # on the release environment independently, and npm dist-tags propagate + # asynchronously, so "tag pushed but latest not moved yet" is a NORMAL + # mid-release state, not a failure): + # - npm-v tag missing -> hard fail. This is the #5822 gap: + # nobody pushed the npm release at all. + # - tag pushed, latest lagging -> poll briefly, then WARN and pass. + # The npm run may still be awaiting its own environment approval; + # release-npm.yml's verify step owns asserting the dist-tag lands. + - name: Check npm latest dist-tag freshness + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + case "$TAG" in + *-*) + echo "prerelease $TAG — npm latest does not move on prereleases; skipping" + exit 0 + ;; + esac + version="${TAG#v}" + if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then + echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)." + exit 1 + fi + for attempt in 1 2 3 4 5 6; do + got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)" + if [ -n "$got" ]; then + newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)" + if [ "$newest" = "$got" ]; then + echo "npm latest -> $got (>= $version) OK" + exit 0 + fi + fi + echo "npm latest -> ${got:-}, want >= $version (attempt $attempt)" + sleep 10 + done + echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands." + exit 0 diff --git a/.github/workflows/update-acknowledgments.yml b/.github/workflows/update-acknowledgments.yml new file mode 100644 index 0000000..56a870c --- /dev/null +++ b/.github/workflows/update-acknowledgments.yml @@ -0,0 +1,37 @@ +name: Update acknowledgments + +on: + workflow_dispatch: + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Update README contributor tables + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/update-acknowledgments.mjs + - name: Open pull request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: chore/update-acknowledgments + delete-branch: true + title: 'Update acknowledgments contributors / 更新致谢贡献者' + commit-message: 'docs: update acknowledgments contributors' + body: | + Refreshes the README acknowledgments tables from the GitHub contributors API. + + - Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1` + - Anonymous contributors are shown without email addresses. + labels: documentation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbfdf4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Build artifacts +/bin/ +/dist/ +/stage/ +/npm/.stage/ +/reasonix +/reasonix.exe + +# Website (Astro) build output +/site/dist/ +/site/.astro/ +/site/_preview.png + +# Go test/build scratch +*.test +*.out +*.coverprofile +coverage.out +cpu.prof +mem.prof + +# Wails desktop artifacts +/desktop/build/* +!/desktop/build/appicon.png +!/desktop/build/appicon.svg +/desktop/desktop +/desktop/frontend/dist/* +!/desktop/frontend/dist/.gitkeep +/desktop/frontend/sourcemaps/ +/desktop/frontend/wailsjs/ +/desktop/frontend/package.json.md5 + +# Environment / local config (reasonix.example.toml is the shared template) +.env +.env.local +*.local +/reasonix.toml +/desktop/.env +/desktop/reasonix.toml + +# Node / frontend dependencies and caches +node_modules/ +.pnpm-store/ +.npm/ + +# Project-local Reasonix state; keep the checked-in review command. +/.reasonix/* +!/.reasonix/commands/ +!/.reasonix/commands/review.md + +# CodeGraph per-project index (rebuilt locally; never committed) +.codegraph/ + +# Editor / OS +.DS_Store +Thumbs.db +.idea/ +.vscode/ +/site/public/av/ + +benchmarks/context-maintenance-e2e/run/ + +# Local scratch +temp/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..fcfdb9a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,48 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + settings: + errcheck: + check-type-assertions: false + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - (*net/http.Response).Body.Close + - golang.org/x/term.Restore + staticcheck: + checks: + - all + - -ST1005 # error strings ending with punctuation — too strict for initial adoption + - -ST1018 # Unicode format characters — intentional in TUI code + - -QF1001 # De Morgan's law — readability preference + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + # Setup/teardown in tests routinely ignores cleanup errors. + - path: _test\.go + linters: + - errcheck + # The pinned golangci-lint binary's staticcheck reports SA5011 false + # positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it + # doesn't treat t.Fatal as terminating); the same code is clean under a + # locally-built golangci-lint. Scope the suppression to tests so SA5011 + # still guards production code. + - path: _test\.go + linters: + - staticcheck + text: SA5011 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..9e0fae1 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,62 @@ +version: 2 +project_name: reasonix + +before: + hooks: + - go mod download + +builds: + - id: reasonix + main: ./cmd/reasonix + binary: reasonix + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Tag }} + goos: [darwin, linux, windows] + goarch: [amd64, arm64] + +archives: + - id: reasonix + ids: [reasonix] + name_template: "reasonix-{{ .Os }}-{{ .Arch }}" + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: SHA256SUMS + algorithm: sha256 + +homebrew_casks: + - name: reasonix + ids: [reasonix] + # Prereleases (v*-rc*) must not update the stable tap; brew has no separate + # prerelease channel, so a pushed rc cask becomes the default `brew install`. + # npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no + # longer drags brew along — a stable `v*` cask can ship while npm stays on rc. + skip_upload: "auto" + repository: + owner: esengine + name: homebrew-reasonix + branch: main + token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" + homepage: "https://github.com/esengine/DeepSeek-Reasonix" + description: "Cache-first DeepSeek coding agent for the terminal." + commit_author: + name: reasonix + email: reasonix@deepseek.com + hooks: + post: + install: | + if OS.mac? + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"] + end + +release: + prerelease: auto + make_latest: false + name_template: "Reasonix CLI {{ .Tag }}" diff --git a/.reasonix/commands/review.md b/.reasonix/commands/review.md new file mode 100644 index 0000000..1de8a82 --- /dev/null +++ b/.reasonix/commands/review.md @@ -0,0 +1,5 @@ +--- +description: Review a file for bugs +argument-hint: [path] +--- +Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS. diff --git a/.signpath/artifact-configurations/windows-installer.xml b/.signpath/artifact-configurations/windows-installer.xml new file mode 100644 index 0000000..8dc41ff --- /dev/null +++ b/.signpath/artifact-configurations/windows-installer.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c7076cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy +`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) +branch. + +## Unreleased + +### Changed + +- Agent runtime defaults now leave both executor and dedicated planner tool-call + rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now + come from the user/global config only; project `reasonix.toml` does not + override them. + +## [1.0.0] — 2026-06-03 + +First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x` +TypeScript line; a new codebase that becomes the default (`main-v2`). + +### Highlights + +- **Go kernel**: a single static binary (CGO-free), cross-compiled for + darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps + the native binary), Homebrew (`esengine/reasonix` tap), and release archives; + no Node runtime needed to run it. +- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/ + ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the + DeepSeek prefix-cache–oriented design. +- **Subagents**: `task` plus explore/research/review/security_review skill agents. +- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks + (`internal/hook`), symlink-aware and slash-integrated. +- **MCP client**: connect external servers over stdio / Streamable HTTP; reads + `[[plugins]]` and a Claude-Code `.mcp.json`. +- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph + (`codegraph_*` tools) replaces embedding semantic search — no embedding service + or API cost. Fetched into a local cache on first use (or `reasonix codegraph + install`) and indexed in the background, so installs and startup stay fast. +- **Plan mode** with evidence-backed step sign-off (`complete_step`). +- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable + prefix. +- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails). + +### Fixed + +- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files + can now be read, edited, and grepped correctly. The v2 rewrite had dropped + v1's encoding detection; files in CJK Windows charsets were silently misread + or rejected as binary. The read/edit/write round-trip now preserves the + original file encoding. (#2637) + +### Notes + +- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at + `1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md). +- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows + support for the fetched runtime is unverified — install `codegraph` on PATH if + the auto-fetch doesn't resolve there. + +[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4c0cf82 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,190 @@ +# Contributing to Reasonix + +Thank you for your interest in contributing to Reasonix! This guide covers +everything you need to get started. + +## Prerequisites + +- **Go 1.25+** — the project targets the latest stable Go release +- **Git** — for version control +- **Node.js** (optional) — only if you work on the desktop app (`desktop/`) + +## Getting started + +```bash +git clone https://github.com/esengine/DeepSeek-Reasonix.git +cd DeepSeek-Reasonix +go build ./cmd/reasonix # builds the CLI binary +go test ./... # runs the full test suite +``` + +## Project structure + +| Directory | Purpose | +|-----------|---------| +| `cmd/reasonix` | CLI entry point | +| `internal/agent` | Agent loop, session, coordinator | +| `internal/cli` | TUI, subcommands, setup wizard | +| `internal/control` | Transport-agnostic controller | +| `internal/config` | TOML configuration loading | +| `internal/tool/builtin` | Built-in tools (bash, read_file, …) | +| `internal/provider` | Model-backend abstraction | +| `internal/provider/openai` | OpenAI-compatible provider | +| `internal/plugin` | MCP client (stdio + HTTP) | +| `internal/event` | Typed event stream | +| `internal/hook` | Shell hooks (PreToolUse, …) | +| `internal/memory` | REASONIX.md hierarchy + auto-memory | +| `internal/skill` | Skill discovery from Markdown | +| `internal/sandbox` | OS-level sandboxing | +| `internal/serve` | HTTP/SSE server frontend | +| `internal/checkpoint` | Snapshot-based rewind | +| `desktop/` | Wails-based desktop app (separate Go module) | +| `docs/` | Engineering spec, migration guide | + +### Dependency direction + +``` +cli → {agent, plugin, config} → {tool, provider} +``` + +Built-in subpackages import their parent to self-register via `init()`. +Parents never import children. + +## Development workflow + +### Building + +```bash +make build # go build ./... +make test # go test ./... +make vet # go vet ./... +make fmt # gofmt -w . +make hooks # install git hooks (pre-push: go vet) +make cross # cross-compile for all 6 targets +``` + +### Isolated development environment + +A source-built binary shares no on-disk state with a stable release when launched +with `REASONIX_HOME` set. This gives each build its own self-contained directory +tree — config, credentials, sessions, cache, skills, commands, hooks, and +desktop tab state — so the two builds never interfere: + +**CLI** + +```bash +REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix +# or after building: +# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix +``` + +**Desktop** + +```bash +cd desktop && wails build +REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop +``` + +On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in +Command Prompt; the binary extension is `.exe`. + +The directory is empty on first launch; the app behaves exactly like a fresh +install. Every subsequent write — config saves, credential storage, session +logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention +directory scanning, and all other fallback paths are skipped so no production +data leaks in or out. + +### Cache-first review gate + +Reasonix treats high prompt-cache hit rate as product behavior. Changes that +touch provider-visible system prompt construction, memory prefix, output styles, +skill index behavior, default tool surfaces, tool schemas, provider request +serialization, compaction, or MCP/tool registration need explicit cache review. + +For these changes: + +- Keep system prompt changes low-frequency and require explicit review. +- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high` + plus the reason. +- Fill the PR body `Cache-guard:` line with the focused guard test/command added + or run, or explain why an existing guard covers the change. +- Fill `System-prompt-review:` when system prompt, memory prefix, output style, + or skill index behavior changes. +- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh` + remains the broader release-level cache-hit check. + +CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn +is called out before review. + +### Running tests + +```bash +go test ./... # all tests +go test ./internal/agent/ -v # verbose, one package +go test ./internal/tool/builtin/ -run TestGrep # one test +``` + +### Code style + +- `gofmt` is enforced by CI — format before committing +- Follow existing patterns: wrap errors with `fmt.Errorf("...: %w", err)` +- Library code never calls `os.Exit` or prints to stdout/stderr +- Only `cli/` and `main/` decide exit codes and user-facing messages +- Exported identifiers must have doc comments + +### Commit messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(glob): add ** recursive pattern support +fix: replace silent error discards with structured logging +test(event): add comprehensive unit tests for event package +docs: add CONTRIBUTING.md +ci: add golangci-lint and govulncheck +``` + +## Adding a new built-in tool + +1. Create `internal/tool/builtin/mytool.go` +2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()` +3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }` +4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go` +5. The tool is automatically available — `main` blank-imports `builtin` + +## Adding a new model provider + +(For MCP tool servers see `internal/plugin` instead — that's a different layer.) + +1. Create `internal/provider/myprovider/` +2. Implement `provider.Provider`: `Name()`, `Stream()` +3. Register via `func init() { provider.Register("mykind", New) }` +4. The provider is available from config with `kind = "mykind"` + +## Adding i18n strings + +1. Add the field to `internal/i18n/i18n.go` (`Messages` struct) +2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go` +3. The `TestCatalogsComplete` test will fail if you miss a locale + +## Submitting changes + +1. Fork the repository +2. Create a feature branch from `main-v2` +3. Make your changes with tests +4. Ensure `go test ./...` passes +5. Ensure `gofmt -l .` shows no changes +6. Submit a pull request to `main-v2` + +## Reporting issues + +Open an issue on GitHub with: +- Steps to reproduce +- Expected vs actual behavior +- Go version and OS +- Relevant logs or error messages + +## License + +By contributing, you agree that your contributions will be licensed under the +same license as the project. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc45a28 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Reasonix Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..81ac501 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) +GOEXE := $(shell go env GOEXE) + +.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean + +build: + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix + CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example$(GOEXE) ./cmd/reasonix-plugin-example + +vet: + go vet ./... + +fmt: + gofmt -w . + +test: + go test ./... + +desktop-test: + cd desktop && go test . + +desktop-test-short: + cd desktop && go test -short . + +desktop-test-times: + cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py + +hooks: + @git config core.hooksPath .githooks + @echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)" + +cross: + @mkdir -p dist + @for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \ + os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \ + echo "build $$os/$$arch"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \ + done + +clean: + rm -rf bin dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..e36bffc --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +

+ Reasonix +

+ +

+ English +  ·  + 简体中文 +  ·  + Guide +  ·  + Spec +  ·  + Website +  ·  + Discord +

+ +> [!IMPORTANT] +> **Reasonix 1.0 is a ground-up rewrite in Go** — this branch (`main-v2`) is the new default and where development happens now. +> The earlier `0.x` TypeScript releases are **legacy**, living on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) branch (maintenance only). +> See the **[migration guide](./docs/MIGRATING.md)**. `npm i -g reasonix` stays the install command — `1.0.0`+ delivers the Go binary, `0.x` is the legacy TS build. + +

+ npm version + CI + license + downloads + GitHub stars + AtomGit stars + contributors + Discussions + Discord +

+ +
+ +

A DeepSeek-native AI coding agent for your terminal.

+

A config- and plugin-driven harness — a single static Go binary, tuned around DeepSeek's prefix cache so token costs stay low across long sessions.

+ +
+ +> [!IMPORTANT] +> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **** + +
+ +## Features + +- **Config-driven.** Providers, the agent, enabled tools, and plugins are all + declared in `reasonix.toml`. No hardcoded models. +- **Multi-model & composable.** DeepSeek ships as a preset; any + OpenAI-compatible endpoint is a config entry, not new code. Optionally run + two models together (executor + planner) in separate, cache-stable sessions. +- **Plugin-driven.** External tools run as subprocesses over stdio JSON-RPC + (MCP-compatible). Built-in tools self-register at compile time. +- **Cache-aware context maintenance.** Startup injects a small stable environment + summary, stale tool output is snipped/pruned before summary compaction, and the + built-in tool schema contract is documented for regression review. +- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile + to six targets with one command. The only dependency is a TOML parser. + +## Install + +```sh +npm i -g reasonix # any OS; pulls the prebuilt native binary +brew install esengine/reasonix/reasonix # macOS +``` + +Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on +every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases). + +### Code signing + +Windows builds are code-signed with a free certificate provided by the +[SignPath Foundation](https://signpath.org/), with signing through +[SignPath.io](https://signpath.io/). + +### Build from source + +```sh +make build # -> bin/reasonix(.exe) +make cross # -> dist/ (darwin|linux|windows × amd64|arm64) +``` + +## Quick start + +```sh +reasonix setup # config wizard → ./reasonix.toml +export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env +reasonix # then run /init to generate AGENTS.md (project memory) +reasonix run "implement the TODOs in main.go" +reasonix run --model deepseek-pro "add unit tests for this function" +echo "explain this code" | reasonix run +``` + +## Configuration + +A minimal `reasonix.toml` — one provider and a default model — is enough to start: + +```toml +default_model = "deepseek-flash" + +[[providers]] +name = "deepseek-flash" +kind = "openai" +base_url = "https://api.deepseek.com" +model = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" +``` + +Resolution order is **flag > `./reasonix.toml` > the user config file > +built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at +`~/.reasonix/config.toml` on macOS/Linux and +`%AppData%\reasonix\config.toml` on Windows. See +**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the +full `config.toml` / `.env` structure. Provider entries name secrets with +`api_key_env`; the secret values themselves live in Reasonix's global +`/.env`, shared by CLI and desktop. Project `.env` files are not +provider-key runtime fallbacks, but still feed workspace-scoped, non-provider +`${VAR}` expansion for MCP/plugin settings without importing Reasonix control +variables. Permissions, the sandbox, plugins (MCP), slash +commands, `@` references, and two-model setup are all in the +**[Guide](./docs/GUIDE.md)**. + +## Documentation + +- **[Guide](./docs/GUIDE.md)** — configuration, permissions & sandbox, plugins + (MCP), slash commands, `@` references, two-model collaboration. +- **[Subagent profiles](./docs/SUBAGENT_PROFILES.md)** — create, share, preview, + run, edit, and safely delete isolated agent profiles from desktop or CLI. +- **[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md)** — + `reasonix doctor capabilities`, desktop Settings → Diagnostics, and the + `/reasonix-guide` skill for skills/hooks/MCP/plugin troubleshooting. +- **[Bot guide](./docs/BOT_GUIDE.md)** — connect Feishu, Lark, and WeChat bots + from the desktop app, then use approvals, YOLO, and commands from IM. +- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries, + data types, and roadmap. +- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure + complex requests with context, output boundaries, constraints, and when to ask. +- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool + names, read-only flags, and schema snapshot guard. +- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy + TypeScript releases to the 1.0 Go rewrite. +- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit + safety net (Esc-Esc / `/rewind`). + +
+ +## Star History + + + + + + Star History Chart + + + +
+ +## Support + +If Reasonix has been useful and you'd like to say thanks, you can. It stays a coffee, not a contract — donations don't buy feature priority or change how issues get triaged. + +- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui) +- **国内** — 微信支付(扫码) + +

+ WeChat Pay QR code +

+ +
+ +## Acknowledgments + +A small list of folks whose work has shaped Reasonix the most — the current top +20 contributors by commit count. The full contributor graph is on +[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1). + + +| Contributor | Contributor | Contributor | Contributor | +| --- | --- | --- | --- | +| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) | +| **reasonix** (anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) | +| **merge-order-check** (anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** (anonymous) | +| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) | +| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) | + + +Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123) +for designing the project logo, and to +[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu. + +

+ + Contributors to esengine/DeepSeek-Reasonix + +

+ +
+ +--- + +

+ MIT — see LICENSE +
+ Built by the community at esengine/DeepSeek-Reasonix +

diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..9a9d8bd --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`esengine/DeepSeek-Reasonix` +- 原始仓库:https://github.com/esengine/DeepSeek-Reasonix +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..585a648 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,195 @@ +

+ Reasonix +

+ +

+ English +  ·  + 简体中文 +  ·  + 指南 +  ·  + 规格 +  ·  + 官方网站 +  ·  + Discord +

+ +> [!IMPORTANT] +> **Reasonix 1.0 是用 Go 从零重写的版本** —— 本分支(`main-v2`)已是新的默认分支,后续开发都在这里。 +> 早期的 `0.x` TypeScript 版本转为 **legacy**,保留在 [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1) 分支(仅维护)。 +> 详见**[迁移指南](./docs/MIGRATING.md)**。`npm i -g reasonix` 仍是安装命令——`1.0.0`+ 装的是 Go 二进制,`0.x` 是 legacy TS 版。 + +

+ npm version + CI + license + downloads + GitHub stars + AtomGit stars + contributors + Discussions + Discord +

+ +
+ +

面向终端的 DeepSeek 原生 AI coding agent。

+

由配置与插件驱动的极薄 harness——单一静态 Go 二进制,围绕 DeepSeek 的前缀缓存调优,长会话也能把 token 成本压低。

+ +
+ +> [!IMPORTANT] +> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **** + +## 特性 + +- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明, + 内核无硬编码模型。 +- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容 + 端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。 +- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容); + 内置工具在编译期自注册。 +- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune, + 再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。 +- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。 + 唯一依赖是一个 TOML 解析库。 + +## 安装 + +```sh +npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制 +brew install esengine/reasonix/reasonix # macOS +``` + +预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个 +[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。 + +### 代码签名 + +Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书, +通过 [SignPath.io](https://signpath.io/) 完成签名。 + +### 从源码构建 + +```sh +make build # -> bin/reasonix(.exe) +make cross # -> dist/(darwin|linux|windows × amd64|arm64) +``` + +## 快速开始 + +```sh +reasonix setup # 配置向导 → ./reasonix.toml +export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env +reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆) +reasonix run "把 main.go 里的 TODO 实现掉" +reasonix run --model deepseek-pro "给这个函数补单元测试" +echo "解释这段代码" | reasonix run +``` + +## 配置 + +一个最小的 `reasonix.toml`——一个 provider 加一个默认模型——就够跑起来: + +```toml +default_model = "deepseek-flash" + +[[providers]] +name = "deepseek-flash" +kind = "openai" +base_url = "https://api.deepseek.com" +model = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" +``` + +优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从 +**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`, +Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见 +**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml` +和 `.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在 +CLI 与桌面端共用的 Reasonix 全局 `/.env`;项目 `.env` 不再作为 +provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、 +斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。 + +## 文档 + +- **[指南](./docs/GUIDE.zh-CN.md)** —— 配置、权限与沙盒、插件(MCP)、斜杠命令、 + `@` 引用、双模型协同。 +- **[子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md)** —— 在桌面端或 CLI + 创建、共享、预览、运行、编辑和安全删除隔离智能体 Profile。 +- **[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md)** —— + `reasonix doctor capabilities`、桌面端 **设置 → 诊断**,以及内置 Skill + `/reasonix-guide`,用于 skills / hooks / MCP / 插件排障。 +- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信 + Bot,以及 IM 里的审批、YOLO 和命令交互。 +- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。 +- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。 +- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、 + read-only 标记和 schema 快照保护。 +- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。 +- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网 + (Esc-Esc / `/rewind`)。 + +
+ +## Star 趋势 + + + + + + Star History Chart + + + +
+ +## 支持本项目 + +如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature 优先级,也不会影响 issue 的处理顺序——就是「谢谢」。 + +- **国内** — 微信支付(扫下方二维码) +- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui) + +

+ 微信支付收款码 +

+ +
+ +## 致谢 + +下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。 +完整贡献者列表在 +[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1)。 + + +| Contributor | Contributor | Contributor | Contributor | +| --- | --- | --- | --- | +| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) | +| **reasonix**(anonymous) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) | +| **merge-order-check**(anonymous) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan**(anonymous) | +| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) | +| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) | + + +另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo, +以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。 + +

+ + esengine/DeepSeek-Reasonix 贡献者 + +

+ +
+ +--- + +

+ MIT —— 见 LICENSE +
+ esengine/DeepSeek-Reasonix 社区共建 +

diff --git a/REASONIX.md b/REASONIX.md new file mode 100644 index 0000000..abb7797 --- /dev/null +++ b/REASONIX.md @@ -0,0 +1,73 @@ +# Reasonix project memory + +This file is loaded into every session's system prompt (the cache-stable prefix), +so keep it concise and durable — it is the project's standing instructions to the +agent. It is the Reasonix analog of Claude Code's CLAUDE.md. + +## Conventions + +- Go kernel under `internal/`; each package owns one concern and documents it in a + package comment. Match the surrounding comment density and idiom when editing. +- One transport-agnostic `control.Controller` sits behind every frontend (chat + TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a + frontend, so all three inherit it. +- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay + byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never + mutate it mid-session — ride the turn tail instead (see `control.Compose`). + +## Memory + +- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md` + (personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any + `REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name. +- `@path` on its own line imports another file's contents. +- `#` in chat quick-adds a line here. The `remember` tool saves durable + facts to the per-project auto-memory store (frontmatter files + `MEMORY.md` + index), which loads into the prefix on the next session. + +## Notes + +## Pre-push CI simulation + +Run these **before every commit** to catch the fastest CI failures locally: + +```bash +gofmt -w . # catches gofmt (saves ~13s CI) +go vet ./... # catches vet warnings (saves ~52s CI/lint) +go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks +``` + +CI runs `golangci-lint` (not locally available), but gofmt + vet already block ~80% of fast-fail scenarios. + +## Import cycle rule + +Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you: + +``` +# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed +``` + +Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists. + +## PR hygiene + +- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers. +- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches. +- **Amend, don't add commits, for review feedback** — keeps the commit history clean. + +## Cache-impact PR metadata + +When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end: + +``` +Cache-impact: +Cache-guard: +``` + +If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add: + +``` +System-prompt-review: +``` + +Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4b9ecac --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,137 @@ +# Security Policy + +## Supported Versions + +Reasonix security fixes are prioritized for the currently developed Go rewrite +and the current 1.x release line. + +| Version or branch | Security support | +| --- | --- | +| `main-v2` / 1.x releases | Supported | +| `v1` / 0.x legacy branch | Critical fixes only, where practical | +| Older releases, forks, or modified builds | Not covered unless the issue is reproducible upstream | + +If you are unsure whether a version is affected, report against the newest +released 1.x version and include the exact version or commit you tested. + +## Reporting a Vulnerability + +Please report security issues privately. Do not open a public issue with exploit +details, secrets, crash dumps, or proof-of-concept payloads. + +Preferred reporting path: + +1. Use GitHub private vulnerability reporting for this repository, if available. +2. If private reporting is not available to you, open a minimal public issue + asking for a private maintainer contact path. Do not include exploit details + in that issue. + +Please include: + +- Affected Reasonix version, commit, operating system, and installation method. +- The feature or surface involved, such as CLI, desktop app, HTTP `serve`, bot + gateway, MCP plugin, built-in tool, updater, or configuration loading. +- Clear reproduction steps using dummy credentials and non-sensitive files. +- The expected impact, such as secret disclosure, arbitrary file access, + command execution, sandbox escape, authentication bypass, or supply-chain risk. +- Any relevant logs with API keys, tokens, local paths, and personal data + redacted. + +Do not send real provider API keys, bot credentials, OAuth tokens, private +workspace files, or third-party user data. + +## Security Boundaries + +Reasonix is a local coding agent. Many features intentionally operate on the +user's local machine and workspace, including file reads, file writes, shell +commands, MCP plugins, language servers, bot sessions, and model-provider +requests. A finding is security-relevant when it crosses a supported boundary or +bypasses an explicit guard. + +Supported boundaries include: + +- Workspace confinement for file operations that are documented or implemented + as workspace-scoped. +- Permission checks for tool calls, shell commands, file writes, and approvals. +- Sandbox behavior for built-in shell execution where the platform supports it. +- Secret handling for provider keys, bot credentials, OAuth tokens, plugin + headers, and credential-store fallback files. +- HTTP `serve` protections for the unauthenticated local server, including + localhost binding assumptions, JSON-only state-changing requests, and CORS + restrictions. +- Desktop and bot session isolation, including per-workspace session metadata + and configured bot allowlists. +- Updater, install, and release verification paths. + +The following are normally treated as trusted local/operator-controlled inputs +unless another bug lets an untrusted actor supply them: + +- CLI arguments and text typed directly by the local user. +- Project configuration files intentionally loaded from the current workspace. +- Explicit `@path` references supplied by the local user to attach local files. +- MCP servers, language servers, hooks, and slash commands installed or enabled + by the local user. +- Provider base URLs and model names configured by the local user. + +The following can be security issues when reachable by an untrusted actor or +when they bypass the intended boundary: + +- Reading or writing files outside the configured workspace without explicit + local-user intent. +- Following symlinks or path traversal to escape workspace confinement. +- Running shell commands or external tools without the required permission gate. +- Leaking credentials, environment variables, prompt history, local files, or bot + messages to logs, model providers, MCP servers, crash reports, or telemetry. +- Allowing a website to drive the local HTTP server through CSRF, CORS, or + content-type bypasses. +- Letting a bot user outside the configured allowlist submit prompts, approve + tools, or access a project workspace. +- Trusting unverified update artifacts, plugin definitions, or downloaded + binaries. + +## `@` File References + +Reasonix supports `@path` references so users can include local files and images +in a prompt. This is intentional local functionality, but implementations must +preserve these invariants: + +- In workspace-scoped sessions, relative and absolute paths must resolve under + the active workspace root before file content is read or attached. +- Path traversal such as `..` must not escape the workspace root. +- Symlinks must not be usable to bypass the intended workspace boundary. +- Unscoped local CLI compatibility must not be exposed to remote, bot, or + browser-controlled inputs unless an equivalent workspace boundary is applied. +- File content should be size-limited and binary content should not be dumped as + prompt text. + +Static analysis alerts about path expressions should be triaged against these +rules: user-controlled path data is expected, but the access must either stay +inside the configured workspace or be clearly limited to trusted local CLI use. + +## Out of Scope + +The following reports are usually out of scope unless they demonstrate a bypass +of one of the boundaries above: + +- A local user intentionally asks Reasonix to read, edit, or send their own + files to a configured model provider. +- A local user installs or enables a malicious MCP server, hook, slash command, + language server, or shell command and then grants it permission. +- A configured model provider, proxy, or MCP server receives data the user + intentionally sent to it. +- Denial-of-service issues that only affect the local user's own session and do + not corrupt files, leak secrets, or bypass permissions. +- Issues requiring administrator/root access on the user's machine before + interacting with Reasonix. +- Vulnerabilities in third-party services, models, proxies, or plugins that are + not caused by Reasonix behavior. + +## Coordinated Disclosure + +This is a community-maintained project. Maintainers will make a best-effort +assessment, ask follow-up questions when needed, and coordinate fixes before +public disclosure for confirmed vulnerabilities. + +Please give maintainers reasonable time to investigate and release a fix before +publishing exploit details. If you plan to disclose on a timeline, include that +timeline in your initial report. diff --git a/benchmarks/context-maintenance-e2e/main.go b/benchmarks/context-maintenance-e2e/main.go new file mode 100644 index 0000000..fda902a --- /dev/null +++ b/benchmarks/context-maintenance-e2e/main.go @@ -0,0 +1,255 @@ +// Drives the context-maintenance E2E scenarios against the real DeepSeek API: +// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "reasonix/internal/agent" + "reasonix/internal/event" + "reasonix/internal/provider" + _ "reasonix/internal/provider/openai" + "reasonix/internal/tool" + "reasonix/internal/tool/builtin" +) + +const ( + model = "deepseek-v4-flash" + baseURL = "https://api.deepseek.com" + fatResults = 20 + fatBytes = 12_000 +) + +func prov() provider.Provider { + key := os.Getenv("DEEPSEEK_API_KEY") + if key == "" { + fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set") + os.Exit(1) + } + p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return p +} + +func fakeGoFile(nonce string, i, size int) string { + var b strings.Builder + fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i) + line := 0 + for b.Len() < size { + fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7) + line++ + } + return b.String() +} + +func seedSession(nonce string) *agent.Session { + s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.") + s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."}) + for i := 0; i < fatResults; i++ { + id := fmt.Sprintf("c%02d", i) + name := fmt.Sprintf("src/file%02d.go", i) + s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}}) + s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)}) + s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)}) + } + return s +} + +// oneShot appends a user message and runs a single completion, returning usage. +func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) { + req := provider.Request{ + Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}), + MaxTokens: 32, + } + ch, err := p.Stream(context.Background(), req) + if err != nil { + return provider.Usage{}, err + } + var u provider.Usage + for c := range ch { + switch c.Type { + case provider.ChunkUsage: + u = *c.Usage + case provider.ChunkError: + return u, c.Err + } + } + return u, nil +} + +type meta struct { + SeededAt time.Time `json:"seeded_at"` + Nonces map[string]string `json:"nonces"` + SeedUse map[string]provider.Usage `json:"seed_usage"` +} + +func seed(dir string) { + p := prov() + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}} + for _, arm := range []string{"pruned", "control"} { + nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000) + s := seedSession(nonce) + u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.") + if err != nil { + fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err) + os.Exit(1) + } + if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m.Nonces[arm] = nonce + m.SeedUse[arm] = u + fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens) + } + b, _ := json.MarshalIndent(m, "", " ") + if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func resume(dir string) { + p := prov() + raw, err := os.ReadFile(filepath.Join(dir, "meta.json")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + var m meta + if err := json.Unmarshal(raw, &m); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + idle := time.Since(m.SeededAt).Round(time.Minute) + + out := map[string]provider.Usage{} + for _, arm := range []string{"pruned", "control"} { + s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + prunes := 0 + if arm == "pruned" { + a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard) + st, err := a.PruneStaleToolResults() + if err != nil { + fmt.Fprintln(os.Stderr, "prune:", err) + os.Exit(1) + } + prunes = st.Results + } + u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.") + if err != nil { + fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err) + os.Exit(1) + } + out[arm] = u + fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens) + } + b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ") + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + c, pr := out["control"], out["pruned"] + if c.CacheMissTokens > 0 { + fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n", + c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens))) + } +} + +// comprehension checks that the agent re-reads a file behind a prune placeholder +// instead of hallucinating: the answer is a number that exists only in the file. +func comprehension(trials int) { + p := prov() + pass := 0 + for t := 0; t < trials; t++ { + dir, err := os.MkdirTemp("", "cm-e2e-") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000) + content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400) + if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.") + s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."}) + s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}}) + s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content}) + s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."}) + for i := 0; i < 4; i++ { + s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)}) + s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) + } + + reg := tool.NewRegistry() + for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") { + reg.Add(tl) + } + a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard) + st, err := a.PruneStaleToolResults() + if err != nil || st.Results == 0 { + fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err) + os.Exit(1) + } + + err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.") + reRead, answered := false, false + for _, msg := range s.Snapshot() { + if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) { + reRead = true + } + if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) { + answered = true + } + } + ok := err == nil && reRead && answered + if ok { + pass++ + } + fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err) + os.RemoveAll(dir) + } + fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials) + if pass < trials { + os.Exit(1) + } +} + +func main() { + dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume") + trials := flag.Int("trials", 5, "comprehension trials") + flag.Parse() + switch flag.Arg(0) { + case "seed": + seed(*dir) + case "resume": + resume(*dir) + case "comprehension": + comprehension(*trials) + default: + fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]") + os.Exit(1) + } +} diff --git a/benchmarks/e2e/tasks/compaction/task.toml b/benchmarks/e2e/tasks/compaction/task.toml new file mode 100644 index 0000000..ff6b91c --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/task.toml @@ -0,0 +1,3 @@ +prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else." +max_steps = 40 +timeout_sec = 900 diff --git a/benchmarks/e2e/tasks/compaction/verify.sh b/benchmarks/e2e/tasks/compaction/verify.sh new file mode 100644 index 0000000..c9ec1b6 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/verify.sh @@ -0,0 +1,4 @@ +set -e +got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]') +want="aldermoor-verrin" +[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; } diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md new file mode 100644 index 0000000..4070cdd --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-1.md @@ -0,0 +1,220 @@ +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +To pick up the trail again, the next page you must read is chapter-4.md. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md new file mode 100644 index 0000000..0cfbb43 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-2.md @@ -0,0 +1,220 @@ +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +To pick up the trail again, the next page you must read is chapter-5.md. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md new file mode 100644 index 0000000..46d895b --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-3.md @@ -0,0 +1,220 @@ +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +To pick up the trail again, the next page you must read is chapter-6.md. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md new file mode 100644 index 0000000..61f84af --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-4.md @@ -0,0 +1,220 @@ +Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +To pick up the trail again, the next page you must read is chapter-2.md. +Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md new file mode 100644 index 0000000..24f6210 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-5.md @@ -0,0 +1,220 @@ +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +To pick up the trail again, the next page you must read is chapter-3.md. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. diff --git a/benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md new file mode 100644 index 0000000..828e100 --- /dev/null +++ b/benchmarks/e2e/tasks/compaction/workdir/story/chapter-6.md @@ -0,0 +1,220 @@ +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. +Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end. +The road wound on through the grey hills, and the company spoke little of what lay behind them. +Rain had come in the night, so the morning was washed and bright, and the carts ran easily. +Old stories were traded at the fire, half-remembered and half-invented, as such stories are. +A merchant counted his coin twice and still mistrusted the sum, for the season had been lean. +Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun. diff --git a/benchmarks/e2e/tasks/fix-add-bug/task.toml b/benchmarks/e2e/tasks/fix-add-bug/task.toml new file mode 100644 index 0000000..fe3456b --- /dev/null +++ b/benchmarks/e2e/tasks/fix-add-bug/task.toml @@ -0,0 +1,3 @@ +prompt = "The file calc.py has a bug: add(a, b) returns the wrong result. Fix add so it returns the sum of a and b. Keep the other functions unchanged." +max_steps = 12 +timeout_sec = 180 diff --git a/benchmarks/e2e/tasks/fix-add-bug/verify.sh b/benchmarks/e2e/tasks/fix-add-bug/verify.sh new file mode 100644 index 0000000..4bb24c0 --- /dev/null +++ b/benchmarks/e2e/tasks/fix-add-bug/verify.sh @@ -0,0 +1,7 @@ +set -e +python3 - <<'PY' +import calc +assert calc.add(2, 3) == 5, calc.add(2, 3) +assert calc.add(10, 5) == 15, calc.add(10, 5) +assert calc.mul(2, 3) == 6, calc.mul(2, 3) +PY diff --git a/benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py b/benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py new file mode 100644 index 0000000..f905fb9 --- /dev/null +++ b/benchmarks/e2e/tasks/fix-add-bug/workdir/calc.py @@ -0,0 +1,6 @@ +def add(a, b): + return a - b + + +def mul(a, b): + return a * b diff --git a/benchmarks/e2e/tasks/fizzbuzz/task.toml b/benchmarks/e2e/tasks/fizzbuzz/task.toml new file mode 100644 index 0000000..1663242 --- /dev/null +++ b/benchmarks/e2e/tasks/fizzbuzz/task.toml @@ -0,0 +1,3 @@ +prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time." +max_steps = 12 +timeout_sec = 180 diff --git a/benchmarks/e2e/tasks/fizzbuzz/verify.sh b/benchmarks/e2e/tasks/fizzbuzz/verify.sh new file mode 100644 index 0000000..71952f4 --- /dev/null +++ b/benchmarks/e2e/tasks/fizzbuzz/verify.sh @@ -0,0 +1,8 @@ +set -e +python3 - <<'PY' +import fizzbuzz +assert fizzbuzz.fizzbuzz(3) == "Fizz", fizzbuzz.fizzbuzz(3) +assert fizzbuzz.fizzbuzz(5) == "Buzz", fizzbuzz.fizzbuzz(5) +assert fizzbuzz.fizzbuzz(15) == "FizzBuzz", fizzbuzz.fizzbuzz(15) +assert fizzbuzz.fizzbuzz(7) == "7", fizzbuzz.fizzbuzz(7) +PY diff --git a/benchmarks/e2e/tasks/palindrome/task.toml b/benchmarks/e2e/tasks/palindrome/task.toml new file mode 100644 index 0000000..a9d7c81 --- /dev/null +++ b/benchmarks/e2e/tasks/palindrome/task.toml @@ -0,0 +1,3 @@ +prompt = "Create a file named palindrome.py with a function is_palindrome(s) that returns True if s reads the same forwards and backwards ignoring case, spaces, and punctuation, and False otherwise. Do not print anything at import time." +max_steps = 12 +timeout_sec = 180 diff --git a/benchmarks/e2e/tasks/palindrome/verify.sh b/benchmarks/e2e/tasks/palindrome/verify.sh new file mode 100644 index 0000000..e4b9ebc --- /dev/null +++ b/benchmarks/e2e/tasks/palindrome/verify.sh @@ -0,0 +1,7 @@ +set -e +python3 - <<'PY' +import palindrome +assert palindrome.is_palindrome("Race car") is True +assert palindrome.is_palindrome("A man, a plan, a canal: Panama") is True +assert palindrome.is_palindrome("hello") is False +PY diff --git a/benchmarks/e2e/tasks/subagent-delegation/task.toml b/benchmarks/e2e/tasks/subagent-delegation/task.toml new file mode 100644 index 0000000..509fa48 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/task.toml @@ -0,0 +1,3 @@ +prompt = "This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory." +max_steps = 15 +timeout_sec = 300 diff --git a/benchmarks/e2e/tasks/subagent-delegation/verify.sh b/benchmarks/e2e/tasks/subagent-delegation/verify.sh new file mode 100644 index 0000000..9bedcc3 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/verify.sh @@ -0,0 +1,12 @@ +set -e +# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run` +# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be +# produced by actually reading the three seed files (the prompt mandates doing +# that via the `task` tool). Before sub-agents could run without a parent +# session, the `task` call errored here with "parent session is required". +test -f result.txt +got=$(tr -d '[:space:]' < result.txt) +if [ "$got" != "86" ]; then + echo "result.txt = '$got', want 86" + exit 1 +fi diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt new file mode 100644 index 0000000..0d93535 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt @@ -0,0 +1 @@ +alpha=17 diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt new file mode 100644 index 0000000..53aae3d --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt @@ -0,0 +1 @@ +beta=28 diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt new file mode 100644 index 0000000..c993911 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt @@ -0,0 +1 @@ +gamma=41 diff --git a/cmd/e2ebench/diff.go b/cmd/e2ebench/diff.go new file mode 100644 index 0000000..170e2a7 --- /dev/null +++ b/cmd/e2ebench/diff.go @@ -0,0 +1,658 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "reasonix/internal/shellparse" +) + +type diffOpts struct { + bin, model, repo, base, testCmd, profile string + maxSteps, timeoutSec, attempts int +} + +type testRef struct{ name, pkg string } + +// pinResult records whether one generated test fails when the PR's source is +// reverted (so it pins the change) and, if so, whether it failed by assertion +// (strong: it checks the new behavior) or only by compile error (weak: it just +// references a symbol the PR added). +type pinResult struct { + testRef + pins bool + byAssertion bool +} + +// runDiff asks the agent to write tests covering what the PR changed, grades +// them against the repo's own tests, and — because the agent is stochastic — +// retries up to o.attempts times until a run passes, keeping the best result. +func runDiff(o diffOpts) string { + srcFiles := changedGoFiles(o.repo, o.base, false) + if len(srcFiles) == 0 { + profile := o.profile + if profile == "" { + profile = benchmarkProfileBaseline + } + return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile) + } + pkgs := packagesOf(srcFiles) + prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--"))) + + attempts := o.attempts + if attempts < 1 { + attempts = 1 + } + var best diffReport + made := 0 + for i := 1; i <= attempts; i++ { + if i > 1 { + resetTree(o.repo) + } + r := runOnce(o, srcFiles, pkgs, prompt) + made = i + if i == 1 || better(r, best) { + best = r + } + if best.passed { + break // stop at the first passing run; attempts is a retry budget + } + } + best.attempt, best.attempts = made, attempts + return renderDiff(best) +} + +// runOnce does one agent run + grade: generate tests, check they pass on HEAD, +// differential-check each against the reverted source, measure changed-line +// coverage, and confirm the agent didn't break the build anywhere. +func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport { + metricsPath := filepath.Join(o.repo, ".e2e-diff-metrics.json") + _ = os.Remove(metricsPath) + defer os.Remove(metricsPath) + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(o.timeoutSec)*time.Second) + defer cancel() + + args := []string{"run", "--metrics", metricsPath, "--max-steps", fmt.Sprint(o.maxSteps)} + if o.model != "" { + args = append(args, "--model", o.model) + } + args = appendBenchmarkProfileArgs(args, o.profile) + args = append(args, prompt) + cmd := exec.CommandContext(ctx, o.bin, args...) + cmd.Dir = o.repo + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout + runErr := cmd.Run() + + // The agent's new files are untracked, so `git diff HEAD` would miss them; + // intent-to-add surfaces them as additions without committing. + _ = exec.Command("git", "-C", o.repo, "add", "-AN").Run() + + m, _ := readMetrics(metricsPath) + testDiff := gitOut(o.repo, "diff", "HEAD", "--", "*_test.go") + refs := parseNewTests(testDiff) + sourceTouched := len(changedGoFilesWorktree(o.repo, false)) + testsPass, testOut := runTests(o.repo, o.testCmd, pkgs) + + var pins []pinResult + var mut mutationResult + covered, coverTotal := 0, 0 + if len(refs) > 0 && testsPass { + covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles) + pins = differentialPerTest(o.repo, o.base, srcFiles, refs) + mut = runMutation(o.repo, o.base, srcFiles, refs) + } + buildOK, buildOut := goBuildAll(o.repo) + + passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0 + return diffReport{ + srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff), + newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass, + pins: pins, mut: mut, covered: covered, coverTotal: coverTotal, + buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut), + passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff, + } +} + +// better reports whether candidate a is a stronger result than b: a pass beats a +// fail, then more assertion-pins, then more pins, then higher changed-line +// coverage. +func better(a, b diffReport) bool { + if a.passed != b.passed { + return a.passed + } + if x, y := countAssertionPins(a.pins), countAssertionPins(b.pins); x != y { + return x > y + } + if x, y := countPins(a.pins), countPins(b.pins); x != y { + return x > y + } + if a.mut.caught != b.mut.caught { + return a.mut.caught > b.mut.caught + } + return ratio(a.covered, a.coverTotal) > ratio(b.covered, b.coverTotal) +} + +func ratio(n, d int) float64 { + if d == 0 { + return 0 + } + return float64(n) / float64(d) +} + +// resetTree restores the PR-head tree between attempts, dropping the previous +// attempt's generated tests but keeping the provider config the workflow wrote. +func resetTree(repo string) { + _ = exec.Command("git", "-C", repo, "checkout", "--", ".").Run() + _ = exec.Command("git", "-C", repo, "clean", "-fd", "-e", "reasonix.toml").Run() +} + +func goBuildAll(repo string) (bool, string) { + cmd := exec.Command("go", "build", "./...") + cmd.Dir = repo + cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs + out, err := cmd.CombinedOutput() + return err == nil, string(out) +} + +func buildDiffPrompt(srcFiles, pkgs []string, diffText string) string { + var b strings.Builder + b.WriteString("You are in a Go repository. This pull request changed these source files:\n") + for _, f := range srcFiles { + fmt.Fprintf(&b, " - %s\n", f) + } + b.WriteString("\nUnified diff of the change:\n```diff\n") + b.WriteString(diffText) + b.WriteString("\n```\n\n") + b.WriteString("Write focused Go unit tests that exercise the NEW or CHANGED behavior in those files. ") + b.WriteString("Add them to the appropriate *_test.go files in the same packages (") + b.WriteString(strings.Join(pkgs, ", ")) + b.WriteString("). Do NOT modify the non-test source files — only add or extend test files. ") + b.WriteString("Prefer small, focused edits and run `gofmt`/`go vet` on the test files as you go to avoid syntax errors. ") + b.WriteString("Then run the package tests and iterate until they pass. When finished, list the test functions you added.") + return b.String() +} + +type diffReport struct { + srcFiles, pkgs []string + addedTestLines int + newTests []testRef + sourceTouched int + testsPass bool + pins []pinResult + mut mutationResult + covered, coverTotal int + buildOK bool + buildOut string + failing []string + passed bool + profile string + attempt, attempts int + m runMetrics + runErr error + testOut string + testDiff string +} + +func renderDiff(r diffReport) string { + var b strings.Builder + result := "❌ fail" + if r.passed { + result = "✅ pass" + } + profile := r.profile + if profile == "" { + profile = benchmarkProfileBaseline + } + fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile) + fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs)) + + pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins) + fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n") + fmt.Fprintf(&b, "| New test functions added | %d |\n", len(r.newTests)) + fmt.Fprintf(&b, "| Test lines added | +%d |\n", r.addedTestLines) + fmt.Fprintf(&b, "| `go test` on affected pkgs | %s |\n", passFail(r.testsPass)) + fmt.Fprintf(&b, "| Differential (fail on pre-PR code) | %s |\n", differentialCell(r)) + if pinned > 0 { + fmt.Fprintf(&b, "| ↳ pin by assertion / by compile only | %d / %d |\n", byAssert, pinned-byAssert) + } + fmt.Fprintf(&b, "| Changed-line coverage | %s |\n", coverageCell(r)) + fmt.Fprintf(&b, "| Mutation (changed funcs caught) | %s |\n", mutationCell(r)) + fmt.Fprintf(&b, "| `go build ./...` (regression) | %s |\n", passFail(r.buildOK)) + fmt.Fprintf(&b, "| Non-test source touched by agent | %d file(s) |\n", r.sourceTouched) + fmt.Fprintf(&b, "| Cache hit | %s |\n", pct(r.m.CacheHitTokens, r.m.CacheHitTokens+r.m.CacheMissTokens)) + fmt.Fprintf(&b, "| Tokens (prompt / completion) | %s / %s |\n", comma(r.m.PromptTokens), comma(r.m.CompletionTokens)) + fmt.Fprintf(&b, "| Model calls | %d |\n", r.m.Steps) + fmt.Fprintf(&b, "| Cost | %s%.4f |\n", currencySym(r.m.Currency), r.m.Cost) + if r.m.CapabilityRoutes > 0 || r.m.CapabilitySkillInvocations > 0 || r.m.CapabilityMCPCall > 0 || r.m.ReadinessChecks > 0 { + fmt.Fprintf(&b, "| Capability routes (semantic) | %d (%d) |\n", r.m.CapabilityRoutes, r.m.CapabilitySemanticRoutes) + fmt.Fprintf(&b, "| Routed candidates (require / prefer / suggest / declined) | %d (%d / %d / %d / %d) |\n", r.m.CapabilityRoutedCandidates, r.m.CapabilityRoutedRequire, r.m.CapabilityRoutedPrefer, r.m.CapabilityRoutedSuggest, r.m.CapabilityDeclines) + fmt.Fprintf(&b, "| Skill invocations / MCP proxy calls | %d / %d |\n", r.m.CapabilitySkillInvocations, r.m.CapabilityMCPCall) + fmt.Fprintf(&b, "| Review blocks / readiness recoveries | %d / %d |\n", r.m.CapabilityReviewBlocks, r.m.ReadinessRecoveries) + if r.m.CapabilityRouterCost > 0 || r.m.CapabilityRouterLatencyMs > 0 { + fmt.Fprintf(&b, "| Capability-router cost / latency | %s%.4f / %dms |\n", currencySym(r.m.Currency), r.m.CapabilityRouterCost, r.m.CapabilityRouterLatencyMs) + } + } + if len(r.failing) > 0 { + fmt.Fprintf(&b, "| Failing tests | `%s` |\n", strings.Join(r.failing, "`, `")) + } + if r.attempts > 1 { + status := "none passed" + if r.passed { + status = "passed" + } + fmt.Fprintf(&b, "| Attempts | %d of up to %d (%s) |\n", r.attempt, r.attempts, status) + } + + fmt.Fprintf(&b, "\n**Packages:** %s\n", strings.Join(r.pkgs, ", ")) + if r.attempts <= 1 { + fmt.Fprintf(&b, "\nSingle stochastic run — a green result is one sample, not a guarantee. Comment `/e2e diff x3` to retry up to 3×.\n") + } + if !r.buildOK && strings.TrimSpace(r.buildOut) != "" { + fmt.Fprintf(&b, "\n
go build ./... output (tail)\n\n```\n%s\n```\n
\n", tail(r.buildOut, 40)) + } + if r.sourceTouched > 0 { + fmt.Fprintf(&b, "\n⚠️ The agent modified %d non-test source file(s); a green run may not reflect the PR's code. Review the diff.\n", r.sourceTouched) + } + + if len(r.pins) > 0 { + fmt.Fprintf(&b, "\n
Per-test differential\n\n| Test | Package | Pins the change? |\n|---|---|---|\n") + for _, p := range r.pins { + fmt.Fprintf(&b, "| `%s` | %s | %s |\n", p.name, p.pkg, pinCell(p)) + } + fmt.Fprintf(&b, "\n
\n") + } + if strings.TrimSpace(r.testDiff) != "" { + fmt.Fprintf(&b, "\n
Generated tests (review the assertions)\n\n```diff\n%s\n```\n
\n", truncateFor(r.testDiff, 20000)) + } + if !r.testsPass && strings.TrimSpace(r.testOut) != "" { + fmt.Fprintf(&b, "\n
go test output (tail)\n\n```\n%s\n```\n
\n", tail(r.testOut, 60)) + } + if r.runErr != nil { + fmt.Fprintf(&b, "\nagent run note: %v\n", r.runErr) + } + fmt.Fprintf(&b, "\nPass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.\n") + return b.String() +} + +func differentialCell(r diffReport) string { + if !(len(r.newTests) > 0 && r.testsPass) { + return "n/a (tests not green)" + } + return fmt.Sprintf("%d/%d new tests", countPins(r.pins), len(r.pins)) +} + +func coverageCell(r diffReport) string { + if r.coverTotal == 0 { + return "n/a" + } + return fmt.Sprintf("%s (%d/%d changed lines)", pct(r.covered, r.coverTotal), r.covered, r.coverTotal) +} + +func mutationCell(r diffReport) string { + if r.mut.total == 0 { + return "n/a" + } + cell := fmt.Sprintf("%d/%d (%s)", r.mut.caught, r.mut.total, pct(r.mut.caught, r.mut.total)) + if len(r.mut.survivors) > 0 { + cell += fmt.Sprintf(" · survived: `%s`", strings.Join(r.mut.survivors, "`, `")) + } + return cell +} + +func pinCell(p pinResult) string { + switch { + case p.pins && p.byAssertion: + return "✅ by assertion" + case p.pins: + return "⚠️ by compile only" + default: + return "❌ no (passes on old code)" + } +} + +// differentialPerTest reverts the PR's changed source to base (deleting files +// new in the PR), runs each generated test on its own against the old code, and +// restores the source. A test that fails on the old code pins the change. +func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult { + for _, f := range srcFiles { + if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil { + _ = os.Remove(filepath.Join(repo, filepath.FromSlash(f))) + } + } + // Restore source even on panic; a tree left on `base` would mask the PR for later steps. + restored := false + defer func() { + if restored { + return + } + for _, f := range srcFiles { + _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run() + } + }() + + out := make([]pinResult, 0, len(refs)) + for _, r := range refs { + cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg) + cmd.Dir = repo + cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test + raw, err := cmd.CombinedOutput() + out = append(out, pinResult{ + testRef: r, + pins: err != nil, + byAssertion: strings.Contains(string(raw), "--- FAIL: "+r.name), + }) + } + for _, f := range srcFiles { + _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run() + } + restored = true + return out +} + +// changedLineCoverage runs the affected packages with a coverage profile and +// reports how many of the PR's changed source statement-lines the (new+existing) +// tests actually execute. covered/total are over changed lines that fall inside +// a coverage block; lines that aren't statements are ignored. +func changedLineCoverage(repo, base string, pkgs, srcFiles []string) (covered, total int) { + profile := filepath.Join(repo, ".e2e-cover.out") + defer os.Remove(profile) + args := append([]string{"test", "-covermode=set", "-coverprofile=" + profile, "-coverpkg=" + strings.Join(pkgs, ",")}, pkgs...) + cmd := exec.Command("go", args...) + cmd.Dir = repo + _ = cmd.Run() // a non-zero exit still writes the profile for the tests that ran + + blocks := parseCoverProfile(repo, profile) + for file, lines := range changedLineSet(repo, base, srcFiles) { + fileBlocks := blocks[file] + for ln := range lines { + for _, blk := range fileBlocks { + if ln >= blk.start && ln <= blk.end { + total++ + if blk.count > 0 { + covered++ + } + break + } + } + } + } + return covered, total +} + +type coverBlock struct { + start, end, count int +} + +// parseCoverProfile reads a Go coverage profile, keyed by repo-relative file path +// (the profile uses module-qualified paths; we match by repo-relative suffix). +func parseCoverProfile(repo, path string) map[string][]coverBlock { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + out := map[string][]coverBlock{} + for _, ln := range strings.Split(string(data), "\n") { + if ln == "" || strings.HasPrefix(ln, "mode:") { + continue + } + colon := strings.LastIndexByte(ln, ':') + if colon < 0 { + continue + } + modPath, rest := ln[:colon], ln[colon+1:] + var sl, sc, el, ec, nstmt, count int + if _, err := fmt.Sscanf(rest, "%d.%d,%d.%d %d %d", &sl, &sc, &el, &ec, &nstmt, &count); err != nil { + continue + } + rel := repoRelFromModulePath(modPath) + out[rel] = append(out[rel], coverBlock{start: sl, end: el, count: count}) + } + return out +} + +// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into +// "internal/agent/foo.go" by dropping the first path element (the module root). +func repoRelFromModulePath(p string) string { + // Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path. + prefix := "reasonix/" + if strings.HasPrefix(p, prefix) { + return p[len(prefix):] + } + if i := strings.IndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +// changedLineSet returns, per repo-relative source file, the set of new line +// numbers the PR added or changed (from a zero-context diff). +func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]bool { + args := append([]string{"diff", "--unified=0", base + "...HEAD", "--"}, srcFiles...) + diff := gitOut(repo, args...) + out := map[string]map[int]bool{} + file := "" + newLine := 0 + for _, ln := range strings.Split(diff, "\n") { + // '-' (deletion) lines are intentionally unhandled: they don't advance the + // new-side line counter, so they fall through with no case. + switch { + case strings.HasPrefix(ln, "+++ b/"): + file = strings.TrimPrefix(ln, "+++ b/") + out[file] = map[int]bool{} + case strings.HasPrefix(ln, "@@"): + // @@ -a,b +c,d @@ — start collecting at new-side line c. + // Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed. + if plus := strings.Index(ln, "+"); plus >= 0 { + num := ln[plus+1:] + end := len(num) + for i := 0; i < len(num); i++ { + if num[i] < '0' || num[i] > '9' { + end = i + break + } + } + _, _ = fmt.Sscanf(num[:end], "%d", &newLine) + } + case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"): + if file != "" { + out[file][newLine] = true + } + newLine++ + } + } + return out +} + +func countPins(ps []pinResult) int { + n := 0 + for _, p := range ps { + if p.pins { + n++ + } + } + return n +} + +func countAssertionPins(ps []pinResult) int { + n := 0 + for _, p := range ps { + if p.pins && p.byAssertion { + n++ + } + } + return n +} + +// parseNewTests reads the working-tree *_test.go diff and returns the Test/Fuzz/ +// Benchmark functions the agent added, each tagged with its package directory. +func parseNewTests(diff string) []testRef { + var refs []testRef + pkg := "" + for _, ln := range strings.Split(diff, "\n") { + if strings.HasPrefix(ln, "+++ b/") { + pkg = "./" + filepath.ToSlash(filepath.Dir(strings.TrimPrefix(ln, "+++ b/"))) + continue + } + if !strings.HasPrefix(ln, "+") || strings.HasPrefix(ln, "+++") { + continue + } + body := strings.TrimSpace(ln[1:]) + if !strings.HasPrefix(body, "func ") { + continue + } + sig := strings.TrimPrefix(body, "func ") + // Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name. + var name string + if sig[0] == '(' { + close := strings.IndexByte(sig, ')') + if close < 0 { + continue + } + rest := strings.TrimSpace(sig[close+1:]) + methodParen := strings.IndexByte(rest, '(') + if methodParen <= 0 { + continue + } + name = rest[:methodParen] + } else { + funcParen := strings.IndexByte(sig, '(') + if funcParen <= 0 { + continue + } + name = sig[:funcParen] + } + if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") { + refs = append(refs, testRef{name: name, pkg: pkg}) + } + } + return refs +} + +func countAdded(diff string) int { + n := 0 + for _, ln := range strings.Split(diff, "\n") { + if strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++") { + n++ + } + } + return n +} + +// failingTestNames pulls the names out of `--- FAIL: TestX (…)` lines. +func failingTestNames(out string) []string { + var names []string + seen := map[string]bool{} + for _, ln := range strings.Split(out, "\n") { + ln = strings.TrimSpace(ln) + if !strings.HasPrefix(ln, "--- FAIL:") { + continue + } + rest := strings.Fields(strings.TrimSpace(strings.TrimPrefix(ln, "--- FAIL:"))) + if len(rest) > 0 && !seen[rest[0]] { + seen[rest[0]] = true + names = append(names, rest[0]) + } + } + return names +} + +func runTests(repo, testCmd string, pkgs []string) (bool, string) { + test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true}) + if err != nil { + return false, "invalid test command: " + err.Error() + } + fields := test.Argv + if len(fields) == 0 { + fields = []string{"go", "test"} + } + args := append(fields[1:], pkgs...) + cmd := exec.Command(fields[0], args...) + cmd.Dir = repo + if len(test.Env) > 0 { + cmd.Env = append(os.Environ(), test.Env...) + } + cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs + out, err := cmd.CombinedOutput() + return err == nil, string(out) +} + +// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go +// when includeTests is false (we want the source under test). +func changedGoFiles(repo, base string, includeTests bool) []string { + return filterGo(gitOut(repo, "diff", "--name-only", base+"...HEAD", "--", "*.go"), includeTests) +} + +func changedGoFilesWorktree(repo string, includeTests bool) []string { + return filterGo(gitOut(repo, "diff", "--name-only", "HEAD", "--", "*.go"), includeTests) +} + +func filterGo(out string, includeTests bool) []string { + var keep []string + for _, f := range strings.Fields(strings.ReplaceAll(out, "\n", " ")) { + if strings.HasSuffix(f, "_test.go") && !includeTests { + continue + } + keep = append(keep, f) + } + sort.Strings(keep) + return keep +} + +func packagesOf(files []string) []string { + seen := map[string]bool{} + var pkgs []string + for _, f := range files { + dir := "./" + filepath.ToSlash(filepath.Dir(f)) + if !seen[dir] { + seen[dir] = true + pkgs = append(pkgs, dir) + } + } + sort.Strings(pkgs) + return pkgs +} + +func gitOut(repo string, args ...string) string { + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, _ := cmd.Output() + return string(out) +} + +func truncate(s string) string { return truncateFor(s, 12000) } + +func truncateFor(s string, max int) string { + if max <= 0 || len(s) <= max { + return s + } + // Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune. + cut := max + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "\n…(truncated)…" +} + +func tail(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +func passFail(ok bool) string { + if ok { + return "pass" + } + return "fail" +} diff --git a/cmd/e2ebench/diff_test.go b/cmd/e2ebench/diff_test.go new file mode 100644 index 0000000..6d98b05 --- /dev/null +++ b/cmd/e2ebench/diff_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) { + repo := t.TempDir() + writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n") + writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest + +import ( + "os" + "testing" +) + +func TestEnv(t *testing.T) { + if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" { + t.Fatalf("REASONIX_E2E_ENV = %q", got) + } +} +`) + + ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."}) + if !ok { + t.Fatalf("runTests failed:\n%s", out) + } +} + +func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) { + ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."}) + if ok { + t.Fatal("runTests accepted dynamic env assignment") + } + if !strings.Contains(out, "invalid test command: shell expansion") { + t.Fatalf("output = %q, want shell expansion rejection", out) + } +} + +func writeE2EFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/cmd/e2ebench/main.go b/cmd/e2ebench/main.go new file mode 100644 index 0000000..1cd63f0 --- /dev/null +++ b/cmd/e2ebench/main.go @@ -0,0 +1,430 @@ +// e2ebench runs the committed e2e task suite against a real provider and emits a +// markdown + JSON report (accuracy, cache-hit rate, token use, cost) for a PR. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/BurntSushi/toml" + + fileencoding "reasonix/internal/fileutil/encoding" +) + +type task struct { + ID string + Prompt string `toml:"prompt"` + MaxSteps int `toml:"max_steps"` + TimeoutSec int `toml:"timeout_sec"` + dir string +} + +type runMetrics struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + CacheHitTokens int `json:"cache_hit_tokens"` + CacheMissTokens int `json:"cache_miss_tokens"` + Steps int `json:"steps"` + Cost float64 `json:"cost"` + Currency string `json:"currency"` + Compactions int `json:"compactions"` + + // Optional Delivery capability counters (omitempty for baseline/old metrics). + ReadinessChecks int `json:"readiness_checks,omitempty"` + ReadinessRecoveries int `json:"readiness_recoveries,omitempty"` + CapabilityRoutes int `json:"capability_routes,omitempty"` + CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"` + CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"` + CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"` + CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"` + CapabilityDeclines int `json:"capability_declines,omitempty"` + CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"` + CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"` + CapabilityMCPCall int `json:"capability_mcp_call,omitempty"` + CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"` + CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"` + CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"` +} + +type result struct { + task + runMetrics + Profile string `json:"profile"` + Passed bool + Skipped bool + Note string +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n") + fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name()) + flag.PrintDefaults() + fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n") + fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n") + fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1)) + fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n") + fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1)) + fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n") + fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1)) + } + + mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)") + suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks//)") + bin := flag.String("bin", "reasonix", "path to the reasonix binary") + model := flag.String("model", "", "provider/model name (default: config default)") + profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery") + outMD := flag.String("out", "", "write the markdown report here (default: stdout)") + outJSON := flag.String("json", "", "write the JSON report here (optional)") + budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)") + // diff-mode flags + repo := flag.String("repo", ".", "repo root (diff mode)") + base := flag.String("base", "", "base ref to diff the PR head against (diff mode)") + testCmd := flag.String("test-cmd", "go test", "grader command run on the affected packages (diff mode)") + maxSteps := flag.Int("max-steps", 80, "agent tool-call cap for the diff task") + timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)") + attempts := flag.Int("attempts", 1, "diff mode: retry up to N times until a run passes (stochastic agent)") + flag.Parse() + profile, err := normalizeBenchmarkProfile(*profileFlag) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + if *mode == "diff" { + report := runDiff(diffOpts{ + bin: *bin, model: *model, repo: *repo, base: *base, + testCmd: *testCmd, profile: profile, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts, + }) + emit(report, *outMD, "") + return + } + + tasks, err := loadTasks(*suite) + if err != nil { + fmt.Fprintln(os.Stderr, "load suite:", err) + os.Exit(1) + } + if len(tasks) == 0 { + dir := filepath.Join(*suite, "tasks") + if _, statErr := os.Stat(dir); statErr != nil { + fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr) + } else { + fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir) + } + os.Exit(1) + } + + var results []result + total := 0 + for _, t := range tasks { + if *budget > 0 && total >= *budget { + results = append(results, result{task: t, Profile: profile, Skipped: true, Note: "skipped: token budget reached"}) + continue + } + r := runTask(*bin, *model, profile, t) + total += r.PromptTokens + r.CompletionTokens + results = append(results, r) + } + + report := render(results) + if *outMD != "" { + if err := os.WriteFile(*outMD, []byte(report), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write report:", err) + os.Exit(1) + } + } else { + fmt.Print(report) + } + if *outJSON != "" { + b, err := json.MarshalIndent(results, "", " ") + if err != nil { + fmt.Fprintln(os.Stderr, "marshal json:", err) + os.Exit(1) + } + if err := os.WriteFile(*outJSON, b, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write json:", err) + os.Exit(1) + } + } +} + +func emit(report, outMD, _ string) { + if outMD != "" { + if err := os.WriteFile(outMD, []byte(report), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write report:", err) + os.Exit(1) + } + return + } + fmt.Print(report) +} + +func loadTasks(suite string) ([]task, error) { + tasksDir := filepath.Join(suite, "tasks") + entries, err := os.ReadDir(tasksDir) + if err != nil { + return nil, err + } + var tasks []task + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(tasksDir, e.Name()) + var t task + data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml")) + if err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + if _, err := toml.Decode(string(data), &t); err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + t.ID = e.Name() + t.dir = dir + if t.TimeoutSec == 0 { + t.TimeoutSec = 240 + } + tasks = append(tasks, t) + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID }) + return tasks, nil +} + +// runTask copies the task's seed workdir into a temp dir, runs the agent there, +// then drops in verify.sh and runs it as the grader. The grader is added only +// after the run so the agent can't read the answer key. +func runTask(bin, model, profile string, t task) result { + r := result{task: t, Profile: profile} + + work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-") + if err != nil { + r.Note = "mktemp: " + err.Error() + return r + } + defer os.RemoveAll(work) + + if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) { + if err := copyDir(seed, work); err != nil { + r.Note = "copy seed: " + err.Error() + return r + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second) + defer cancel() + + metricsPath := filepath.Join(work, ".run-metrics.json") + args := []string{"run", "--metrics", metricsPath} + if model != "" { + args = append(args, "--model", model) + } + if t.MaxSteps > 0 { + args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps)) + } + args = appendBenchmarkProfileArgs(args, profile) + args = append(args, t.Prompt) + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = work + cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report + cmd.Stderr = os.Stderr + cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout + runErr := cmd.Run() + + if m, err := readMetrics(metricsPath); err == nil { + r.runMetrics = m + } + if runErr != nil { + r.Note = "run: " + runErr.Error() + // still grade — a non-zero exit may just be a max-steps notice + } + + r.Passed = grade(work, t.dir) + return r +} + +func grade(work, taskDir string) bool { + verify := filepath.Join(taskDir, "verify.sh") + if !fileExists(verify) { + return false + } + dst := filepath.Join(work, "verify.sh") + if err := copyFile(verify, dst); err != nil { + return false + } + cmd := exec.Command("bash", "verify.sh") + cmd.Dir = work + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() == nil +} + +func render(results []result) string { + var b strings.Builder + passed, ran := 0, 0 + var pTok, cTok, hit, miss, compacts int + var cost float64 + currency := "" + for _, r := range results { + if r.Skipped { + continue + } + ran++ + if r.Passed { + passed++ + } + pTok += r.PromptTokens + cTok += r.CompletionTokens + hit += r.CacheHitTokens + miss += r.CacheMissTokens + compacts += r.Compactions + cost += r.Cost + if r.Currency != "" { + currency = r.Currency + } + } + + profile := benchmarkProfileBaseline + if len(results) > 0 && results[0].Profile != "" { + profile = results[0].Profile + } + fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark (%s)\n\n", profile) + fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n", + passed, ran, pct(passed, ran), pct(hit, hit+miss), + comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost) + + fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n") + fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n") + for _, r := range results { + switch { + case r.Skipped: + fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID) + default: + res := "❌ fail" + if r.Passed { + res = "✅ pass" + } + fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n", + r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens), + pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens), + r.Compactions, currencySym(r.Currency), r.Cost) + } + } + fmt.Fprintf(&b, "\nReal provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.\n") + + notes := false + for _, r := range results { + if r.Note != "" { + if !notes { + fmt.Fprintf(&b, "\n
Notes\n\n") + notes = true + } + fmt.Fprintf(&b, "- `%s`: %s\n", r.ID, r.Note) + } + } + if notes { + fmt.Fprintf(&b, "\n
\n") + } + return b.String() +} + +func pct(n, d int) string { + if d == 0 { + return "n/a" + } + return fmt.Sprintf("%.0f%%", 100*float64(n)/float64(d)) +} + +func comma(n int) string { + s := fmt.Sprint(n) + if len(s) <= 3 { + return s + } + var out []byte + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} + +func currencySym(c string) string { + if c == "" { + return "" + } + return c + " " +} + +func readMetrics(path string) (runMetrics, error) { + var m runMetrics + b, err := fileencoding.ReadFileUTF8(path) + if err != nil { + return m, err + } + return m, json.Unmarshal(b, &m) +} + +func dirExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +func fileExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Skip symlinks so a seed link can't leak a file from outside the seed tree. + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + rel, _ := filepath.Rel(src, p) + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(p, target) + }) +} + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + info, err := in.Stat() + if err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + // Mirror the source mode so a seed's read-only / exec bit survives the copy. + return os.Chmod(dst, info.Mode().Perm()) +} diff --git a/cmd/e2ebench/mutation.go b/cmd/e2ebench/mutation.go new file mode 100644 index 0000000..5a214cc --- /dev/null +++ b/cmd/e2ebench/mutation.go @@ -0,0 +1,143 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/printer" + "go/token" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const maxMutants = 15 + +type mutationResult struct { + caught, total int + survivors []string +} + +// runMutation gives a behavioral signal that the differential can't for additive +// PRs: it replaces each changed function's body with a zero-value return (which +// compiles for any signature via *new(T)), runs only the agent's new tests for +// that package, and records whether they catch the mutation. A caught mutant +// means a test actually asserts that function's output; a survivor means the new +// tests don't check it. +func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult { + changed := changedLineSet(repo, base, srcFiles) + byPkg := map[string][]string{} + for _, r := range refs { + byPkg[r.pkg] = append(byPkg[r.pkg], r.name) + } + + var res mutationResult + for _, file := range srcFiles { + if res.total >= maxMutants { + break + } + pkg := "./" + filepath.ToSlash(filepath.Dir(file)) + tests := byPkg[pkg] + if len(tests) == 0 { + continue // no new tests to attribute a catch to + } + runRe := "^(" + strings.Join(tests, "|") + ")$" + + abs := filepath.Join(repo, filepath.FromSlash(file)) + srcB, err := os.ReadFile(abs) + if err != nil { + continue + } + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, abs, srcB, 0) + if err != nil { + continue + } + src := string(srcB) + for _, fd := range changedFuncs(fset, f, changed[file]) { + if res.total >= maxMutants { + break + } + res.total++ + lb := fset.Position(fd.Body.Lbrace).Offset + rb := fset.Position(fd.Body.Rbrace).Offset + mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:] + if os.WriteFile(abs, []byte(mutated), 0o644) != nil { + res.total-- + continue + } + cmd := exec.Command("go", "test", "-run", runRe, pkg) + cmd.Dir = repo + cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test + // Restore source even on panic; a file left mutated would corrupt the next mutant. + restored := false + defer func() { + if !restored { + _ = os.WriteFile(abs, srcB, 0o644) + } + }() + caught := cmd.Run() != nil + _ = os.WriteFile(abs, srcB, 0o644) + restored = true + if caught { + res.caught++ + } else { + res.survivors = append(res.survivors, fd.Name.Name) + } + } + } + return res +} + +// changedFuncs returns the funcs in f whose line range overlaps a changed line. +// main/init and nil-named decls are skipped (no meaningful return to mutate). +func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl { + var out []*ast.FuncDecl + for _, decl := range f.Decls { + fd, ok := decl.(*ast.FuncDecl) + if !ok || fd.Body == nil || fd.Name == nil { + continue + } + name := fd.Name.Name + if name == "main" || name == "init" { + continue + } + start := fset.Position(fd.Pos()).Line + end := fset.Position(fd.End()).Line + for ln := range lines { + if ln >= start && ln <= end { + out = append(out, fd) + break + } + } + } + return out +} + +// mutantBody returns a replacement body that returns the zero value for each +// result. *new(T) is the zero value of any type T, so this compiles for every +// signature without naming the results or knowing their concrete types. +func mutantBody(fset *token.FileSet, results *ast.FieldList) string { + if results == nil || len(results.List) == 0 { + return "{\n}" + } + var rets []string + for _, field := range results.List { + t := "*new(" + printType(fset, field.Type) + ")" + n := len(field.Names) + if n == 0 { + n = 1 + } + for i := 0; i < n; i++ { + rets = append(rets, t) + } + } + return "{\n\treturn " + strings.Join(rets, ", ") + "\n}" +} + +func printType(fset *token.FileSet, e ast.Expr) string { + var b strings.Builder + _ = printer.Fprint(&b, fset, e) + return b.String() +} diff --git a/cmd/e2ebench/profile.go b/cmd/e2ebench/profile.go new file mode 100644 index 0000000..e3b5808 --- /dev/null +++ b/cmd/e2ebench/profile.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "strings" +) + +const ( + benchmarkProfileBaseline = "baseline" + benchmarkProfileDelivery = "delivery" +) + +func normalizeBenchmarkProfile(profile string) (string, error) { + switch strings.ToLower(strings.TrimSpace(profile)) { + case "", benchmarkProfileBaseline: + return benchmarkProfileBaseline, nil + case benchmarkProfileDelivery: + return benchmarkProfileDelivery, nil + default: + return "", fmt.Errorf("unknown benchmark profile %q (want baseline or delivery)", profile) + } +} + +func appendBenchmarkProfileArgs(args []string, profile string) []string { + if profile == benchmarkProfileDelivery { + return append(args, "--profile", "delivery") + } + return args +} diff --git a/cmd/e2ebench/profile_test.go b/cmd/e2ebench/profile_test.go new file mode 100644 index 0000000..bd32f00 --- /dev/null +++ b/cmd/e2ebench/profile_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) { + args := []string{"run", "fix the bug"} + if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) { + t.Fatalf("baseline args changed: %v", got) + } +} + +func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) { + args := []string{"run"} + got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery) + want := []string{"run", "--profile", "delivery"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("delivery args = %v, want %v", got, want) + } +} + +func TestNormalizeBenchmarkProfile(t *testing.T) { + for _, input := range []string{"", "baseline", " BASELINE "} { + if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline { + t.Fatalf("normalize(%q) = %q, %v", input, got, err) + } + } + if got, err := normalizeBenchmarkProfile("delivery"); err != nil || got != benchmarkProfileDelivery { + t.Fatalf("normalize(delivery) = %q, %v", got, err) + } + if _, err := normalizeBenchmarkProfile("fast"); err == nil { + t.Fatal("unknown profile should fail") + } +} diff --git a/cmd/reasonix-plugin-example/main.go b/cmd/reasonix-plugin-example/main.go new file mode 100644 index 0000000..3d900a6 --- /dev/null +++ b/cmd/reasonix-plugin-example/main.go @@ -0,0 +1,349 @@ +// Command reasonix-plugin-example is a reference Reasonix plugin: a minimal MCP stdio +// server speaking newline-delimited JSON-RPC 2.0 on stdin/stdout. It exists to +// document the contract end-to-end (the protocol the internal/plugin client +// drives) and to give users a working example to copy. +// +// Wire it up in reasonix.toml: +// +// [[plugins]] +// name = "example" +// command = "reasonix-plugin-example" +// +// Then reasonix surfaces its tools as "mcp__example__echo" / "mcp__example__wordcount", +// its prompt as the "/mcp__example__review" slash command, and its resource as +// the "@example:doc://style-guide" reference. +// +// Protocol, one JSON object per line: +// - initialize → {protocolVersion, capabilities, serverInfo} +// - notifications/initialized (notification, no id) → ignored +// - tools/list → {tools: [{name, description, inputSchema, annotations}]} +// - tools/call {name, arguments} → {content: [{type:"text", text}], isError} +// - prompts/list → {prompts: [{name, description, arguments}]} +// - prompts/get {name, arguments} → {messages: [{role, content:{type,text}}]} +// - resources/list → {resources: [{uri, name, description, mimeType}]} +// - resources/read {uri} → {contents: [{uri, mimeType, text}]} +// +// Logs go to stderr (reasonix forwards plugin stderr to the terminal); stdout is +// reserved for JSON-RPC so it must never carry stray prose. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "unicode/utf8" +) + +// version is overridable via -ldflags "-X main.version=...". Reported in +// initialize's serverInfo so reasonix (and humans) can see which build is running. +var version = "dev" + +func main() { + log.SetPrefix("reasonix-plugin-example: ") + log.SetFlags(0) + if err := serve(os.Stdin, os.Stdout); err != nil { + log.Fatal(err) + } +} + +// --- JSON-RPC framing --- + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID *json.RawMessage `json:"id"` // nil ⇒ notification (no reply); echoed back verbatim otherwise + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +const ( + protocolVersion = "2024-11-05" + codeMethodNotFound = -32601 + codeInvalidParams = -32602 +) + +// serve runs the read-dispatch-reply loop until stdin closes (reasonix closed the +// pipe / is shutting down). Each line is one JSON-RPC message. +func serve(in *os.File, out *os.File) error { + r := bufio.NewReader(in) + w := bufio.NewWriter(out) + defer w.Flush() + + for { + line, err := r.ReadBytes('\n') + if len(line) > 0 { + if rerr := handleLine(line, w); rerr != nil { + return rerr + } + if ferr := w.Flush(); ferr != nil { + return ferr + } + } + if err != nil { + return nil // EOF or pipe closed: clean shutdown + } + } +} + +func handleLine(line []byte, w *bufio.Writer) error { + line = trimSpace(line) + if len(line) == 0 { + return nil + } + var req request + if err := json.Unmarshal(line, &req); err != nil { + log.Printf("skipping unparseable line: %v", err) + return nil + } + if req.ID == nil { + return nil // notification (e.g. notifications/initialized): no reply + } + + resp := response{JSONRPC: "2.0", ID: *req.ID} + switch req.Method { + case "initialize": + resp.Result = map[string]any{ + "protocolVersion": protocolVersion, + "capabilities": map[string]any{ + "tools": map[string]any{}, + "prompts": map[string]any{}, + "resources": map[string]any{}, + }, + "serverInfo": map[string]any{"name": "reasonix-plugin-example", "version": version}, + } + case "tools/list": + resp.Result = map[string]any{"tools": toolList()} + case "tools/call": + resp.Result, resp.Error = callTool(req.Params) + case "prompts/list": + resp.Result = map[string]any{"prompts": promptList()} + case "prompts/get": + resp.Result, resp.Error = getPrompt(req.Params) + case "resources/list": + resp.Result = map[string]any{"resources": resourceList()} + case "resources/read": + resp.Result, resp.Error = readResource(req.Params) + default: + resp.Error = &rpcError{Code: codeMethodNotFound, Message: "method not found: " + req.Method} + } + + b, err := json.Marshal(resp) + if err != nil { + return fmt.Errorf("marshal response: %w", err) + } + if _, err := w.Write(append(b, '\n')); err != nil { + return err + } + return nil +} + +// --- tools --- + +// toolDef is one exposed tool: its advertised metadata plus the handler. run +// returns the text result, or an error which becomes an isError tool result the +// model can read and adapt to. +type toolDef struct { + name string + description string + schema map[string]any + readOnly bool + run func(args map[string]any) (string, error) +} + +// tools is the registry. Both demo tools are read-only and declare it via the +// readOnlyHint annotation, so reasonix runs them in parallel batches and (with the +// permission layer) auto-allows them without prompting. +var tools = []toolDef{ + { + name: "echo", + description: "Echo the given text back. The simplest possible proof the plugin round-trip works.", + readOnly: true, + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string", "description": "Text to echo back"}, + }, + "required": []string{"text"}, + }, + run: func(args map[string]any) (string, error) { + text, ok := args["text"].(string) + if !ok { + return "", fmt.Errorf("argument 'text' must be a string") + } + return text, nil + }, + }, + { + name: "wordcount", + description: "Count the lines, words, and bytes of the given text (like wc).", + readOnly: true, + schema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string", "description": "Text to measure"}, + }, + "required": []string{"text"}, + }, + run: func(args map[string]any) (string, error) { + text, ok := args["text"].(string) + if !ok { + return "", fmt.Errorf("argument 'text' must be a string") + } + return fmt.Sprintf("lines: %d, words: %d, bytes: %d, runes: %d", + countLines(text), len(strings.Fields(text)), len(text), utf8.RuneCountInString(text)), nil + }, + }, +} + +func toolList() []map[string]any { + out := make([]map[string]any, 0, len(tools)) + for _, t := range tools { + out = append(out, map[string]any{ + "name": t.name, + "description": t.description, + "inputSchema": t.schema, + "annotations": map[string]any{ + "readOnlyHint": t.readOnly, + "title": t.name, + }, + }) + } + return out +} + +// callTool dispatches a tools/call. A handler error is reported as an isError +// content result (in-band, so the model sees it) rather than a JSON-RPC error; +// JSON-RPC errors are reserved for protocol-level faults (bad params, unknown +// tool). +func callTool(params json.RawMessage) (any, *rpcError) { + var p struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} + } + for _, t := range tools { + if t.name != p.Name { + continue + } + text, err := t.run(p.Arguments) + if err != nil { + return textResult(err.Error(), true), nil + } + return textResult(text, false), nil + } + return nil, &rpcError{Code: codeInvalidParams, Message: "unknown tool: " + p.Name} +} + +func textResult(text string, isError bool) map[string]any { + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": text}}, + "isError": isError, + } +} + +// --- prompts --- + +// promptList advertises the server's prompts. They surface in reasonix as +// /mcp__example__ slash commands. +func promptList() []map[string]any { + return []map[string]any{{ + "name": "review", + "description": "Draft a focused code-review request for a file.", + "arguments": []map[string]any{ + {"name": "path", "description": "File to review", "required": true}, + }, + }} +} + +// getPrompt renders a prompt into MCP messages. The returned text becomes the +// next user turn in reasonix, so it's phrased as an instruction to the model. +func getPrompt(params json.RawMessage) (any, *rpcError) { + var p struct { + Name string `json:"name"` + Arguments map[string]string `json:"arguments"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} + } + if p.Name != "review" { + return nil, &rpcError{Code: codeInvalidParams, Message: "unknown prompt: " + p.Name} + } + path := p.Arguments["path"] + if path == "" { + path = "the current file" + } + text := fmt.Sprintf("Please review %s. Read it, then list any correctness bugs and risky patterns with file:line references, most important first.", path) + return map[string]any{ + "description": "Code review request", + "messages": []map[string]any{ + {"role": "user", "content": map[string]any{"type": "text", "text": text}}, + }, + }, nil +} + +// --- resources --- + +// resourceContents is the demo resource store, keyed by uri. +var resourceContents = map[string]string{ + "doc://style-guide": "Project style: tabs for indentation; comments explain why, not what; keep functions short.", +} + +func resourceList() []map[string]any { + return []map[string]any{{ + "uri": "doc://style-guide", + "name": "Style guide", + "description": "The project's coding style notes.", + "mimeType": "text/plain", + }} +} + +func readResource(params json.RawMessage) (any, *rpcError) { + var p struct { + URI string `json:"uri"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, &rpcError{Code: codeInvalidParams, Message: "invalid params: " + err.Error()} + } + text, ok := resourceContents[p.URI] + if !ok { + return nil, &rpcError{Code: codeInvalidParams, Message: "unknown resource: " + p.URI} + } + return map[string]any{ + "contents": []map[string]any{ + {"uri": p.URI, "mimeType": "text/plain", "text": text}, + }, + }, nil +} + +// countLines counts newline-separated lines, counting a final unterminated line. +func countLines(s string) int { + if s == "" { + return 0 + } + n := strings.Count(s, "\n") + if !strings.HasSuffix(s, "\n") { + n++ + } + return n +} + +// trimSpace trims leading/trailing ASCII whitespace without pulling in bytes. +func trimSpace(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} diff --git a/cmd/reasonix/main.go b/cmd/reasonix/main.go new file mode 100644 index 0000000..8bc4966 --- /dev/null +++ b/cmd/reasonix/main.go @@ -0,0 +1,20 @@ +// Command reasonix is a config- and plugin-driven coding agent CLI. +package main + +import ( + "os" + + "reasonix/internal/cli" + + // Blank imports wire compile-time built-ins into their registries. + _ "reasonix/internal/provider/anthropic" + _ "reasonix/internal/provider/openai" + _ "reasonix/internal/tool/builtin" +) + +// version is injected at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + os.Exit(cli.Run(os.Args[1:], version)) +} diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..dc78f70 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,45 @@ +# Wails build assets/output — mostly auto-generated (plists, manifests, compiled +# binary); ignore them but keep the committed designed app icon source and PNG. +/build/* +!/build/appicon.png +!/build/appicon.svg + +# Commit only our customized per-user NSIS installer template. wails_tools.nsh and +# everything else under build/ are regenerated by `wails build`, so stay ignored. +# (Each `!` un-ignore needs its parent dir un-ignored first.) +!/build/windows +/build/windows/* +!/build/windows/installer +/build/windows/installer/* +!/build/windows/installer/project.nsi + +# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh +# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT +# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first. +!/build/darwin +/build/darwin/* +!/build/darwin/entitlements.plist +!/build/darwin/icon.icns + +# Commit the hand-written Linux .deb packaging inputs and app icon assets. +# `wails build` does not generate these, so they must live in git. The .deb is built +# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first. +!/build/linux +/build/linux/* +!/build/linux/nfpm.yaml +!/build/linux/reasonix.desktop +!/build/linux/icons +!/build/linux/icons/** + +# Go test binaries +*.test + +# Local config symlinks for `wails dev` (point at the repo-root reasonix.toml/.env +# so the desktop app resolves the same config/keys the CLI does). Never commit. +/reasonix.toml +/.env + +# `go build` binary produced in-tree (module reasonix/desktop → "desktop") +/desktop +# Wails dependency-change cache +/frontend/package.json.md5 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..a7f27ce --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,283 @@ +# Reasonix Desktop (Wails shell) + +A native desktop window around the Reasonix Go kernel. The same +transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE +server is bound **directly** to a React webview — Go methods in, typed events +out, no HTTP hop. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ webview (React + TS, Vite) │ +│ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │ +│ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│ +└───────────────▲───────────────────────────┬─────────────────┘ + bound methods runtime.EventsEmit +┌───────────────┴───────────────────────────▼─────────────────┐ +│ desktop/app.go App (bound) + eventSink (event.Sink) │ +│ desktop/main.go Wails options, window, embed frontend/dist │ +└───────────────▲───────────────────────────┬─────────────────┘ + commands │ │ typed event stream +┌───────────────┴────────────────────────────▼────────────────┐ +│ internal/boot.Build → internal/control.Controller (kernel) │ +│ (same assembly the CLI uses: providers, tools, gate, …) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Why a nested module + +`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix => +../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's +`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build / +vet / test ./...` skip this directory, while the import path stays under +`reasonix/` so it can still import the `reasonix/internal/*` kernel. + +## Prerequisites + +- Go (matches the parent module). +- Node + **pnpm** (`npm i -g pnpm`). +- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest` +- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2** + runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links + against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu + 24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run + `wails doctor` to verify. + +## Develop + +```sh +cd desktop +wails dev # hot-reloads Go + frontend (Vite dev server) +``` + +Frontend-only iteration without the Go side: + +```sh +cd desktop/frontend +pnpm install +pnpm dev # opens in a plain browser; bridge.ts uses the dev mock +``` + +In a plain browser the native bindings are absent, so `bridge.ts` falls back to a +**mock** that streams a canned turn (text + one `edit_file` tool call) through the +exact same event contract — so layout, streaming, markdown, tool cards, and the +diff seam can all be built without rebuilding Go. + +## Test + +The desktop package is a nested Go module, so parent `go test ./...` does not run +it. Use the full lane before merging desktop changes, and the short lane for fast +local feedback: + +```sh +make desktop-test # cd desktop && go test . +make desktop-test-short # skips slow desktop integration/e2e checks +``` + +To find the next bottleneck, rank individual test cases from the JSON stream: + +```sh +make desktop-test-times +# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py +``` + +### Frontend UI review checklist + +For anchored menus, dropdowns, tooltips, and other portaled UI, review both the +component code and the CSS positioning contract: + +- If a component uses `createPortal` plus `getBoundingClientRect()`, it must + handle scrollable ancestors, window resize, and `visualViewport` changes. +- Add a focused regression test when changing shared positioning primitives such + as `AnchoredPopover`, not only the specific menu that exposed the bug. +- Exercise at least one scrollable container path, such as Settings content, when + manually checking dropdown or popover changes. + +## Build + +```sh +cd desktop +wails build # → build/bin/Reasonix(.app/.exe) +``` + +**Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no +`webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1. + +```sh +wails build -tags webkit2_41 +wails dev -tags webkit2_41 # same tag for hot-reload +``` + +Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`. + +`frontend/dist` is generated by the build (it's git-ignored except for a +`.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh +checkout). A bare `go build` without a prior `pnpm build` produces a blank window. + +## Releases & auto-update + +Desktop releases ride their own tag namespace, `desktop-v` (plain `v*` +tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`, +which builds on a native runner per platform (Wails can't cross-compile a +CGO/WebKit binary), packages each artifact, signs it with minisign, generates a +`latest.json` manifest, publishes a GitHub release, marks the desktop release as +GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the +current desktop manifest to the matching CLI release for old clients that still +ask GitHub's repository-wide `latest` release for it. +The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs +`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+. + +```sh +git tag desktop-v1.1.0 && git push origin desktop-v1.1.0 +``` + +The app checks `latest.json` on startup (R2 first, then the +`crash.reasonix.io` desktop release gateway) and shows an update banner when a +newer version is published; **Settings → Software update** has a manual check. +The gateway resolves only the desktop `desktop-v*` release line and never uses +GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does +not depend on homepage badge semantics. Self-update behavior by platform: + +- **Linux / Windows** — download, verify the minisign signature, then update in + place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS + installer (no admin rights needed). +- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an + in-place swap would be blocked by Gatekeeper; the banner links to the download + page for a manual update instead. + +### Unsigned builds — first launch + +There are no Apple/Windows code-signing certificates yet, so a downloaded build +trips the OS gatekeepers on first run: + +- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into + Applications. Gatekeeper may then report the app "is damaged" or is from an + unidentified developer; clear the quarantine attribute and open it: + ```sh + xattr -dr com.apple.quarantine /Applications/Reasonix.app + ``` +- **Windows** — SmartScreen shows "Windows protected your PC". Click *More info → + Run anyway*. + +When Developer ID / Authenticode certificates are added, the release workflow's +`HAS_APPLE_CERT` gate flips to the signed path and these steps go away. + +### Verifying a download + +Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig` +signature sits next to each artifact in the release; verify with the +[minisign](https://jedisct1.github.io/minisign/) CLI: + +```sh +minisign -Vm Reasonix-darwin-arm64.zip \ + -P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq +``` + +## Editor seam (Monaco / CodeMirror) + +Code and diff rendering go through two components with stable prop contracts and a +lazy boundary, so a heavy editor stays out of the initial bundle and dropping one +in is a one-line change — no consumer touches: + +| Component | Props | Default impl | Upgrade | +|---|---|---|---| +| `components/CodeViewer.tsx` | `EditorProps` | `editors/PlainCode.tsx` (`
`) | swap the lazy import for `editors/MonacoCode` or `editors/CodeMirrorCode` |
+| `components/DiffView.tsx` | `DiffProps` | `editors/PlainDiff.tsx` (LCS line diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
+
+```sh
+# Monaco
+pnpm add @monaco-editor/react monaco-editor
+# or CodeMirror 6
+pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
+```
+
+Then add `editors/MonacoCode.tsx` (default-export a component taking
+`EditorProps`) and point `CodeViewer.tsx`'s `lazy(() => import(...))` at it.
+`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
+`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
+both seams light up everywhere at once.
+
+Markdown itself is currently minimal (fenced code + plain text). Upgrade path:
+`pnpm add react-markdown remark-gfm` and render in `components/Markdown.tsx`,
+keeping fenced code delegated to `CodeViewer`.
+
+## Multi-platform adaptation
+
+Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses
+the **native** webview per OS, so the rough edges are platform-specific. What's
+handled here, and what to reach for if a target misbehaves:
+
+- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
+  GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
+  is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
+  that cannot access `/dev/dri`. If artifacts persist, launch with
+  `WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
+  the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
+  - **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
+    crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
+    explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
+    Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
+    Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
+    Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
+    or `GDK_BACKEND=x11` (forces XWayland).
+- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
+  setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
+  WebView2 GPU acceleration by default to smoke-test blank-window reports; set
+  `REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
+  off.
+- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
+  marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
+  room for the traffic lights.
+- **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all
+  three webviews honor, so the UI follows the OS theme without native glue.
+- **Fonts / offline** — system font stack only; no web-font fetches, so first paint
+  is instant and identical offline.
+- **First paint** — the window background is set to the dark shell color so there's
+  no white flash before CSS loads (most visible on WebKitGTK).
+
+## Files
+
+```
+desktop/
+  main.go            Wails options, window, embed frontend/dist
+  app.go             App (bound command surface) + eventSink (event.Sink → webview)
+  wire.go            event.Event → JSON wire form (mirrors internal/serve/wire.go)
+  wails.json         Wails project config (pnpm install/build/dev)
+  frontend/
+    src/
+      lib/
+        types.ts         wire contract (mirrors wire.go)
+        bridge.ts        window.go/window.runtime wrapper + browser dev mock
+        useController.ts event-stream reducer + command surface (the hook)
+      components/
+        Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge,
+        Markdown, CodeViewer, DiffView
+        editors/  PlainCode, PlainDiff   ← editor seam impls (swap targets)
+```
+
+## Telemetry
+
+The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
+a random install id (generated locally, tied to nothing), app version, OS,
+arch, and OS version. It exists solely to count active installs. It never
+includes conversations, API keys, file contents, or paths.
+
+Opt out any time: Settings > Updates > "Anonymous usage ping", or set
+`telemetry = false` under `[desktop]` in the global config. Dev builds
+never ping. Crash and performance-pressure reports are separate and only
+ever sent when the user clicks "Send report" on the diagnostic UI.
+
+Aggregate quality metrics are also enabled by default and can be disabled from
+Settings > Updates > "Share aggregate quality metrics", or by setting
+`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
+counts and preference buckets; they never include conversations, prompts, keys,
+paths, base URLs, or file contents.
+
+When Memory v5 is enabled, the same aggregate metrics pipeline may include only
+content-free count/size buckets such as injection on/off, compiled-token bucket,
+IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
+memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
+file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
+is controlled from Settings > General > "Memory v5" and shares the user/global
+`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
+CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
+or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
diff --git a/desktop/app.go b/desktop/app.go
new file mode 100644
index 0000000..8b606d3
--- /dev/null
+++ b/desktop/app.go
@@ -0,0 +1,9047 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"mime"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	goruntime "runtime"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+	"unicode/utf8"
+
+	"github.com/wailsapp/wails/v2/pkg/runtime"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/autoresearch"
+	"reasonix/internal/billing"
+	"reasonix/internal/boot"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/evidence"
+	"reasonix/internal/fileref"
+	fileenc "reasonix/internal/fileutil/encoding"
+	"reasonix/internal/i18n"
+	"reasonix/internal/mcpdiag"
+	"reasonix/internal/memory"
+	"reasonix/internal/notify"
+	"reasonix/internal/plugin"
+	"reasonix/internal/pluginpkg"
+	"reasonix/internal/provider"
+	"reasonix/internal/skill"
+	"reasonix/internal/store"
+	"reasonix/internal/tool"
+)
+
+// eventChannel is the Wails runtime event name the frontend subscribes to for the
+// agent's typed event stream. One channel carries every event kind; the payload's
+// `kind` field discriminates — the desktop analogue of the serve transport's SSE
+// `data:` frames.
+const eventChannel = "agent:event"
+
+const singleInstanceIDPrefix = "com.reasonix.desktop"
+
+// singleInstanceID is used by Wails to route a second desktop launch back to the
+// running instance. It is stable for a given binary path, while allowing a dev
+// build and an installed release at different paths to run side by side.
+func singleInstanceID() string {
+	abs, err := os.Executable()
+	if err != nil {
+		return singleInstanceIDPrefix
+	}
+	if resolved, err := filepath.EvalSymlinks(abs); err == nil {
+		abs = resolved
+	} else if fallback, err := filepath.Abs(abs); err == nil {
+		abs = fallback
+	}
+	sum := sha256.Sum256([]byte(abs))
+	return singleInstanceIDPrefix + "." + hex.EncodeToString(sum[:8])
+}
+
+// PromptHistoryEntry is one user prompt extracted from a session JSONL file.
+// The frontend uses these for ↑/↓ prompt-history navigation.
+type PromptHistoryEntry struct {
+	Text        string `json:"text"`
+	At          int64  `json:"at"` // unix ms
+	SessionPath string `json:"sessionPath"`
+	Turn        int    `json:"turn"`
+}
+
+// PromptHistoryResult is returned as one Wails value. It carries one loaded tape
+// segment plus the cursor needed to keep walking toward older prompts.
+type PromptHistoryResult struct {
+	Entries     []PromptHistoryEntry `json:"entries"`
+	Nonce       string               `json:"nonce"`
+	OlderCursor string               `json:"olderCursor,omitempty"`
+	HasOlder    bool                 `json:"hasOlder"`
+}
+
+// App is the Wails-bound application object: the desktop frontend's command
+// surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS
+// bindings. The app manages multiple WorkspaceTabs — each with its own controller
+// scoped to a project workspace — and routes commands to the active tab. Events
+// flow the other way: each tab's controller emits to a tabEventSink that
+// forwards events tagged with tabId to the webview via runtime.EventsEmit.
+type App struct {
+	ctx context.Context
+
+	// mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
+	// from bound methods. All bound methods that touch a controller use activeCtrl().
+	mu          sync.RWMutex
+	tabs        map[string]*WorkspaceTab
+	tabOrder    []string
+	activeTabID string
+	readyHook   func()
+
+	// tabsRestored is closed when restoreOrBuildTabs has finished populating
+	// a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
+	// work that inspects "which sessions are open" or persists the tab list
+	// (recovery GC's DeleteSession does both) must wait on it: running against
+	// the pre-restore empty tab map would treat every saved tab's session as
+	// closed and could overwrite desktop-tabs.json with an empty snapshot.
+	tabsRestored chan struct{}
+
+	// projectTreeChangedHook is test-only: set once before any concurrency
+	// starts, then read lock-free from emitProjectTreeChanged (whose callers
+	// may or may not hold a.mu, so it cannot re-lock). Never write it after
+	// startup.
+	projectTreeChangedHook func()
+
+	// singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
+	// one-conversation layout so overlapping navigation cannot remove the tab
+	// another navigation is still activating.
+	singleSurfaceMu sync.Mutex
+
+	// sessionRemovalMu serializes operations that remove visible or detached
+	// session bindings. Those operations may snapshot controllers before
+	// deletion; keep that snapshot outside a.mu, but do not let DeleteSession or
+	// topic/workspace removal trash the same files while it is in flight.
+	sessionRemovalMu sync.Mutex
+
+	// runtimeRebuildMu serializes controller rebuilds (build + swap) across
+	// rebuildSetting, SetModelForTab, and the deferred-rebuild retry loop. Two
+	// concurrent rebuilds of the same tab both pass the tab-identity check at
+	// swap time (the tab pointer is unchanged), so the loser's controller
+	// would replace the winner's and leak it without Close.
+	runtimeRebuildMu sync.Mutex
+
+	// tryRunMu guards tryRunCancel — the cancel handle for the single
+	// in-flight settings-page subagent try run (TrySubagentProfile /
+	// CancelTrySubagentProfile).
+	tryRunMu     sync.Mutex
+	tryRunCancel context.CancelFunc
+
+	// deferredRebuild tracks tabs whose settings were saved but whose runtime
+	// could not refresh because the session lease was held by another process.
+	deferredRebuild deferredRebuildState
+
+	// detachedSessions keeps live session runtimes whose visible tab was closed.
+	// It is process-local by design: shutdown closes every detached controller.
+	detachedSessions map[string]*WorkspaceTab
+
+	// sharedHosts holds one *plugin.Host per workspace root, shared by all
+	// controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are
+	// spawned once instead of N times. Lifecycle: first Acquire creates the
+	// host, last Release closes it.
+	sharedHosts   map[string]*sharedPluginHost
+	sharedHostsMu sync.Mutex
+
+	// tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.
+	tabsSaveMu             sync.Mutex
+	tabsSaveVersion        uint64 // protected by mu; assigned when collecting a snapshot
+	tabsLastWrittenVersion uint64 // protected by tabsSaveMu
+
+	forceQuit           atomic.Bool
+	backgroundMaximised atomic.Bool
+	trayReady           bool
+	tray                *desktopTray
+	hangWatchdogMu      sync.Mutex
+	hangWatchdogCancel  context.CancelFunc
+
+	mediaTokens *mediaTokenStore
+	botInstalls map[string]*botInstallSession
+	botRuntime  *desktopBotRuntime
+	// botBridge gives the embedded bot gateway a god view over desktop
+	// sessions (/desktop commands). Set once in NewApp before any tab exists,
+	// read-only afterwards, so tabEventSink.Emit reads it without a lock.
+	botBridge *botBridgeHub
+
+	metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
+
+	notificationSenderOnce sync.Once
+	notificationSender     notify.Sender
+
+	runtimeEvents asyncRuntimeEmitter
+
+	// promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
+	// stores session order and per-session parsed entries only after that session is
+	// reached by ↑ navigation. See ScanPromptHistory.
+	promptHistoryMu   sync.Mutex
+	promptHistoryTape *promptHistoryTape
+
+	skillRootsMu    sync.Mutex
+	skillRootsCache skillRootsCache
+
+	heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
+}
+
+type skillRootsCache struct {
+	key   string
+	at    time.Time
+	roots []SkillRootView
+}
+
+// mediaTokenEntry holds metadata for a workspace media file served via temporary URL.
+type mediaTokenEntry struct {
+	absPath   string
+	filename  string
+	mime      string
+	kind      string
+	size      int64
+	modTime   time.Time
+	createdAt time.Time
+	expiresAt time.Time
+}
+
+// mediaTokenStore manages temporary tokens that grant access to workspace files
+// through the AssetServer middleware. Tokens expire after a fixed TTL and are
+// capped at a maximum count; creating a new token evicts the oldest entry when
+// the store is full.
+type mediaTokenStore struct {
+	mu    sync.Mutex
+	byTok map[string]*mediaTokenEntry
+	order []string // oldest first
+	maxN  int
+	ttl   time.Duration
+}
+
+const mediaTokenMax = 256
+
+func newMediaTokenStore() *mediaTokenStore {
+	return &mediaTokenStore{
+		byTok: map[string]*mediaTokenEntry{},
+		maxN:  mediaTokenMax,
+		ttl:   10 * time.Minute,
+	}
+}
+
+func (s *mediaTokenStore) cleanupLocked() {
+	now := time.Now()
+	for len(s.order) > 0 {
+		tok := s.order[0]
+		e := s.byTok[tok]
+		if e == nil {
+			s.order = s.order[1:]
+			continue
+		}
+		if !now.Before(e.expiresAt) {
+			delete(s.byTok, tok)
+			s.order = s.order[1:]
+			continue
+		}
+		break
+	}
+	for len(s.order) > s.maxN {
+		oldest := s.order[0]
+		delete(s.byTok, oldest)
+		s.order = s.order[1:]
+	}
+}
+
+func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.cleanupLocked()
+
+	tok := make([]byte, 16)
+	if _, err := rand.Read(tok); err != nil {
+		panic("crypto/rand.Read failed: " + err.Error())
+	}
+	token := hex.EncodeToString(tok)
+
+	now := time.Now()
+	s.byTok[token] = &mediaTokenEntry{
+		absPath:   absPath,
+		filename:  filename,
+		mime:      mime,
+		kind:      kind,
+		size:      size,
+		modTime:   modTime,
+		createdAt: now,
+		expiresAt: now.Add(s.ttl),
+	}
+	s.order = append(s.order, token)
+
+	// Trim oldest if the new token pushed us over the limit.
+	for len(s.order) > s.maxN {
+		oldest := s.order[0]
+		delete(s.byTok, oldest)
+		s.order = s.order[1:]
+	}
+
+	return token
+}
+
+func (s *mediaTokenStore) get(token string) *mediaTokenEntry {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	e := s.byTok[token]
+	if e == nil {
+		return nil
+	}
+	if time.Now().After(e.expiresAt) {
+		delete(s.byTok, token)
+		return nil
+	}
+	return e
+}
+
+func (a *App) ensureMediaTokenStore() *mediaTokenStore {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	if a.mediaTokens == nil {
+		a.mediaTokens = newMediaTokenStore()
+	}
+	return a.mediaTokens
+}
+
+// jsProfilingMiddleware opts every asset response into the JS Self-Profiling
+// document policy so the frontend performance monitor can attach sampled stacks
+// to long-task reports. Chromium WebViews (WebView2) honor it; WebKit ignores
+// both the header and the API, so the frontend degrades to unattributed reports.
+func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Document-Policy", "js-profiling")
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+// workspaceMediaMiddleware returns an HTTP middleware that intercepts
+// /__reasonix_workspace_media/{token}/{filename} requests and serves the
+// corresponding workspace file. All other paths pass through to the Wails
+// default asset handler unchanged.
+func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			prefix := "/__reasonix_workspace_media/"
+			if !strings.HasPrefix(r.URL.Path, prefix) {
+				next.ServeHTTP(w, r)
+				return
+			}
+
+			if r.Method != http.MethodGet && r.Method != http.MethodHead {
+				w.WriteHeader(http.StatusMethodNotAllowed)
+				return
+			}
+
+			rest := strings.TrimPrefix(r.URL.Path, prefix)
+			parts := strings.SplitN(rest, "/", 2)
+			if len(parts) == 0 || parts[0] == "" {
+				http.NotFound(w, r)
+				return
+			}
+			token := parts[0]
+
+			entry := a.ensureMediaTokenStore().get(token)
+			if entry == nil {
+				http.NotFound(w, r)
+				return
+			}
+
+			f, err := os.Open(entry.absPath)
+			if err != nil {
+				http.NotFound(w, r)
+				return
+			}
+			defer f.Close()
+
+			w.Header().Set("Content-Type", entry.mime)
+			w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.filename}))
+			w.Header().Set("X-Content-Type-Options", "nosniff")
+			w.Header().Set("Cache-Control", "private, max-age=600")
+			http.ServeContent(w, r, entry.filename, entry.modTime, f)
+		})
+	}
+}
+
+// NewApp constructs the bound object. Tabs are restored in startup from the
+// last session's desktop-tabs.json.
+func NewApp() *App {
+	a := &App{
+		tabs:             map[string]*WorkspaceTab{},
+		detachedSessions: map[string]*WorkspaceTab{},
+		mediaTokens:      newMediaTokenStore(),
+		botInstalls:      map[string]*botInstallSession{},
+		botRuntime:       newDesktopBotRuntime(),
+	}
+	a.botBridge = a.newBotBridge()
+	return a
+}
+
+func (a *App) bootContext() context.Context {
+	if a.ctx != nil {
+		return a.ctx
+	}
+	return context.Background()
+}
+
+// Platform exposes the native OS to the frontend so chrome/layout affordances can
+// stay platform-scoped instead of relying on browser user-agent guesses.
+func (a *App) Platform() string {
+	return goruntime.GOOS
+}
+
+// startup runs once the webview process is up, before the frontend can issue any
+// bound call. It captures the Wails context (needed for EventsEmit), then kicks
+// off the initialization in a background goroutine so the webview loads immediately.
+func (a *App) startup(ctx context.Context) {
+	a.ctx = ctx
+	installSystemQuitHook()
+	a.startTray()
+	a.enableDeferredRebuildRetry()
+
+	if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
+		a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
+		a.recordSettingsMetricsSnapshot(cfg)
+	}
+	a.startMainThreadWatchdog()
+
+	a.heartbeat = newHeartbeatEngine(a)
+	a.heartbeat.Start()
+
+	a.mu.Lock()
+	a.tabsRestored = make(chan struct{})
+	a.mu.Unlock()
+	go a.restoreOrBuildTabs()
+	a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
+	a.goSafe("sendStartupPing", a.sendStartupPing)
+	a.goSafe("flushMetrics", a.flushMetrics)
+	a.goSafe("flushPendingCrash", a.flushPendingCrash)
+	// After restoreOrBuildTabs is launched: the GC's first sweep waits on
+	// tabsRestored so it never observes the pre-restore empty tab map.
+	a.startRecoveryGC()
+}
+
+func (a *App) beforeClose(ctx context.Context) bool {
+	if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
+		return false
+	}
+	cfg, _, err := a.loadDesktopUserConfigForView()
+	if err != nil {
+		cfg = config.LoadForEdit(config.UserConfigPath())
+	}
+	if cfg.DesktopCloseBehavior() == "background" {
+		if !a.backgroundCloseHasRestorePath() {
+			return false
+		}
+		a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx))
+		a.saveWindowStateSync()
+		a.snapshotAllTabs()
+		hideForBackground(ctx)
+		return true
+	}
+	return false
+}
+
+const backgroundCloseTrayReadyTimeout = 500 * time.Millisecond
+
+func (a *App) backgroundCloseHasRestorePath() bool {
+	if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
+		return backgroundCloseHasRestorePathFor(goruntime.GOOS, false, false)
+	}
+	if !a.startTray() {
+		return false
+	}
+	return backgroundCloseHasRestorePathFor(goruntime.GOOS, true, a.waitForTrayReady(backgroundCloseTrayReadyTimeout))
+}
+
+func (a *App) waitForTrayReady(timeout time.Duration) bool {
+	if a.isTrayReady() {
+		return true
+	}
+	ready := a.trayReadySignal()
+	if ready == nil {
+		return false
+	}
+	if timeout <= 0 {
+		select {
+		case <-ready:
+			return a.isTrayReady()
+		default:
+			return false
+		}
+	}
+	timer := time.NewTimer(timeout)
+	defer timer.Stop()
+	select {
+	case <-ready:
+		return a.isTrayReady()
+	case <-timer.C:
+		return a.isTrayReady()
+	}
+}
+
+func (a *App) isTrayReady() bool {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	return a.trayReady
+}
+
+func (a *App) trayReadySignal() <-chan struct{} {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if a.tray == nil {
+		return nil
+	}
+	return a.tray.ready
+}
+
+// markTabsRestored closes the tabsRestored gate exactly once. Safe when the
+// channel was never created (tests that drive App without startup).
+func (a *App) markTabsRestored() {
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	if a.tabsRestored == nil {
+		return
+	}
+	select {
+	case <-a.tabsRestored:
+	default:
+		close(a.tabsRestored)
+	}
+}
+
+// tabsRestoredSignal returns a channel closed once tab restore has completed.
+// When startup never armed the gate (tests), it reports already-restored.
+func (a *App) tabsRestoredSignal() <-chan struct{} {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if a.tabsRestored == nil {
+		closed := make(chan struct{})
+		close(closed)
+		return closed
+	}
+	return a.tabsRestored
+}
+
+func (a *App) showMainWindow() {
+	if a.ctx != nil {
+		showFromBackground(a.ctx, a.backgroundMaximised.Swap(false))
+		a.kickDeferredRebuildRetry()
+	}
+}
+
+func (a *App) secondInstanceLaunch() {
+	a.showMainWindow()
+}
+
+func (a *App) quitApp() {
+	if a.ctx == nil {
+		return
+	}
+	a.forceQuit.Store(true)
+	runtime.Quit(a.ctx)
+}
+
+func hideForBackground(ctx context.Context) {
+	if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
+		runtime.Hide(ctx)
+		return
+	}
+	runtime.WindowHide(ctx)
+}
+
+func showFromBackground(ctx context.Context, wasMaximised bool) {
+	if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
+		runtime.Show(ctx)
+	}
+	plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)
+	if plan.maximiseBeforeShow {
+		runtime.WindowMaximise(ctx)
+	}
+	runtime.WindowShow(ctx)
+	if plan.unminimiseAfterShow {
+		runtime.WindowUnminimise(ctx)
+	}
+}
+
+func backgroundCloseUsesApplicationHide(goos string) bool {
+	return goos == "darwin"
+}
+
+func backgroundCloseHasRestorePathFor(goos string, trayStarted, trayReady bool) bool {
+	return backgroundCloseUsesApplicationHide(goos) || (trayStarted && trayReady)
+}
+
+type backgroundRestorePlan struct {
+	maximiseBeforeShow  bool
+	unminimiseAfterShow bool
+}
+
+func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
+	if backgroundRestoreShouldMaximise(goos, wasMaximised) {
+		return backgroundRestorePlan{maximiseBeforeShow: true}
+	}
+	return backgroundRestorePlan{unminimiseAfterShow: true}
+}
+
+func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
+	return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
+}
+
+// restoreOrBuildTabs restores the tabs from the last session, or creates a
+// default Global tab on first launch.
+func (a *App) restoreOrBuildTabs() {
+	defer a.recoverToPending("restoreOrBuildTabs")
+	// Unblock startup work gated on the restore (recovery GC) no matter how
+	// this returns — including the recover path above.
+	defer a.markTabsRestored()
+	// Reap any orphaned codegraph processes from a previous crash or older
+	// version that leaked them, so they don't accumulate across restarts.
+	a.reapOrphanCodeGraph()
+	ctx := a.ctx
+	ensureWorkspace()
+
+	// Run legacy config migration before the first config load so the
+	// freshly written config (including the user's default_model) is
+	// picked up by Load instead of falling back to built-in defaults.
+	_, _ = config.MigrateLegacyIfNeeded()
+	f := loadTabsFile()
+	_, _ = recoverLegacyProjectSidebarRoots(f)
+	_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
+	_, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
+
+	// Load i18n from the first available config.
+	// Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),
+	// so the user's language choice in desktop settings takes effect.
+	startupCfg, cfgErr := config.Load()
+	if cfgErr == nil {
+		cfg := startupCfg
+		lang := cfg.DesktopLanguage()
+		if lang == "" {
+			lang = cfg.Language
+		}
+		i18n.DetectLanguage(lang)
+	}
+	if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
+		f = singleSurfaceTabsFile(f)
+	}
+
+	if len(f.Tabs) > 0 {
+		toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
+		for _, entry := range f.Tabs {
+			a.mu.Lock()
+			id := a.restoredTabIDLocked(entry.ID)
+			a.mu.Unlock()
+
+			var tab *WorkspaceTab
+			if entry.Scope == "project" {
+				tab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)
+			} else {
+				tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id)
+			}
+			tab.model = entry.Model
+			tab.effort = cloneStringPtr(entry.Effort)
+			tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
+			tab.mode = persistedTabMode(entry.Mode)
+			// Validate the persisted goal against the session's goal-state
+			// sidecar: a typed /new or /clear rotates the session through the
+			// controller without passing App.NewSession/ClearSession, so
+			// entry.Goal can be stale. Session rotation writes a stopped
+			// goal-state onto the fresh path; reading it here stops a restart
+			// from re-seeding the cleared goal into the rotated session. A
+			// session without a sidecar keeps the persisted goal (legacy).
+			tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
+			tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
+			if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
+				tab.toolApprovalMode = control.ToolApprovalYolo
+			}
+			tab.SessionPath = strings.TrimSpace(entry.SessionPath)
+			tab.ReadOnly = entry.ReadOnly
+			tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
+			a.mu.Lock()
+			a.tabs[tab.ID] = tab
+			a.tabOrder = append(a.tabOrder, tab.ID)
+			a.mu.Unlock()
+			toBuild = append(toBuild, tab)
+		}
+		a.mu.Lock()
+		if _, ok := a.tabs[f.ActiveTab]; ok {
+			a.activeTabID = f.ActiveTab
+		} else {
+			ordered := a.orderedTabIDsLocked()
+			if len(ordered) > 0 {
+				a.activeTabID = ordered[0]
+			}
+		}
+		a.saveTabsLocked()
+		a.mu.Unlock()
+		for _, tab := range toBuild {
+			a.startTabControllerBuild(tab)
+		}
+		return
+	}
+
+	// First launch: create a default Global tab.
+	tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "")
+	tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
+	tab.TopicTitle = "Global"
+	a.mu.Lock()
+	a.tabs[tab.ID] = tab
+	a.tabOrder = append(a.tabOrder, tab.ID)
+	a.activeTabID = tab.ID
+	a.mu.Unlock()
+	a.startTabControllerBuild(tab)
+}
+
+func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {
+	return a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())
+}
+
+func desktopNewSessionDefaults() (string, string) {
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	return strings.TrimSpace(cfg.DefaultModel), normalizeToolApprovalMode(cfg.DesktopDefaultToolApprovalMode())
+}
+
+func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {
+	model, toolApprovalMode := desktopNewSessionDefaults()
+	return &WorkspaceTab{
+		ID:               id,
+		Scope:            scope,
+		WorkspaceRoot:    workspaceRoot,
+		TopicID:          topicID,
+		TopicTitle:       topicTitleForTab(scope, workspaceRoot, topicID),
+		model:            model,
+		tokenMode:        boot.TokenModeFull,
+		mode:             tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
+		toolApprovalMode: toolApprovalMode,
+		disabledMCP:      map[string]ServerView{},
+	}
+}
+
+func (a *App) snapshotAllTabs() {
+	a.mu.RLock()
+	tabs := a.runtimeTabsLocked()
+	a.mu.RUnlock()
+	for _, t := range tabs {
+		if err := a.snapshotTab(t); err != nil {
+			slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
+		}
+	}
+}
+
+// shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
+func (a *App) shutdown(context.Context) {
+	a.stopDeferredRebuildRetry()
+	a.stopMainThreadWatchdog()
+	if a.heartbeat != nil {
+		a.heartbeat.Stop()
+	}
+	a.stopBotRuntime()
+	a.stopTray()
+	// Save window geometry synchronously from Go so it's persisted even if the
+	// frontend's beforeunload promise hasn't resolved yet.
+	a.saveWindowStateSync()
+	// Close every shared plugin host on exit, even if a tab cleanup panics.
+	defer a.closeAllSharedHosts()
+
+	a.mu.RLock()
+	tabs := a.runtimeTabsLocked()
+	type shutdownItem struct {
+		tab  *WorkspaceTab
+		ctrl control.SessionAPI
+	}
+	items := make([]shutdownItem, 0, len(tabs))
+	for _, t := range tabs {
+		if t.Ctrl != nil {
+			items = append(items, shutdownItem{tab: t, ctrl: t.Ctrl})
+		}
+	}
+	a.mu.RUnlock()
+	for _, it := range items {
+		if err := a.snapshotTab(it.tab); err != nil {
+			slog.Warn("desktop: shutdown snapshot failed", "tab", it.tab.ID, "err", err)
+		}
+		it.ctrl.Close()
+		it.tab.releaseSessionLease()
+	}
+}
+
+// domReady is called (via OnDomReady) after the webview finishes loading its DOM
+// but before the window is shown (StartHidden). It restores the saved window
+// position and size, then calls WindowShow so the user never sees the default
+// size/position flash.
+func (a *App) domReady(_ context.Context) {
+	state, ok := loadWindowState()
+	if ok {
+		// Validate saved position against current screens. Wails v2 doesn't
+		// expose per-screen origin (x,y offsets) so we can only do a basic
+		// sanity check: ensure the window origin falls within a generous
+		// estimate of the screen area. If the user unplugged an external
+		// display, negative or out-of-bounds coordinates are caught here.
+		valid := state.X >= 0 && state.Y >= 0
+		if valid {
+			screens, err := runtime.ScreenGetAll(a.ctx)
+			if err == nil && len(screens) > 0 {
+				maxW, maxH := 0, 0
+				for _, sc := range screens {
+					if sc.Size.Width > maxW {
+						maxW = sc.Size.Width
+					}
+					if sc.Size.Height > maxH {
+						maxH = sc.Size.Height
+					}
+				}
+				if state.X > maxW*2 || state.Y > maxH*2 {
+					valid = false
+				}
+			}
+		}
+		if valid {
+			runtime.WindowSetPosition(a.ctx, state.X, state.Y)
+		} else {
+			runtime.WindowCenter(a.ctx)
+		}
+	} else {
+		runtime.WindowCenter(a.ctx)
+	}
+
+	if ok && state.Maximised {
+		runtime.WindowMaximise(a.ctx)
+	}
+
+	runtime.WindowShow(a.ctx)
+}
+
+// --- bound command surface (frontend → controller) ---
+// Each method guards on a nil controller so a pre-startup or failed-build call is
+// a no-op, never a panic.
+
+// Submit runs raw user input as a turn; slash commands and @-references are
+// resolved by the controller. Output arrives asynchronously on eventChannel.
+func (a *App) Submit(input string) error {
+	return a.SubmitToTab("", input)
+}
+
+func (a *App) SubmitToTab(tabID, input string) error {
+	return a.submitToTab(tabID, input, false)
+}
+
+// beginTabTurn locks the tab's foreground-turn admission gate and reserves the
+// event sink until TurnDone has completed all of its fan-out. The caller must
+// call finishTabTurnStart exactly once after invoking the controller.
+func (a *App) beginTabTurn(tabID string, reclaim bool) (*WorkspaceTab, control.SessionAPI, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return nil, nil, readOnlyChannelErr()
+	}
+	if tab == nil || ctrl == nil {
+		return nil, nil, a.workspaceNotReadyErr(tab)
+	}
+	tab.turnStartMu.Lock()
+	if a.tabIsReadOnly(tab) {
+		tab.turnStartMu.Unlock()
+		return nil, nil, readOnlyChannelErr()
+	}
+	if reclaim && a.botBridge != nil {
+		a.botBridge.reclaimFromDesktop(tab.ID)
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		tab.turnStartMu.Unlock()
+		return nil, nil, a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		tab.turnStartMu.Unlock()
+		return nil, nil, err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		tab.turnStartMu.Unlock()
+		return nil, nil, a.workspaceNotReadyErr(tab)
+	}
+	if ctrl.RuntimeStatus().Running || (tab.sink != nil && !tab.sink.tryBeginTurn()) {
+		tab.turnStartMu.Unlock()
+		return nil, nil, control.ErrTurnRunning
+	}
+	return tab, ctrl, nil
+}
+
+func (a *App) finishTabTurnStart(tab *WorkspaceTab, ctrl control.SessionAPI) bool {
+	started := ctrl != nil && ctrl.RuntimeStatus().Running
+	if !started && tab != nil && tab.sink != nil {
+		tab.sink.cancelTurnStart()
+	}
+	if tab != nil {
+		tab.turnStartMu.Unlock()
+	}
+	return started
+}
+
+// submitToTab is the shared submit body. fromBridge marks submissions driven
+// by the IM takeover bridge; local (frontend) submissions on a taken-over tab
+// reclaim remote control first — typing locally is the grab-back gesture.
+func (a *App) submitToTab(tabID, input string, fromBridge bool) error {
+	trimmed := strings.TrimSpace(input)
+	if trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") {
+		tab, _ := a.tabAndCtrlByID(tabID)
+		if a.tabIsReadOnly(tab) {
+			return readOnlyChannelErr()
+		}
+		if tab == nil {
+			return a.workspaceNotReadyErr(tab)
+		}
+		tab.turnStartMu.Lock()
+		defer tab.turnStartMu.Unlock()
+		if !fromBridge && a.botBridge != nil {
+			a.botBridge.reclaimFromDesktop(tab.ID)
+		}
+		a.runEffortCommandForTab(tabID, trimmed)
+		return nil
+	}
+	tab, ctrl, err := a.beginTabTurn(tabID, !fromBridge)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitDisplay(input, input)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool {
+	tab, ctrl, err := a.beginTabTurn(tabID, false)
+	if err != nil {
+		return false
+	}
+	var generation uint64
+	if forwarder != nil {
+		generation = tab.sink.SetBotSink(forwarder)
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitUserTurn(input, input)
+	started := a.finishTabTurnStart(tab, ctrl)
+	if !started && forwarder != nil {
+		tab.sink.clearBotSink(generation)
+	}
+	return started
+}
+
+// RunShell executes a shell command directly (bypassing the model) and streams
+// output as events on eventChannel.
+func (a *App) RunShell(command string) error {
+	return a.RunShellForTab("", command)
+}
+
+func (a *App) RunShellForTab(tabID, command string) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.RunShell(command)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+// SubmitDisplay runs input as a turn while recording a shorter UI-only display
+// string for the saved desktop transcript. The model still receives input.
+func (a *App) SubmitDisplay(display, input string) error {
+	return a.SubmitDisplayToTab("", display, input)
+}
+
+func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitDisplay(display, input)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+// InvocationRequest is the Wails-bound form of a composer invocation entity.
+type InvocationRequest struct {
+	Name   string `json:"name"`
+	Kind   string `json:"kind"`
+	Offset int    `json:"offset"`
+}
+
+func (a *App) SubmitInvocationsToTab(tabID, display, input string, invocations []InvocationRequest) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	requests := make([]control.InvocationRequest, 0, len(invocations))
+	for _, invocation := range invocations {
+		requests = append(requests, control.InvocationRequest{
+			Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset,
+		})
+	}
+	ctrl.SubmitInvocationDisplay(display, input, requests)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, true)
+	if err != nil {
+		return err
+	}
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitEditedDisplay(display, input, original)
+	a.finishTabTurnStart(tab, ctrl)
+	return nil
+}
+
+func (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {
+	if ctrl == nil {
+		return
+	}
+	ctrl.SetDisplayRecorder(func(content, display string) {
+		dir := ctrl.SessionDir()
+		if dir == "" {
+			dir = config.SessionDir()
+		}
+		_ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)
+	})
+}
+
+// Cancel aborts the in-flight turn.
+func (a *App) Cancel() {
+	a.CancelTab("")
+}
+
+func (a *App) CancelTab(tabID string) {
+	if ctrl := a.ctrlByTabID(tabID); ctrl != nil {
+		ctrl.Cancel()
+	}
+}
+
+// Steer sends mid-turn guidance to the agent without interrupting the in-flight request.
+func (a *App) Steer(text string) error {
+	return a.SteerForTab("", text)
+}
+
+// SteerForTab sends mid-turn guidance to a specific tab's agent.
+func (a *App) SteerForTab(tabID, text string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	ctrl.Steer(text)
+	return nil
+}
+
+func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.RUnlock()
+		return nil, nil
+	}
+	ctrl := tab.Ctrl
+	retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
+	a.mu.RUnlock()
+	if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
+		a.mu.RLock()
+		defer a.mu.RUnlock()
+		if a.tabs[tab.ID] != tab {
+			return nil, nil
+		}
+		return tab, tab.Ctrl
+	}
+	return tab, ctrl
+}
+
+// activeTabAndCtrl snapshots the active tab and its controller in one locked
+// read, so callers never do a check-then-use on tab.Ctrl after the lock is
+// released (a rebuild can swap the controller in between).
+func (a *App) activeTabAndCtrl() (*WorkspaceTab, control.SessionAPI) {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	tab := a.activeTabLocked()
+	if tab == nil {
+		return nil, nil
+	}
+	return tab, tab.Ctrl
+}
+
+func (a *App) controllerForTab(tab *WorkspaceTab) control.SessionAPI {
+	if tab == nil {
+		return nil
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if tab.ID != "" && a.tabs[tab.ID] != tab {
+		return nil
+	}
+	return tab.Ctrl
+}
+
+// currentSessionPathFor is the locked form of tab.currentSessionPath: it
+// snapshots Ctrl/SessionPath under a.mu, then queries the controller off-lock.
+// Use it on paths that do not otherwise hold a.mu.
+func (a *App) currentSessionPathFor(tab *WorkspaceTab) string {
+	if tab == nil {
+		return ""
+	}
+	a.mu.RLock()
+	ctrl := tab.Ctrl
+	fallback := strings.TrimSpace(tab.SessionPath)
+	a.mu.RUnlock()
+	if ctrl != nil {
+		if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
+			return path
+		}
+	}
+	return fallback
+}
+
+// sessionDirForSnapshot mirrors tabSessionDir for callers that hold a
+// tabRuntimeSnapshot instead of reading the live tab.
+func sessionDirForSnapshot(s tabRuntimeSnapshot) string {
+	if s.workspaceRoot != "" {
+		return desktopSessionDir(s.workspaceRoot)
+	}
+	if s.ctrl != nil {
+		if dir := s.ctrl.SessionDir(); dir != "" {
+			return dir
+		}
+	}
+	return desktopSessionDir("")
+}
+
+func readOnlyChannelErr() error {
+	return fmt.Errorf("channel session is read-only")
+}
+
+func (a *App) snapshotTab(tab *WorkspaceTab) error {
+	if tab == nil {
+		return nil
+	}
+	a.mu.RLock()
+	readOnly := tab.ReadOnly
+	ctrl := tab.Ctrl
+	a.mu.RUnlock()
+	if readOnly || ctrl == nil {
+		return nil
+	}
+	return ctrl.Snapshot()
+}
+
+func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
+	if err := a.snapshotTab(tab); err != nil {
+		a.reportTabSnapshotError(tab, action, err)
+		if strings.TrimSpace(action) == "" {
+			return fmt.Errorf("save current session: %w", err)
+		}
+		return fmt.Errorf("save current session before %s: %w", action, err)
+	}
+	return nil
+}
+
+func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
+	if err == nil {
+		return
+	}
+	tabID := ""
+	if tab != nil {
+		tabID = tab.ID
+	}
+	slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
+	if tab == nil || tab.sink == nil {
+		return
+	}
+	// Autosave fires once per turn; on a persistently failing disk that would
+	// stream a chat warning after every turn. Rate-limit the user-facing
+	// notice per tab (the slog line above always records every failure). Saves
+	// triggered by an explicit action are one-shot and always surface.
+	if action == "autosave" {
+		tab.saveMu.Lock()
+		now := time.Now()
+		if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
+			tab.saveMu.Unlock()
+			return
+		}
+		tab.lastAutosaveWarnAt = now
+		tab.saveMu.Unlock()
+	}
+	prefix := "Session autosave failed"
+	if strings.TrimSpace(action) != "" && action != "autosave" {
+		prefix = "Session save failed before " + action
+	}
+	tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
+}
+
+func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
+	if tab == nil {
+		return ""
+	}
+	path, _ := a.reconcileTabWithPinnedSessionMeta(tab)
+	if ctrl := a.controllerForTab(tab); path == "" && ctrl != nil {
+		path = ctrl.SessionPath()
+	}
+	return path
+}
+
+func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
+	if tab == nil {
+		return nil
+	}
+	tab.reconcileMu.Lock()
+	defer tab.reconcileMu.Unlock()
+
+	a.mu.RLock()
+	current := a.tabs[tab.ID]
+	ctrl := tab.Ctrl
+	readOnly := tab.ReadOnly
+	a.mu.RUnlock()
+	if current != tab || ctrl == nil || readOnly {
+		return nil
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return nil
+	}
+	path, hasBinding := a.reconcileTabWithPinnedSessionMeta(tab)
+	desiredRoot := strings.TrimSpace(tab.WorkspaceRoot)
+	ctrlRoot, rootOK := safeControllerWorkspaceRoot(ctrl)
+	ctrlDir, dirOK := safeControllerSessionDir(ctrl)
+	if !rootOK || !dirOK {
+		return nil
+	}
+	if !hasBinding {
+		if desiredRoot == "" || strings.TrimSpace(ctrlRoot) == "" || sameDesktopPath(ctrlRoot, desiredRoot) {
+			return nil
+		}
+	}
+	desiredDir := tabSessionDir(tab)
+	rootMatches := desiredRoot == "" || sameDesktopPath(ctrlRoot, desiredRoot)
+	dirMatches := desiredDir == "" || sameDesktopPath(ctrlDir, desiredDir)
+	if !dirMatches && path != "" {
+		if validPath, _, err := validateSessionPath(ctrlDir, path); err == nil && sessionRuntimeKey(validPath) == sessionRuntimeKey(path) {
+			dirMatches = true
+		}
+	}
+	if strings.TrimSpace(ctrlRoot) == "" && dirMatches {
+		rootMatches = true
+	}
+	if tab.Scope == "global" {
+		if strings.TrimSpace(ctrlRoot) == "" {
+			rootMatches = true
+		}
+		if sameDesktopPath(ctrlDir, config.SessionDir()) || sameDesktopPath(ctrlDir, desktopSessionDir(globalWorkspaceRoot())) {
+			dirMatches = true
+		}
+	}
+	sessionMatches := path == "" || sessionRuntimeKey(ctrl.SessionPath()) == sessionRuntimeKey(path)
+	if rootMatches && dirMatches && sessionMatches {
+		return nil
+	}
+	if err := ctrl.Snapshot(); err != nil {
+		return err
+	}
+	ctrl.Close()
+
+	a.mu.Lock()
+	var hostKey string
+	if current := a.tabs[tab.ID]; current == tab {
+		tab.Ctrl = nil
+		tab.Ready = false
+		clearTabStartupError(tab)
+		tab.ActivityStatus = ""
+		if tab.sink == nil {
+			tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+		}
+		hostKey = takeTabSharedHostKey(tab)
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+	if hostKey != "" {
+		a.releaseSharedHost(hostKey)
+	}
+
+	a.buildTabController(tab)
+	if tab.Ctrl == nil {
+		if tab.StartupErr != "" {
+			return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr)
+		}
+		return fmt.Errorf("workspace failed to restart with corrected root")
+	}
+	return nil
+}
+
+func safeControllerWorkspaceRoot(ctrl control.SessionAPI) (root string, ok bool) {
+	if ctrl == nil {
+		return "", false
+	}
+	defer func() {
+		if recover() != nil {
+			root = ""
+			ok = false
+		}
+	}()
+	return ctrl.WorkspaceRoot(), true
+}
+
+func safeControllerSessionDir(ctrl control.SessionAPI) (dir string, ok bool) {
+	if ctrl == nil {
+		return "", false
+	}
+	defer func() {
+		if recover() != nil {
+			dir = ""
+			ok = false
+		}
+	}()
+	return ctrl.SessionDir(), true
+}
+
+// Approve answers a pending approval_request by ID: allow runs the call, session
+// also remembers the grant for the rest of the session.
+func (a *App) Approve(id string, allow, session, persist bool) {
+	ctrl := a.ctrlByTabID("")
+	if ctrl != nil {
+		ctrl.Approve(id, allow, session, persist)
+	}
+}
+
+// ApproveTab is like Approve but scoped to a specific tab.
+func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl != nil {
+		ctrl.Approve(id, allow, session, persist)
+	}
+}
+
+// ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask
+// prompt that is currently blocking its run loop. The frontend calls this once
+// its event subscription is live (on load/reconnect) so a session that was
+// already awaiting confirmation rebuilds its modal instead of showing a
+// "waiting" status with no way to answer — and no way to stop.
+func (a *App) ReplayPendingPrompts() {
+	a.mu.RLock()
+	tabs := a.runtimeTabsLocked()
+	ctrls := make([]control.SessionAPI, 0, len(tabs))
+	for _, t := range tabs {
+		if t.Ctrl != nil {
+			ctrls = append(ctrls, t.Ctrl)
+		}
+	}
+	a.mu.RUnlock()
+	for _, ctrl := range ctrls {
+		ctrl.ReplayPendingPrompts()
+	}
+}
+
+// SetPlanMode toggles the read-only plan axis while preserving the current
+// tool-auto-approval axis.
+func (a *App) SetPlanMode(on bool) {
+	a.setPlanModeForTab("", on)
+}
+
+func (a *App) setPlanModeForTab(tabID string, on bool) {
+	current := a.currentModeForTab(tabID)
+	a.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current)))
+}
+
+// SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" |
+// anything else =
+// normal) in one call, so a turn submitted right after the switch can't race a
+// half-applied plan/tool-auto-approval pair.
+func (a *App) SetMode(mode string) {
+	a.SetModeForTab("", mode)
+}
+
+func (a *App) SetModeForTab(tabID, mode string) {
+	normalized := normalizeTabMode(mode)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	tab.mode = normalized
+	tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)
+	if tabModeHasAutoApproveTools(normalized) {
+		tab.toolApprovalMode = control.ToolApprovalYolo
+	} else if tab.toolApprovalMode == control.ToolApprovalYolo {
+		tab.toolApprovalMode = control.ToolApprovalAsk
+	}
+	ctrl := tab.Ctrl
+	approvalMode := tab.toolApprovalMode
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	applyTabModeToController(ctrl, normalized)
+	applyTabToolApprovalModeToController(ctrl, approvalMode)
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func applyTabModeToController(ctrl control.SessionAPI, mode string) {
+	if ctrl == nil {
+		return
+	}
+	switch normalizeTabMode(mode) {
+	case "plan":
+		ctrl.SetMode(true, false)
+	case "yolo":
+		ctrl.SetMode(false, true)
+	case "plan-yolo":
+		ctrl.SetMode(true, true)
+	default:
+		ctrl.SetMode(false, false)
+	}
+}
+
+func applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) {
+	if ctrl == nil {
+		return
+	}
+	ctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode))
+}
+
+func (a *App) currentModeForTab(tabID string) string {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	mode := "normal"
+	if tab != nil {
+		mode = currentTabMode(tab)
+	}
+	a.mu.RUnlock()
+	return mode
+}
+
+func normalizeCollaborationMode(mode string) string {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "plan":
+		return "plan"
+	case "goal":
+		return "goal"
+	default:
+		return "normal"
+	}
+}
+
+func (a *App) SetCollaborationMode(mode string) {
+	a.SetCollaborationModeForTab("", mode)
+}
+
+func (a *App) SetCollaborationModeForTab(tabID, mode string) {
+	mode = normalizeCollaborationMode(mode)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	approvalMode := currentTabToolApprovalMode(tab)
+	switch mode {
+	case "plan":
+		tab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)
+		tab.goal = ""
+	case "goal":
+		tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
+	default:
+		tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
+		tab.goal = ""
+	}
+	ctrl := tab.Ctrl
+	goal := tab.goal
+	plan := tabModeHasPlan(tab.mode)
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	if ctrl != nil {
+		ctrl.SetPlanMode(plan)
+		ctrl.SetGoal(goal)
+	}
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+// QuestionAnswer is the frontend's reply to one question in an ask_request.
+type QuestionAnswer struct {
+	QuestionID string   `json:"questionId"`
+	Selected   []string `json:"selected"`
+}
+
+// AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the
+// user's selections per question.
+func (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {
+	a.AnswerQuestionForTab("", id, answers)
+}
+
+func (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return
+	}
+	out := make([]event.AskAnswer, len(answers))
+	for i, an := range answers {
+		out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
+	}
+	ctrl.AnswerQuestion(id, out)
+}
+
+// Compact runs a plain compaction pass (the "compact now" button). Focus-guided
+// compaction goes through Submit("/compact ") instead.
+func (a *App) Compact() error {
+	return a.CompactForTab("")
+}
+
+// CompactForTab compacts the requested tab without depending on which tab is
+// focused when the asynchronous frontend call reaches the backend.
+func (a *App) CompactForTab(tabID string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.Compact(a.ctx, "")
+}
+
+// workspaceNotReadyErr names why a session action arrived before the tab's
+// controller existed: still starting, or failed to start. Silently returning
+// nil here swallowed the click with no feedback (#3938).
+//
+// This is the bound-method form: StartupErr is written under a.mu by the
+// build goroutine while Submit-family calls race it, so read it under the
+// lock. Callers must not hold a.mu.
+func (a *App) workspaceNotReadyErr(tab *WorkspaceTab) error {
+	startupErr := ""
+	if tab != nil {
+		a.mu.RLock()
+		startupErr = tab.StartupErr
+		a.mu.RUnlock()
+	}
+	if strings.TrimSpace(startupErr) != "" {
+		return fmt.Errorf("workspace failed to start: %s", startupErr)
+	}
+	return fmt.Errorf("workspace is still starting")
+}
+
+// tabIsReadOnly reads tab.ReadOnly under a.mu; setTabReadOnly can flip it
+// concurrently with Submit-family bound calls. Callers must not hold a.mu.
+func (a *App) tabIsReadOnly(tab *WorkspaceTab) bool {
+	if tab == nil {
+		return false
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	return tab.ReadOnly
+}
+
+// NewSession snapshots the current conversation and rotates to a fresh one.
+func (a *App) NewSession() error {
+	return a.NewSessionForTab("")
+}
+
+// NewSessionForTab snapshots and rotates the requested tab regardless of which
+// tab becomes active while the Wails call is in flight.
+func (a *App) NewSessionForTab(tabID string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	// Tab is already blank — just persist and skip the new-session dance.
+	if !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {
+		a.persistTabSessionPath(tab, ctrl.SessionPath())
+		return nil
+	}
+
+	if err := ctrl.NewSession(); err != nil {
+		return err
+	}
+	// The rotated session starts with zero spend: without this reset the tab
+	// telemetry keeps the previous session's totals and the status bar 会话费用
+	// silently turns into an all-sessions running total (#5850).
+	tab.resetTelemetry(ctrl.SessionPath())
+	// Mirror the controller: NewSession cleared the active goal, and the tab's
+	// persisted copy must follow — otherwise the next rebuild/restart would
+	// re-seed the old goal into the fresh session via SetGoal(tab.goal).
+	a.clearTabGoal(tab)
+	a.assignFreshSessionTopic(tab)
+	a.persistTabSessionPath(tab, ctrl.SessionPath())
+	a.invalidatePromptHistoryCache()
+	a.emitProjectTreeChanged()
+	return nil
+}
+
+func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
+	if tab == nil {
+		return
+	}
+	topicID := newTopicID()
+	a.mu.Lock()
+	scope := tab.Scope
+	workspaceRoot := tab.WorkspaceRoot
+	tab.TopicID = topicID
+	tab.TopicTitle = defaultTopicTitle
+	if current := a.tabs[tab.ID]; current == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+	if strings.TrimSpace(scope) == "global" {
+		workspaceRoot = ""
+	} else {
+		workspaceRoot = normalizeProjectRoot(workspaceRoot)
+	}
+	// NewSession already rotated the runtime to a fresh session. If the sidebar
+	// topic index repair fails here, keep the session usable and let persisted
+	// session metadata repair the topic index later instead of surfacing a false
+	// "new session failed" error to the frontend.
+	_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
+	_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
+}
+
+func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
+	if tab == nil {
+		return
+	}
+	topicID := newTopicID()
+	a.mu.Lock()
+	if strings.TrimSpace(tab.TopicID) != "" {
+		a.mu.Unlock()
+		return
+	}
+	scope := tab.Scope
+	workspaceRoot := tab.WorkspaceRoot
+	tab.TopicID = topicID
+	tab.TopicTitle = defaultTopicTitle
+	if current := a.tabs[tab.ID]; current == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+	if strings.TrimSpace(scope) == "global" {
+		scope = "global"
+		workspaceRoot = ""
+	} else {
+		scope = "project"
+		workspaceRoot = normalizeProjectRoot(workspaceRoot)
+	}
+
+	_ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
+	_ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
+	a.persistTabSessionPath(tab, a.currentSessionPathFor(tab))
+	a.emitProjectTreeChanged()
+}
+
+func messagesHaveConversationContent(messages []provider.Message) bool {
+	for _, msg := range messages {
+		if msg.Role != provider.RoleSystem {
+			return true
+		}
+	}
+	return false
+}
+
+// ClearSession discards the current conversation and rotates to a fresh unsaved one.
+func (a *App) ClearSession() error {
+	return a.ClearSessionForTab("")
+}
+
+// ClearSessionForTab clears the requested tab regardless of later focus changes.
+func (a *App) ClearSessionForTab(tabID string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	ctrl = a.controllerForTab(tab)
+	if ctrl == nil {
+		return a.workspaceNotReadyErr(tab)
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return a.clearActiveSessionRuntime(tab, ctrl)
+	}
+	if err := ctrl.ClearSession(); err != nil {
+		return err
+	}
+	if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil {
+		// Wails bridge return: a raw lease error would carry the session path
+		// and holder id across to the frontend.
+		return userFacingSessionLeaseError("", err)
+	}
+	tab.resetTelemetry(ctrl.SessionPath())
+	// Mirror the controller: ClearSession cleared the active goal.
+	a.clearTabGoal(tab)
+	a.persistTabSessionPath(tab, ctrl.SessionPath())
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+// clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
+// cannot re-seed a goal the controller has already cleared on session rotation.
+func (a *App) clearTabGoal(tab *WorkspaceTab) {
+	if tab == nil {
+		return
+	}
+	a.mu.Lock()
+	tab.goal = ""
+	if current := a.tabs[tab.ID]; current == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
+	if tab == nil || oldCtrl == nil {
+		return fmt.Errorf("workspace is still starting")
+	}
+	// This is a build+swap of the tab's controller; serialize with the other
+	// rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings
+	// rebuild cannot interleave a second swap. Lock order:
+	// runtimeRebuildMu → sessionRemovalMu (no path acquires them in reverse).
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	// This path destroys the old session's files (removeDesktopSessionArtifacts);
+	// serialize with DeleteSession/TrashTopic/workspace removal so they never
+	// trash or restore the same files mid-clear.
+	a.sessionRemovalMu.Lock()
+	defer a.sessionRemovalMu.Unlock()
+
+	a.reconciledSessionPathForTab(tab)
+	oldPath := oldCtrl.SessionPath()
+	// Snapshot the tab profile under a.mu: bound methods write these fields
+	// under the lock while this rebuild runs off-lock.
+	snap := a.tabRuntimeSnapshot(tab)
+	oldSink := snap.sink
+	if oldSink != nil {
+		// Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
+		// derives — a raw path here would hash to a different detached id on
+		// Windows where keys are case-folded.
+		oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
+		oldSink.clearContext()
+	}
+	if oldCtrl.RuntimeStatus().Cancellable {
+		oldCtrl.Cancel()
+		if err := waitControllerStopped(oldCtrl); err != nil {
+			return err
+		}
+	}
+	destroy := oldCtrl.BeginDestroySession(oldPath)
+	destroys := []control.SessionDestroyHandle{destroy}
+	teardownTimedOut := waitDestroyHandles(destroys)
+	if teardownTimedOut {
+		if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
+			return err
+		}
+	}
+
+	newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    snap.model,
+		RequireKey:               false,
+		Sink:                     newSink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           cloneStringPtr(snap.effort),
+		TokenMode:                snap.currentTokenMode(),
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		if teardownTimedOut {
+			// The old session was already marked cleanup-pending, so finish the
+			// destroy cleanup instead of re-exposing a runtime in teardown.
+			go delayedDesktopSessionCleanup(oldPath, destroys)
+		} else {
+			finishDestroyHandles(destroys)
+		}
+		if oldSink != nil {
+			oldSink.setBinding(tab.ID, nil)
+			oldSink.setContext(a.ctx)
+		}
+		return err
+	}
+	if teardownTimedOut {
+		go delayedDesktopSessionCleanup(oldPath, destroys)
+	} else {
+		if err := removeDesktopSessionArtifacts(oldPath); err != nil {
+			finishDestroyHandles(destroys)
+			newCtrl.Close()
+			return err
+		}
+		finishDestroyHandles(destroys)
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	// Clearing the session clears the active goal too (same contract as
+	// Controller.ClearSession): the snapshot's goal belongs to the destroyed
+	// conversation and must not seed the replacement.
+	path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
+	if err := tab.ensureSessionLease(path); err != nil {
+		newCtrl.Close()
+		// Surfaces through ClearSession's Wails return; keep the holder's
+		// path/pid/writer id out of it.
+		return userFacingSessionLeaseError("", err)
+	}
+	newCtrl.SetSessionPath(path)
+
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		a.mu.Unlock()
+		// The old session is already destroyed either way; release what this
+		// clear acquired for the replaced tab (fresh controller and its
+		// lease) so neither leaks, and still finish the old runtime teardown.
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		oldCtrl.CloseAfterDestroy()
+		a.emitProjectTreeChanged()
+		return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.sink = newSink
+	tab.SessionPath = path
+	tab.Label = newCtrl.Label()
+	tab.Ready = true
+	clearTabStartupError(tab)
+	tab.goal = ""
+	// Supersede any in-flight startup build: the session it was resuming
+	// was just destroyed, and finishing later would pass the generation
+	// check and overwrite this controller.
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	// Same contract as ClearSession's non-running path: the replacement
+	// session starts with zero spend.
+	tab.resetTelemetry(path)
+	oldCtrl.CloseAfterDestroy()
+	a.emitProjectTreeChanged()
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func removeDesktopSessionArtifacts(path string) error {
+	if strings.TrimSpace(path) == "" {
+		return nil
+	}
+	guard, err := acquireSessionRemovalGuard(path)
+	if err != nil {
+		return err
+	}
+	defer guard.Release()
+	if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
+		return err
+	}
+	defer invalidateTopicSessionIndexForPath(path)
+	for _, p := range sessionOwnedArtifactPaths(path) {
+		if strings.TrimSpace(p) == "" {
+			continue
+		}
+		if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {
+			return err
+		}
+	}
+	if err := guard.RemoveSidecarsAndRelease(); err != nil {
+		return err
+	}
+	if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
+		return err
+	}
+	if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
+		return err
+	}
+	return agent.ClearCleanupPending(path)
+}
+
+// CheckpointMeta summarises one rewind point (a user turn) for the desktop.
+type CheckpointMeta struct {
+	Turn            int      `json:"turn"`
+	Prompt          string   `json:"prompt"`
+	Files           []string `json:"files"`     // stable preview of cumulative files RestoreCode would affect from this turn
+	FileCount       int      `json:"fileCount"` // full cumulative file count, including entries omitted from Files
+	FilesTruncated  bool     `json:"filesTruncated,omitempty"`
+	TurnFileCount   int      `json:"turnFileCount"` // files changed during this turn only
+	Time            int64    `json:"time"`          // unix milliseconds
+	CanCode         bool     `json:"canCode"`
+	CanConversation bool     `json:"canConversation"`
+}
+
+const checkpointFilePreviewLimit = 60
+
+// Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
+func (a *App) Checkpoints() []CheckpointMeta {
+	return a.CheckpointsForTab("")
+}
+
+func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
+	a.mu.RLock()
+	var ctrl control.SessionAPI
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return []CheckpointMeta{}
+	}
+	metas := ctrl.Checkpoints()
+	out := make([]CheckpointMeta, 0, len(metas))
+	for _, m := range metas {
+		out = append(out, CheckpointMeta{
+			Turn:            m.Turn,
+			Prompt:          m.Prompt,
+			Files:           m.Paths,
+			TurnFileCount:   len(m.Paths),
+			Time:            m.Time.UnixMilli(),
+			CanCode:         len(m.Paths) > 0,
+			CanConversation: ctrl.CheckpointHasBoundary(m.Turn),
+		})
+	}
+	// RestoreCode(turn) reverts every file touched in this turn or any later one, so
+	// a turn can rewind code even when it changed no files itself — as long as a
+	// later turn did. Propagate CanCode backwards over the oldest-first list.
+	// Also propagate the cumulative unique file count so the UI shows how many
+	// files RestoreCode would actually affect from this turn.
+	hasCodeAfter := false
+	codeFileSet := make(map[string]bool, len(metas)*2)
+	codeFilePreview := []string{}
+	for i := len(out) - 1; i >= 0; i-- {
+		if len(out[i].Files) > 0 {
+			hasCodeAfter = true
+		}
+		for _, f := range out[i].Files {
+			if codeFileSet[f] {
+				continue
+			}
+			codeFileSet[f] = true
+			codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
+		}
+		out[i].CanCode = hasCodeAfter
+		out[i].FileCount = len(codeFileSet)
+		out[i].Files = append([]string{}, codeFilePreview...)
+		out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
+	}
+	return out
+}
+
+func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
+	if limit <= 0 || path == "" {
+		return preview
+	}
+	idx := sort.SearchStrings(preview, path)
+	if idx < len(preview) && preview[idx] == path {
+		return preview
+	}
+	if len(preview) < limit {
+		preview = append(preview, "")
+		copy(preview[idx+1:], preview[idx:])
+		preview[idx] = path
+		return preview
+	}
+	if idx >= limit {
+		return preview
+	}
+	copy(preview[idx+1:], preview[idx:limit-1])
+	preview[idx] = path
+	return preview
+}
+
+// ToolResultForTab returns the full arguments and output for one tool call that
+// were elided from the frontend's in-memory items[] for memory efficiency. The
+// caller (frontend ToolCard) loads this on demand when the user expands a
+// collapsed tool card. Returns nil when the tool ID is not found.
+func (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {
+	a.mu.RLock()
+	var ctrl control.SessionAPI
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.ToolResult(toolID)
+}
+
+// Rewind restores the session to the start of turn. scope is "code",
+// "conversation", or "both" (anything else is treated as "both"). The frontend
+// re-reads History after this resolves.
+func (a *App) Rewind(turn int, scope string) error {
+	return a.RewindForTab("", turn, scope)
+}
+
+// RewindForTab rewinds the requested tab instead of resolving the active tab at
+// execution time, which may have changed after frontend confirmation.
+func (a *App) RewindForTab(tabID string, turn int, scope string) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	s := control.RewindBoth
+	switch scope {
+	case "code":
+		s = control.RewindCode
+	case "conversation":
+		s = control.RewindConversation
+	}
+	return ctrl.Rewind(turn, s)
+}
+
+// Fork branches the conversation at the start of turn into a new session tab
+// (preserving the current tab), keeping code intact, and switches to the new tab.
+func (a *App) Fork(turn int) (TabMeta, error) {
+	return a.ForkForTab("", turn)
+}
+
+// ForkForTab forks the requested source tab even if focus changes before the
+// backend begins processing the request. The fork becomes active only while the
+// source tab still owns focus, so a later tab selection remains authoritative.
+func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
+	sourceTab, ctrl := a.tabAndCtrlByID(tabID)
+	if sourceTab == nil || ctrl == nil {
+		return TabMeta{}, nil
+	}
+	if a.tabIsReadOnly(sourceTab) {
+		return TabMeta{}, readOnlyChannelErr()
+	}
+
+	if err := a.ensureTabControllerWorkspace(sourceTab); err != nil {
+		return TabMeta{}, err
+	}
+	a.mu.RLock()
+	if a.tabs[sourceTab.ID] != sourceTab || sourceTab.Ctrl == nil {
+		a.mu.RUnlock()
+		return TabMeta{}, nil
+	}
+	ctrl = sourceTab.Ctrl
+	scope := sourceTab.Scope
+	workspaceRoot := sourceTab.WorkspaceRoot
+	sourceTitle := sourceTab.TopicTitle
+	model := sourceTab.model
+	effort := cloneStringPtr(sourceTab.effort)
+	mode := currentTabMode(sourceTab)
+	toolApprovalMode := currentTabToolApprovalMode(sourceTab)
+	disabledMCP := cloneServerViewMap(sourceTab.disabledMCP)
+	mcpOrder := append([]string(nil), sourceTab.mcpOrder...)
+	a.mu.RUnlock()
+
+	newPath, err := ctrl.ForkSession(turn, "")
+	if err != nil {
+		return TabMeta{}, err
+	}
+	topicID := newTopicID()
+	topicTitle := forkTopicTitle(sourceTitle)
+	titleRoot := workspaceRoot
+	if scope == "global" {
+		titleRoot = ""
+	}
+	if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {
+		return TabMeta{}, err
+	}
+	m, _ := agent.EnsureBranchMeta(newPath)
+	m.Scope = scope
+	m.WorkspaceRoot = workspaceRoot
+	m.TopicID = topicID
+	m.TopicTitle = topicTitle
+	if err := agent.SaveBranchMeta(newPath, m); err != nil {
+		return TabMeta{}, err
+	}
+	invalidateTopicSessionIndexForPath(newPath)
+
+	a.mu.Lock()
+	newTabID := a.newUniqueTabIDLocked()
+	tab := &WorkspaceTab{
+		ID:               newTabID,
+		Scope:            scope,
+		WorkspaceRoot:    workspaceRoot,
+		TopicID:          topicID,
+		TopicTitle:       topicTitle,
+		SessionPath:      newPath,
+		model:            model,
+		effort:           effort,
+		mode:             mode,
+		toolApprovalMode: toolApprovalMode,
+		disabledMCP:      disabledMCP,
+		mcpOrder:         mcpOrder,
+	}
+	tab.sink = &tabEventSink{tabID: newTabID, app: a}
+	a.tabs[newTabID] = tab
+	a.tabOrder = append(a.tabOrder, newTabID)
+	activateFork := a.activeTabID == sourceTab.ID
+	if activateFork {
+		a.activeTabID = newTabID
+	}
+	a.saveTabsLocked()
+	meta := a.tabMeta(tab, activateFork)
+	a.mu.Unlock()
+
+	a.emitProjectTreeChanged()
+	a.startTabControllerBuild(tab)
+	return meta, nil
+}
+
+// SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start
+// of turn into one summary (Claude Code's "summarize from/up to here"), keeping
+// code intact. The frontend re-reads History after this resolves.
+func (a *App) SummarizeFrom(turn int) error {
+	return a.SummarizeFromForTab("", turn)
+}
+
+func (a *App) SummarizeFromForTab(tabID string, turn int) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.SummarizeFrom(a.ctx, turn)
+}
+
+func (a *App) SummarizeUpTo(turn int) error {
+	return a.SummarizeUpToForTab("", turn)
+}
+
+func (a *App) SummarizeUpToForTab(tabID string, turn int) error {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if a.tabIsReadOnly(tab) {
+		return readOnlyChannelErr()
+	}
+	if ctrl == nil {
+		return nil
+	}
+	return ctrl.SummarizeUpTo(a.ctx, turn)
+}
+
+// SessionMeta summarises one saved session for the history panel.
+type SessionMeta struct {
+	Path           string `json:"path"`
+	Preview        string `json:"preview"`         // first user message
+	Title          string `json:"title,omitempty"` // user-chosen name, when set (overrides preview)
+	Turns          int    `json:"turns"`
+	CreatedAt      int64  `json:"createdAt"`      // unix milliseconds
+	LastActivityAt int64  `json:"lastActivityAt"` // unix milliseconds
+	ModTime        int64  `json:"modTime"`        // compatibility alias for lastActivityAt
+	DeletedAt      int64  `json:"deletedAt,omitempty"`
+	Current        bool   `json:"current"`
+	Open           bool   `json:"open"`
+	Scope          string `json:"scope,omitempty"`
+	WorkspaceRoot  string `json:"workspaceRoot,omitempty"`
+	TopicID        string `json:"topicId,omitempty"`
+	TopicTitle     string `json:"topicTitle,omitempty"`
+	Kind           string `json:"kind,omitempty"` // "channel" for external IM transcripts
+	Channel        string `json:"channel,omitempty"`
+	ChannelLabel   string `json:"channelLabel,omitempty"`
+	RemoteID       string `json:"remoteId,omitempty"`
+	ChatType       string `json:"chatType,omitempty"`
+	UserID         string `json:"userId,omitempty"`
+	ThreadID       string `json:"threadId,omitempty"`
+	SessionSource  string `json:"sessionSource,omitempty"`
+	Recovered      bool   `json:"recovered,omitempty"`    // created by conflict recovery, including an adopted/continued branch
+	RecoveryCopy   bool   `json:"recoveryCopy,omitempty"` // actual branch content is unchanged and covered by its parent
+}
+
+type channelSessionRoute struct {
+	channel       string
+	channelLabel  string
+	remoteID      string
+	chatType      string
+	userID        string
+	threadID      string
+	sessionSource string
+}
+
+type WorkspaceMeta struct {
+	Path    string `json:"path"`
+	Name    string `json:"name"`
+	Current bool   `json:"current"`
+}
+
+func controllerSessionDir(ctrl control.SessionAPI) string {
+	if ctrl != nil {
+		if dir := ctrl.SessionDir(); dir != "" {
+			return dir
+		}
+	}
+	return desktopSessionDir("")
+}
+
+func tabSessionDir(tab *WorkspaceTab) string {
+	if tab != nil {
+		if tab.WorkspaceRoot != "" {
+			return desktopSessionDir(tab.WorkspaceRoot)
+		}
+		if tab.Ctrl != nil {
+			if dir := tab.Ctrl.SessionDir(); dir != "" {
+				return dir
+			}
+		}
+	}
+	return desktopSessionDir("")
+}
+
+func tabRuntimeSessionDir(tab *WorkspaceTab) string {
+	if tab != nil && tab.Ctrl != nil {
+		if dir, ok := safeControllerSessionDir(tab.Ctrl); ok && strings.TrimSpace(dir) != "" {
+			if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
+				if _, _, err := validateSessionPath(dir, path); err == nil {
+					return dir
+				}
+			} else {
+				return dir
+			}
+		}
+	}
+	return tabSessionDir(tab)
+}
+
+func (a *App) activeSessionDir() string {
+	tab := a.activeTab()
+	if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
+		return filepath.Dir(path)
+	}
+	if tab != nil && tab.Ctrl != nil {
+		return tabRuntimeSessionDir(tab)
+	}
+	return tabSessionDir(tab)
+}
+
+// ListSessions returns the saved sessions newest-first for the history panel,
+// marking the one the current conversation is writing to and attaching any
+// user-chosen titles.
+func (a *App) ListSessions() []SessionMeta {
+	dir := a.activeSessionDir()
+	infos, err := agent.ListSessions(dir)
+	if err != nil {
+		return []SessionMeta{}
+	}
+	open := a.openSessionPaths(dir)
+	protectedDisplays := make(map[string]struct{}, len(open))
+	for path := range open {
+		if key := filepath.Base(path); store.IsSessionTranscriptName(key) {
+			protectedDisplays[key] = struct{}{}
+		}
+	}
+	_ = pruneSessionDisplays(dir, protectedDisplays)
+	titles := loadSessionTitles(dir)
+	channelRoutes := channelSessionRoutesForDir(dir)
+	active := a.activeSessionPath(dir)
+	out := make([]SessionMeta, 0, len(infos))
+	for _, s := range infos {
+		_, isOpen := open[s.Path]
+		title := strings.TrimSpace(s.CustomTitle)
+		if title == "" {
+			title = titles[filepath.Base(s.Path)]
+		}
+		meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0, dir)
+		if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {
+			applyChannelSessionRoute(&meta, route)
+		}
+		out = append(out, meta)
+	}
+	return out
+}
+
+// ListTrashedSessions returns sessions that were moved to the local trash,
+// newest-deleted first. These can be previewed, restored, or permanently purged.
+func (a *App) ListTrashedSessions() []SessionMeta {
+	out := []SessionMeta{}
+	for _, dir := range a.knownSessionDirs() {
+		paths, err := listTrashedSessionFiles(dir)
+		if err != nil {
+			continue
+		}
+		titles := loadSessionTitles(dir)
+		for _, path := range paths {
+			infos, err := agent.ListSessions(filepath.Dir(path))
+			if err != nil || len(infos) == 0 {
+				continue
+			}
+			deletedAt := trashedSessionDeletedAt(path)
+			title := strings.TrimSpace(infos[0].CustomTitle)
+			if title == "" {
+				title = titles[filepath.Base(path)]
+			}
+			out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt, dir))
+		}
+	}
+	sort.Slice(out, func(i, j int) bool {
+		if out[i].DeletedAt == out[j].DeletedAt {
+			return out[i].LastActivityAt > out[j].LastActivityAt
+		}
+		return out[i].DeletedAt > out[j].DeletedAt
+	})
+	return out
+}
+
+func (a *App) trashedSessionDir(path string) (string, error) {
+	for _, dir := range a.knownSessionDirs() {
+		if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {
+			return dir, nil
+		}
+	}
+	return "", fmt.Errorf("trashed session path outside known session dirs: %s", path)
+}
+
+func (a *App) sessionDirForPath(path string) (string, string, error) {
+	for _, dir := range a.knownSessionDirs() {
+		sessionPath, _, err := validateSessionPath(dir, path)
+		if err == nil {
+			return dir, sessionPath, nil
+		}
+	}
+	return "", "", fmt.Errorf("session path outside known session dirs: %s", path)
+}
+
+func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
+	return SessionMeta{
+		Path:           s.Path,
+		Preview:        s.Preview,
+		Title:          title,
+		Turns:          s.Turns,
+		CreatedAt:      s.CreatedAt.UnixMilli(),
+		LastActivityAt: s.LastActivityAt.UnixMilli(),
+		ModTime:        s.LastActivityAt.UnixMilli(),
+		DeletedAt:      deletedAt,
+		Current:        current,
+		Open:           open,
+		Scope:          s.Scope,
+		WorkspaceRoot:  s.WorkspaceRoot,
+		TopicID:        s.TopicID,
+		TopicTitle:     s.TopicTitle,
+		Recovered:      sessionInfoIsAutomaticRecovery(s),
+		RecoveryCopy:   sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir),
+	}
+}
+
+func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {
+	if meta == nil {
+		return
+	}
+	meta.Kind = "channel"
+	meta.Channel = route.channel
+	meta.ChannelLabel = route.channelLabel
+	meta.RemoteID = route.remoteID
+	meta.ChatType = route.chatType
+	meta.UserID = route.userID
+	meta.ThreadID = route.threadID
+	meta.SessionSource = route.sessionSource
+}
+
+func channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {
+	userPath := config.UserConfigPath()
+	if strings.TrimSpace(userPath) == "" {
+		return nil
+	}
+	cfg := config.LoadForEdit(userPath)
+	out := map[string]channelSessionRoute{}
+	for _, conn := range cfg.Bot.Connections {
+		channel := strings.TrimSpace(conn.Provider)
+		if channel == "" {
+			continue
+		}
+		channelLabel := strings.TrimSpace(conn.Label)
+		if channelLabel == "" {
+			channelLabel = channelDisplayName(channel, conn.Domain)
+		}
+		for _, mapping := range conn.SessionMappings {
+			if strings.TrimSpace(mapping.SessionSource) != "auto" {
+				continue
+			}
+			sessionPath := botSessionPathTarget(mapping.SessionID)
+			if sessionPath == "" {
+				continue
+			}
+			validPath, _, err := validateSessionPath(dir, sessionPath)
+			if err != nil {
+				continue
+			}
+			key := sessionRuntimeKey(validPath)
+			if key == "" {
+				continue
+			}
+			out[key] = channelSessionRoute{
+				channel:       channel,
+				channelLabel:  channelLabel,
+				remoteID:      strings.TrimSpace(mapping.RemoteID),
+				chatType:      strings.TrimSpace(mapping.ChatType),
+				userID:        strings.TrimSpace(mapping.UserID),
+				threadID:      strings.TrimSpace(mapping.ThreadID),
+				sessionSource: strings.TrimSpace(mapping.SessionSource),
+			}
+		}
+	}
+	if len(out) == 0 {
+		return nil
+	}
+	return out
+}
+
+func botSessionPathTarget(sessionID string) string {
+	sessionID = strings.TrimSpace(sessionID)
+	if sessionID == "" {
+		return ""
+	}
+	if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
+		return strings.TrimSpace(sessionID[5:])
+	}
+	if strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~") {
+		return sessionID
+	}
+	return ""
+}
+
+func channelDisplayName(provider, domain string) string {
+	provider = strings.TrimSpace(provider)
+	domain = strings.TrimSpace(domain)
+	switch provider {
+	case "feishu":
+		if strings.EqualFold(domain, "lark") {
+			return "Lark"
+		}
+		return "Feishu"
+	case "weixin":
+		return "WeChat"
+	case "qq":
+		return "QQ"
+	default:
+		return provider
+	}
+}
+
+// DeleteSession moves a saved session to the local trash. If the session still
+// has an in-process runtime, the runtime is cancelled and removed first so
+// autosave cannot recreate or append to the deleted file later.
+func (a *App) DeleteSession(path string) error {
+	return friendlySessionFileError(a.deleteSession(path, false))
+}
+
+// DeleteRecoveryCopy is the guarded bulk-cleanup path. The frontend's copy
+// marker is only a hint; the backend re-reads the branch and parent immediately
+// before changing runtime state or moving any files.
+func (a *App) DeleteRecoveryCopy(path string) error {
+	return friendlySessionFileError(a.deleteSession(path, true))
+}
+
+var errRecoveryCopyNotRedundant = errors.New("recovery session contains content not preserved by its parent")
+
+func (a *App) deleteSession(path string, requireRedundantRecovery bool) error {
+	dir := a.activeSessionDir()
+	sessionPath, key, err := validateSessionPath(dir, path)
+	if err != nil {
+		var foundErr error
+		if dir, sessionPath, foundErr = a.sessionDirForPath(path); foundErr != nil {
+			return err
+		}
+		key = filepath.Base(sessionPath)
+	}
+	if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
+		return err
+	}
+	var fallback fallbackRuntimeTarget
+	if err := func() error {
+		a.sessionRemovalMu.Lock()
+		defer a.sessionRemovalMu.Unlock()
+		if requireRedundantRecovery && !agent.RecoveryBranchCoveredByParent(sessionPath, dir) {
+			return errRecoveryCopyNotRedundant
+		}
+
+		removed, nextFallback := a.removeSessionRuntimeBindings(dir, sessionPath)
+		fallback = nextFallback
+		if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
+			a.closeRemovedSessionRuntimes(removed)
+			return err
+		}
+		closedRemoved := map[control.SessionAPI]bool{}
+		destroys := a.destroyHandlesForSession(dir, sessionPath, removed)
+		teardownTimedOut := waitDestroyHandles(destroys)
+		a.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved)
+		if teardownTimedOut {
+			if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
+				a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
+				return err
+			}
+			go delayedDesktopSessionTrash(dir, sessionPath, key, destroys)
+		} else {
+			err = trashSessionArtifacts(dir, sessionPath, key)
+			finishDestroyHandles(destroys)
+			if err != nil {
+				a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
+				return err
+			}
+		}
+		a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved)
+		return nil
+	}(); err != nil {
+		return err
+	}
+	if err := botruntime.ForgetAutoSessionMappingsForPath(sessionPath); err != nil {
+		slog.Warn("desktop: failed to clear auto bot session mapping", "err", err)
+	}
+	if fallback.needs {
+		fallback = a.sessionDeleteFallbackTarget(fallback)
+		if err := a.openFallbackRuntime(fallback); err != nil {
+			return err
+		}
+	}
+	a.emitProjectTreeChanged()
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+type removedSessionRuntime struct {
+	tab           *WorkspaceTab
+	ctrl          control.SessionAPI
+	sink          *tabEventSink
+	sessionDir    string
+	sessionPath   string
+	scope         string
+	workspaceRoot string
+	topicID       string
+	readOnly      bool
+}
+
+type fallbackRuntimeTarget struct {
+	needs         bool
+	scope         string
+	workspaceRoot string
+	topicID       string
+}
+
+func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
+	var removed []removedSessionRuntime
+	var fallback fallbackRuntimeTarget
+
+	a.mu.Lock()
+	for id, tab := range a.tabs {
+		if !tabMatchesSession(tab, dir, sessionPath) {
+			continue
+		}
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.tabs, id)
+		a.removeTabOrderLocked(id)
+		if a.activeTabID == id {
+			a.activeTabID = ""
+		}
+	}
+	for key, tab := range a.detachedSessions {
+		if !tabMatchesSession(tab, dir, sessionPath) {
+			continue
+		}
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.detachedSessions, key)
+	}
+	if a.activeTabID == "" && len(a.tabOrder) > 0 {
+		a.activeTabID = a.tabOrder[0]
+	}
+	fallback.needs = len(removed) > 0 && len(a.tabs) == 0
+	dir, entries, activeID, version := a.saveTabsCollectLocked()
+	a.mu.Unlock()
+
+	a.saveTabsWrite(dir, entries, activeID, version)
+
+	return removed, fallback
+}
+
+func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallbackRuntimeTarget {
+	topicID := strings.TrimSpace(target.topicID)
+	if topicID == "" {
+		return target
+	}
+	if path, _ := a.findTopicContentSessionForTarget(target.scope, target.workspaceRoot, topicID); path != "" {
+		return target
+	}
+	target.topicID = ""
+	return target
+}
+
+func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
+	var removed []removedSessionRuntime
+	var fallback fallbackRuntimeTarget
+
+	a.mu.Lock()
+	for id, tab := range a.tabs {
+		if tab == nil || tab.TopicID != topicID {
+			continue
+		}
+		sessionDir := tabRuntimeSessionDir(tab)
+		sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.tabs, id)
+		a.removeTabOrderLocked(id)
+		if a.activeTabID == id {
+			a.activeTabID = ""
+		}
+	}
+	for key, tab := range a.detachedSessions {
+		if tab == nil || tab.TopicID != topicID {
+			continue
+		}
+		sessionDir := tabRuntimeSessionDir(tab)
+		sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
+		if len(removed) == 0 {
+			fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
+		}
+		removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
+		a.markTabRemovedLocked(tab)
+		delete(a.detachedSessions, key)
+	}
+	if a.activeTabID == "" && len(a.tabOrder) > 0 {
+		a.activeTabID = a.tabOrder[0]
+	}
+	fallback.needs = len(removed) > 0 && len(a.tabs) == 0
+	dir, entries, activeID, version := a.saveTabsCollectLocked()
+	a.mu.Unlock()
+
+	a.saveTabsWrite(dir, entries, activeID, version)
+
+	return removed, fallback
+}
+
+func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
+	return removedSessionRuntime{
+		tab:           tab,
+		ctrl:          tab.Ctrl,
+		sink:          tab.sink,
+		sessionDir:    dir,
+		sessionPath:   sessionPath,
+		scope:         tab.Scope,
+		workspaceRoot: tab.WorkspaceRoot,
+		topicID:       tab.TopicID,
+		readOnly:      tab.ReadOnly,
+	}
+}
+
+func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {
+	if tab == nil {
+		return false
+	}
+	currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())
+	if err == nil && currentPath == sessionPath {
+		return true
+	}
+	if tabRuntimeSessionDir(tab) != dir {
+		return false
+	}
+	currentPath, _, err = validateSessionPath(dir, tab.currentSessionPath())
+	return err == nil && currentPath == sessionPath
+}
+
+func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {
+	for _, item := range removed {
+		if item.sink != nil {
+			item.sink.clearContext()
+		}
+		if item.ctrl == nil {
+			continue
+		}
+		if item.ctrl.Running() {
+			item.ctrl.Cancel()
+			if err := waitControllerStopped(item.ctrl); err != nil {
+				return err
+			}
+		}
+		if item.readOnly {
+			continue
+		}
+		if err := item.ctrl.Snapshot(); err != nil {
+			if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
+				return err
+			}
+			slog.Warn("desktop: skipping stale runtime snapshot before removing session",
+				"session", item.sessionPath, "err", err)
+		}
+		item.ctrl.SetSessionPath("")
+		a.quiesceTabAutosave(item.tab)
+	}
+	return nil
+}
+
+func waitControllerStopped(ctrl control.SessionAPI) error {
+	deadline := time.Now().Add(5 * time.Second)
+	for ctrl.Running() {
+		if time.Now().After(deadline) {
+			return fmt.Errorf("timed out waiting for cancelled session work to stop")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	return nil
+}
+
+func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {
+	destroys := a.beginDestroySessionJobs(dir, sessionPath)
+	for _, item := range removed {
+		if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {
+			continue
+		}
+		destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))
+	}
+	return destroys
+}
+
+func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
+	timedOut := false
+	for _, destroy := range destroys {
+		if destroy.Wait != nil {
+			if destroy.Wait().HasTimedOut() {
+				timedOut = true
+			}
+		}
+	}
+	return timedOut
+}
+
+func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
+	for _, destroy := range destroys {
+		if destroy.WaitAll != nil {
+			destroy.WaitAll()
+		}
+	}
+}
+
+func finishDestroyHandles(destroys []control.SessionDestroyHandle) {
+	for _, destroy := range destroys {
+		if destroy.Finish != nil {
+			destroy.Finish()
+		}
+	}
+}
+
+func delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {
+	waitAllDestroyHandles(destroys)
+	if err := removeDesktopSessionArtifacts(path); err != nil {
+		slog.Warn("desktop: delayed session cleanup failed", "path", path, "err", err)
+	}
+	finishDestroyHandles(destroys)
+}
+
+func delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {
+	waitAllDestroyHandles(destroys)
+	if err := trashSessionArtifacts(dir, sessionPath, key); err != nil {
+		slog.Warn("desktop: delayed session trash failed", "path", sessionPath, "err", err)
+	}
+	finishDestroyHandles(destroys)
+}
+
+func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {
+	a.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{})
+}
+
+func (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {
+	releasedTabs := map[*WorkspaceTab]bool{}
+	for _, item := range removed {
+		if item.sessionDir != dir || item.sessionPath != sessionPath {
+			continue
+		}
+		a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
+	}
+}
+
+func (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
+	releasedTabs := map[*WorkspaceTab]bool{}
+	for _, item := range removed {
+		a.closeRemovedSessionRuntime(item, closed, releasedTabs, false)
+	}
+}
+
+func (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
+	releasedTabs := map[*WorkspaceTab]bool{}
+	for _, item := range removed {
+		a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
+	}
+}
+
+func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {
+	if item.tab != nil {
+		if releasedTabs == nil || !releasedTabs[item.tab] {
+			if releasedTabs != nil {
+				releasedTabs[item.tab] = true
+			}
+			a.releaseTabSharedHost(item.tab)
+			item.tab.releaseSessionLease()
+		}
+	}
+	if item.ctrl == nil {
+		return
+	}
+	if closed == nil {
+		closed = map[control.SessionAPI]bool{}
+	}
+	if closed[item.ctrl] {
+		return
+	}
+	closed[item.ctrl] = true
+	if afterDestroy {
+		item.ctrl.CloseAfterDestroy()
+		return
+	}
+	item.ctrl.Close()
+}
+
+func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
+	scope := target.scope
+	root := target.workspaceRoot
+	topicID := strings.TrimSpace(target.topicID)
+	if scope == "global" {
+		root = ""
+	}
+	if topicID == "" {
+		return a.openTransientBlankRuntime(scope, root)
+	}
+	var err error
+	if a.singleSurfaceLayoutEnabled() {
+		_, err = a.ActivateTopic(scope, root, topicID, "")
+	} else if scope == "global" {
+		_, err = a.OpenGlobalTab(topicID)
+	} else {
+		_, err = a.OpenProjectTab(root, topicID)
+	}
+	return err
+}
+
+func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
+	scope = strings.TrimSpace(scope)
+	if scope != "project" {
+		scope = "global"
+	}
+	actualRoot := ""
+	if scope == "project" {
+		workspaceRoot = normalizeProjectRoot(workspaceRoot)
+		if workspaceRoot == "" {
+			return fmt.Errorf("workspaceRoot is required")
+		}
+		saveWorkspace(workspaceRoot)
+		a.registerProjectRoot(workspaceRoot)
+		actualRoot = workspaceRoot
+	} else {
+		actualRoot = globalWorkspaceRoot()
+		if err := os.MkdirAll(actualRoot, 0o755); err != nil {
+			return fmt.Errorf("create global workspace: %w", err)
+		}
+	}
+
+	model, toolApprovalMode := desktopNewSessionDefaults()
+	sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
+	if err != nil {
+		return err
+	}
+	tab := &WorkspaceTab{
+		Scope:            scope,
+		WorkspaceRoot:    actualRoot,
+		TopicTitle:       defaultTopicTitle,
+		SessionPath:      sessionPath,
+		model:            model,
+		tokenMode:        boot.TokenModeFull,
+		mode:             tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
+		toolApprovalMode: toolApprovalMode,
+		disabledMCP:      map[string]ServerView{},
+	}
+	a.mu.Lock()
+	tab.ID = a.newUniqueTabIDLocked()
+	tab.sink = &tabEventSink{tabID: tab.ID, app: a}
+	a.tabs[tab.ID] = tab
+	a.tabOrder = append(a.tabOrder, tab.ID)
+	a.activeTabID = tab.ID
+	a.saveTabsLocked()
+	a.mu.Unlock()
+
+	a.startTabControllerBuild(tab)
+	return nil
+}
+
+func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	var destroys []control.SessionDestroyHandle
+	for _, tab := range a.runtimeTabsLocked() {
+		if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
+			continue
+		}
+		destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))
+	}
+	return destroys
+}
+
+func (a *App) openSessionPaths(dir string) map[string]struct{} {
+	a.mu.RLock()
+	paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))
+	for _, tab := range a.runtimeTabsLocked() {
+		if tab != nil {
+			paths = append(paths, tab.currentSessionPath())
+		}
+	}
+	a.mu.RUnlock()
+
+	out := make(map[string]struct{}, len(paths))
+	for _, path := range paths {
+		currentPath, _, err := validateSessionPath(dir, path)
+		if err == nil {
+			out[currentPath] = struct{}{}
+		}
+	}
+	return out
+}
+
+func (a *App) activeSessionPath(dir string) string {
+	a.mu.RLock()
+	var path string
+	if tab := a.tabs[a.activeTabID]; tab != nil {
+		path = tab.currentSessionPath()
+	}
+	a.mu.RUnlock()
+	currentPath, _, err := validateSessionPath(dir, path)
+	if err != nil {
+		return ""
+	}
+	return currentPath
+}
+
+// RestoreSession moves a trashed session back into the saved-session list.
+func (a *App) RestoreSession(path string) error {
+	return friendlySessionFileError(a.restoreSession(path))
+}
+
+func (a *App) restoreSession(path string) error {
+	dir, err := a.trashedSessionDir(path)
+	if err != nil {
+		return err
+	}
+	_, key, _, err := validateTrashedSessionPath(dir, path)
+	if err != nil {
+		return err
+	}
+	// The destroying/open checks and the trash-entry move must not interleave
+	// with DeleteSession/TrashTopic trashing the same entry.
+	a.sessionRemovalMu.Lock()
+	defer a.sessionRemovalMu.Unlock()
+	target := filepath.Join(dir, key)
+	if a.sessionDestroying(dir, target) {
+		return fmt.Errorf("session cleanup is still in progress: %s", key)
+	}
+	if a.sessionOpen(dir, target) {
+		return fmt.Errorf("session is open: %s", key)
+	}
+	if err := restoreTrashedSessionFile(dir, path); err != nil {
+		return err
+	}
+	if err := restoreSessionTopicIndex(dir, target); err != nil {
+		return err
+	}
+	a.emitProjectTreeChanged()
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+func (a *App) sessionDestroying(dir, sessionPath string) bool {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	for _, tab := range a.runtimeTabsLocked() {
+		if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
+			continue
+		}
+		if tab.Ctrl.IsDestroyingSession(sessionPath) {
+			return true
+		}
+	}
+	return false
+}
+
+func (a *App) sessionOpen(dir, sessionPath string) bool {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	for _, tab := range a.runtimeTabsLocked() {
+		if tabMatchesSession(tab, dir, sessionPath) {
+			return true
+		}
+	}
+	return false
+}
+
+// PurgeTrashedSession permanently removes a trashed session and its title/display
+// sidecars.
+func (a *App) PurgeTrashedSession(path string) error {
+	return friendlySessionFileError(a.purgeTrashedSession(path, false))
+}
+
+// PurgeRecoveryCopy is the guarded permanent-cleanup path. A trashed branch is
+// rechecked against its live parent; missing, stale, or divergent data is kept.
+func (a *App) PurgeRecoveryCopy(path string) error {
+	return friendlySessionFileError(a.purgeTrashedSession(path, true))
+}
+
+func (a *App) purgeTrashedSession(path string, requireRedundantRecovery bool) error {
+	dir, err := a.trashedSessionDir(path)
+	if err != nil {
+		return err
+	}
+	a.sessionRemovalMu.Lock()
+	defer a.sessionRemovalMu.Unlock()
+	var parentGuard *agent.SessionRemovalGuard
+	if requireRedundantRecovery {
+		parentGuard, err = agent.TryAcquireRecoveryParentGuard(path, dir)
+		if err != nil {
+			switch {
+			case errors.Is(err, agent.ErrRecoveryBranchNotCovered):
+				return errRecoveryCopyNotRedundant
+			case errors.Is(err, agent.ErrSessionLeaseHeld):
+				return errSessionBusyElsewhere
+			default:
+				return err
+			}
+		}
+		defer parentGuard.Release()
+	}
+	if err := purgeTrashedSessionFile(dir, path); err != nil {
+		return err
+	}
+	a.invalidatePromptHistoryCache()
+	return nil
+}
+
+// RenameSession sets a custom display name for a session (empty clears it back to
+// the preview). The transcript file is unchanged; the canonical name lives in
+// the branch meta sidecar, with the legacy .titles.json map kept as a
+// compatibility write-through for older desktop data paths.
+func (a *App) RenameSession(path, title string) error {
+	dir := a.activeSessionDir()
+	sessionPath, _, err := validateSessionPath(dir, path)
+	if err != nil {
+		return err
+	}
+	if err := agent.RenameSession(sessionPath, title); err != nil {
+		return err
+	}
+	if err := setSessionTitle(dir, sessionPath, title); err != nil {
+		return err
+	}
+	a.invalidatePromptHistoryCache()
+	a.emitProjectTreeChanged()
+	return nil
+}
+
+// ResumeSession snapshots the current conversation, then loads the session at
+// path and continues it on the active tab. The model and working folder are
+// unchanged; only the transcript is swapped. Returns the resumed messages for
+// the frontend to render.
+func (a *App) ResumeSession(path string) ([]HistoryMessage, error) {
+	return a.ResumeSessionForTab("", path)
+}
+
+func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) {
+	return a.ResumeSessionPageForTab("", path, limit)
+}
+
+func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return HistoryPage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
+		if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+			return HistoryPage{}, err
+		}
+	}
+	a.setTabReadOnly(tab.ID, false)
+	return a.HistoryPageForTab(tab.ID, 0, limit), nil
+}
+
+// ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session
+// path is a runtime identity, so changing to a different path must replace the
+// tab's controller binding rather than mutating the current controller in place.
+func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return []HistoryMessage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return nil, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return nil, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
+		a.setTabReadOnly(tab.ID, false)
+		return a.HistoryForTab(tabID), nil
+	}
+
+	if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+		return nil, err
+	}
+	a.setTabReadOnly(tab.ID, false)
+	return a.HistoryForTab(tab.ID), nil
+}
+
+func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return []HistoryMessage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return nil, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return nil, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
+		if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+			return nil, err
+		}
+	}
+	a.setTabReadOnly(tab.ID, true)
+	return a.HistoryForTab(tab.ID), nil
+}
+
+func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
+	tab, ctrl := a.tabAndCtrlByID(tabID)
+	if tab == nil || ctrl == nil {
+		return HistoryPage{}, fmt.Errorf("tab is not ready")
+	}
+	sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
+		if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
+			return HistoryPage{}, err
+		}
+	}
+	a.setTabReadOnly(tab.ID, true)
+	return a.HistoryPageForTab(tab.ID, 0, limit), nil
+}
+
+func (a *App) setTabReadOnly(tabID string, readOnly bool) {
+	a.mu.Lock()
+	if tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly {
+		tab.ReadOnly = readOnly
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {
+	sessionPath = canonicalTabSessionPath(sessionPath)
+	if sessionPath == "" {
+		return fmt.Errorf("session path is required")
+	}
+	loaded, err := loadResumableSession(sessionPath)
+	if err != nil {
+		return err
+	}
+	return a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)
+}
+
+func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {
+	if tab == nil {
+		return fmt.Errorf("tab is not ready")
+	}
+	sessionPath = canonicalTabSessionPath(sessionPath)
+	if sessionPath == "" {
+		return fmt.Errorf("session path is required")
+	}
+	if agent.IsCleanupPending(sessionPath) {
+		return fmt.Errorf("session is pending cleanup")
+	}
+	if loaded == nil {
+		var err error
+		loaded, err = loadResumableSession(sessionPath)
+		if err != nil {
+			return err
+		}
+	}
+	// Session rebinding is a full detach/close/build/swap of the tab's
+	// controller — the same shape as rebuildSetting/SetModelForTab. Hold the
+	// shared rebuild mutex so a concurrent model/effort/settings rebuild of the
+	// same tab cannot interleave: without it both builds pass the swap-time
+	// identity check, the loser's controller leaks un-closed, and the old
+	// controller can be double-closed.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+
+	// Validate the tab, compare session keys, invalidate any in-flight async
+	// build, and snapshot the controller in ONE a.mu critical section.
+	// runtimeRebuildMu does not cover startTabControllerBuild's goroutine, so
+	// observing ctrl == nil and bumping the generation later would leave a
+	// window where the async build passes its swap-time generation check,
+	// installs its controller after the observation, and the loaded build
+	// below silently overwrites it — leaking the runtime and its shared-host
+	// reference. Bump-before-snapshot makes the snapshot authoritative: after
+	// the bump the async build can only fall into its superseded branches,
+	// which release exactly what it acquired (abandonSupersededBuild).
+	a.mu.Lock()
+	if tab.removed || a.tabs[tab.ID] != tab {
+		a.mu.Unlock()
+		return fmt.Errorf("tab is not ready")
+	}
+	currentPath := ""
+	if tab.Ctrl != nil {
+		currentPath = strings.TrimSpace(tab.Ctrl.SessionPath())
+	}
+	if currentPath == "" {
+		currentPath = strings.TrimSpace(tab.SessionPath)
+	}
+	if sessionRuntimeKey(currentPath) == sessionRuntimeKey(sessionPath) {
+		// Same session: leave any in-flight build alone — resuming the
+		// session a build is already binding must stay a no-op.
+		a.mu.Unlock()
+		return nil
+	}
+	tab.buildGeneration++
+	if tab.buildCancel != nil {
+		tab.buildCancel()
+		tab.buildCancel = nil
+	}
+	ctrl := tab.Ctrl
+	a.mu.Unlock()
+
+	profile := loadTabSessionProfile(sessionPath)
+
+	if ctrl == nil {
+		a.mu.Lock()
+		tab.SessionPath = sessionPath
+		applyTabSessionProfile(tab, profile)
+		tab.Ready = false
+		clearTabStartupError(tab)
+		tab.ActivityStatus = ""
+		tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+		a.saveTabsLocked()
+		a.mu.Unlock()
+		a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})
+		a.mu.RLock()
+		builtCtrl, startupErr := tab.Ctrl, tab.StartupErr
+		a.mu.RUnlock()
+		if builtCtrl == nil {
+			if startupErr != "" {
+				return fmt.Errorf("resume session: %s", startupErr)
+			}
+			return fmt.Errorf("resume session: controller was not built")
+		}
+		return nil
+	}
+
+	if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
+		return err
+	}
+	if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
+		if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
+			return fmt.Errorf("save current session metadata before switching sessions: %w", err)
+		}
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		if !a.detachRuntimeForReplacement(tab) {
+			return fmt.Errorf("current session runtime cannot be detached")
+		}
+	} else {
+		ctrl.Close()
+		tab.releaseSessionLease()
+	}
+
+	a.mu.Lock()
+	// The generation was already bumped (and any async build cancelled) in
+	// the validation section above; only retarget the tab here.
+	tab.Ctrl = nil
+	tab.SessionPath = sessionPath
+	applyTabSessionProfile(tab, profile)
+	tab.Ready = false
+	clearTabStartupError(tab)
+	tab.ActivityStatus = ""
+	tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
+	a.saveTabsLocked()
+	a.mu.Unlock()
+
+	a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded})
+	a.mu.RLock()
+	builtCtrl, startupErr := tab.Ctrl, tab.StartupErr
+	a.mu.RUnlock()
+	if builtCtrl == nil {
+		if startupErr != "" {
+			return fmt.Errorf("resume session: %s", startupErr)
+		}
+		return fmt.Errorf("resume session: controller was not built")
+	}
+	return nil
+}
+
+func loadResumableSession(sessionPath string) (*agent.Session, error) {
+	if agent.IsCleanupPending(sessionPath) {
+		return nil, fmt.Errorf("session is pending cleanup")
+	}
+	return agent.LoadSession(sessionPath)
+}
+
+// PreviewSession reads a saved session for display only. It does not snapshot or
+// swap the active controller, so the history drawer can call it while a turn runs.
+func (a *App) PreviewSession(path string) ([]HistoryMessage, error) {
+	sessionDir, sessionPath, err := a.sessionDirForPath(path)
+	if err != nil {
+		return nil, err
+	}
+	return previewSessionMessages(sessionDir, sessionPath)
+}
+
+// invalidatePromptHistoryCache resets the lazy prompt-history tape so the next
+// ScanPromptHistory call rebuilds session order and reloads sessions on demand.
+// Called from every session-mutating path: NewSession, ClearSession,
+// DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.
+func (a *App) invalidatePromptHistoryCache() {
+	a.promptHistoryMu.Lock()
+	a.promptHistoryTape = nil
+	a.promptHistoryMu.Unlock()
+}
+
+const (
+	promptHistoryPageLimit    = 50
+	promptHistoryMaxPageLimit = 200
+)
+
+type promptHistoryRequest struct {
+	Nonce  string `json:"nonce,omitempty"`
+	Cursor string `json:"cursor,omitempty"`
+	Limit  int    `json:"limit,omitempty"`
+	legacy bool
+}
+
+type promptHistoryCursor struct {
+	Nonce   string `json:"n"`
+	Session int    `json:"s"`
+	Offset  int    `json:"o"`
+}
+
+type promptHistoryTape struct {
+	nonce       string
+	dir         string
+	currentPath string
+	displays    sessionDisplayMap
+	sessions    []promptHistorySessionFile
+	loaded      map[string][]PromptHistoryEntry
+}
+
+// ScanPromptHistory returns the next prompt-history tape segment. The request is
+// a JSON string so the Wails binding stays one-argument while the protocol can
+// carry a cursor and page limit. Older clients may still pass a bare nonce; that
+// path keeps the old cache-hit behavior.
+func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {
+	req := parsePromptHistoryRequest(rawRequest)
+	dir := a.activeSessionDir()
+	sessionPath := a.activeSessionPath(dir)
+
+	a.promptHistoryMu.Lock()
+	tape, err := a.promptHistoryTapeForLocked(dir, sessionPath)
+	if err != nil {
+		a.promptHistoryMu.Unlock()
+		return PromptHistoryResult{}, err
+	}
+	if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce {
+		a.promptHistoryMu.Unlock()
+		return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil
+	}
+	result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))
+	a.promptHistoryMu.Unlock()
+	return result, nil
+}
+
+func parsePromptHistoryRequest(raw string) promptHistoryRequest {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return promptHistoryRequest{}
+	}
+	if strings.HasPrefix(raw, "{") {
+		var req promptHistoryRequest
+		if err := json.Unmarshal([]byte(raw), &req); err == nil {
+			return req
+		}
+	}
+	return promptHistoryRequest{Nonce: raw, legacy: true}
+}
+
+func promptHistoryLimit(limit int) int {
+	if limit <= 0 {
+		return promptHistoryPageLimit
+	}
+	if limit > promptHistoryMaxPageLimit {
+		return promptHistoryMaxPageLimit
+	}
+	return limit
+}
+
+func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {
+	currentPath := ""
+	if path, _, err := validateSessionPath(dir, sessionPath); err == nil {
+		currentPath = path
+	}
+	if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {
+		return a.promptHistoryTape, nil
+	}
+	tape, err := newPromptHistoryTape(dir, currentPath)
+	if err != nil {
+		return nil, err
+	}
+	a.promptHistoryTape = tape
+	return tape, nil
+}
+
+func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {
+	tape, err := newPromptHistoryTape(dir, "")
+	if err != nil {
+		return nil, err
+	}
+	return tape.readAll(), nil
+}
+
+func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {
+	tape := &promptHistoryTape{
+		nonce:       fmt.Sprintf("%d", time.Now().UnixNano()),
+		dir:         dir,
+		currentPath: currentPath,
+		displays:    loadSessionDisplays(dir),
+		loaded:      map[string][]PromptHistoryEntry{},
+	}
+	sessions, err := promptHistorySessionFiles(dir)
+	if err != nil {
+		return nil, err
+	}
+	if currentPath != "" {
+		currentPath = filepath.Clean(currentPath)
+		currentSession := promptHistorySessionFile{}
+		currentIndex := -1
+		for i, session := range sessions {
+			if filepath.Clean(session.path) == currentPath {
+				currentSession = session
+				currentIndex = i
+				break
+			}
+		}
+		if currentIndex >= 0 {
+			sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)
+		} else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {
+			sessions = append([]promptHistorySessionFile{{
+				path: currentPath,
+			}}, sessions...)
+		}
+	}
+	tape.sessions = sessions
+	return tape, nil
+}
+
+func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {
+	c := promptHistoryCursor{Nonce: t.nonce}
+	if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {
+		c = decoded
+	}
+	if c.Session < 0 {
+		c.Session = 0
+	}
+	if c.Offset < 0 {
+		c.Offset = 0
+	}
+
+	out := make([]PromptHistoryEntry, 0, limit)
+	sessionIndex := c.Session
+	offset := c.Offset
+	for sessionIndex < len(t.sessions) && len(out) < limit {
+		entries, err := t.entriesForSession(sessionIndex)
+		if err != nil || offset >= len(entries) {
+			sessionIndex++
+			offset = 0
+			continue
+		}
+
+		end := min(len(entries), offset+limit-len(out))
+		out = append(out, entries[offset:end]...)
+		offset = end
+		if offset >= len(entries) && len(out) < limit {
+			sessionIndex++
+			offset = 0
+		}
+	}
+
+	if sessionIndex < len(t.sessions) {
+		if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {
+			sessionIndex++
+			offset = 0
+		}
+	}
+	hasOlder := sessionIndex < len(t.sessions)
+	olderCursor := ""
+	if hasOlder {
+		olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})
+	}
+	return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}
+}
+
+func (t *promptHistoryTape) readAll() []PromptHistoryEntry {
+	out := []PromptHistoryEntry{}
+	cursor := ""
+	for {
+		page := t.readOlder(cursor, promptHistoryMaxPageLimit)
+		out = append(out, page.Entries...)
+		if !page.HasOlder || page.OlderCursor == "" {
+			return out
+		}
+		cursor = page.OlderCursor
+	}
+}
+
+func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {
+	if index < 0 || index >= len(t.sessions) {
+		return nil, nil
+	}
+	path := t.sessions[index].path
+	if entries, ok := t.loaded[path]; ok {
+		return entries, nil
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.loaded[path] = nil
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))
+	if err != nil {
+		t.loaded[path] = nil
+		return nil, err
+	}
+	t.loaded[path] = entries
+	return entries, nil
+}
+
+func encodePromptHistoryCursor(cursor promptHistoryCursor) string {
+	b, err := json.Marshal(cursor)
+	if err != nil {
+		return ""
+	}
+	return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {
+	if strings.TrimSpace(value) == "" {
+		return promptHistoryCursor{}, false
+	}
+	b, err := base64.RawURLEncoding.DecodeString(value)
+	if err != nil {
+		return promptHistoryCursor{}, false
+	}
+	var cursor promptHistoryCursor
+	if err := json.Unmarshal(b, &cursor); err != nil {
+		return promptHistoryCursor{}, false
+	}
+	return cursor, true
+}
+
+func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
+	entries, err := collectPromptHistoryEntries(path, info, resolveUserContent)
+	if err != nil {
+		return nil, err
+	}
+	sortPromptHistoryNewestFirst(entries)
+	return entries, nil
+}
+
+type promptHistorySessionFile struct {
+	path string
+}
+
+func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {
+	infos, err := agent.ListSessionOrder(dir)
+	if err != nil {
+		return nil, err
+	}
+	sessions := make([]promptHistorySessionFile, 0, len(infos))
+	for _, info := range infos {
+		sessions = append(sessions, promptHistorySessionFile{path: info.Path})
+	}
+	return sessions, nil
+}
+
+func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {
+	if a.At != b.At {
+		return a.At > b.At
+	}
+	if a.SessionPath != b.SessionPath {
+		return a.SessionPath > b.SessionPath
+	}
+	return a.Turn > b.Turn
+}
+
+func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {
+	sort.Slice(entries, func(i, j int) bool {
+		return promptHistoryEntryNewer(entries[i], entries[j])
+	})
+}
+
+func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
+	var out []PromptHistoryEntry
+	emit := func(entry PromptHistoryEntry) {
+		out = append(out, entry)
+	}
+	// Sessions with an event log must replay it: the .jsonl checkpoint stops
+	// gaining turns between checkpoints, so scanning it directly would freeze
+	// ↑-recall at each session's last checkpoint.
+	if handled, err := collectEventLogUserPrompts(path, info, resolveUserContent, emit); handled {
+		return out, err
+	}
+	err := collectJSONLUserPrompts(path, info, resolveUserContent, emit)
+	return out, err
+}
+
+func collectEventLogUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) (bool, error) {
+	logPath := store.SessionEventLog(path)
+	if logPath == "" {
+		return false, nil
+	}
+	if logInfo, err := os.Stat(logPath); err != nil || logInfo.IsDir() || logInfo.Size() == 0 {
+		return false, nil
+	}
+	users, err := agent.LoadSessionUserMessages(path)
+	if err != nil {
+		return true, err
+	}
+	fallbackAt := promptHistoryFallbackMillis(path, info)
+	turn := 0
+	for _, user := range users {
+		text := strings.TrimSpace(resolveUserContent(strings.TrimSpace(user.Text)))
+		if text == "" || control.IsSyntheticUserMessage(text) {
+			continue
+		}
+		at := fallbackAt
+		if !user.At.IsZero() {
+			at = user.At.UnixMilli()
+		}
+		emit(PromptHistoryEntry{
+			Text:        text,
+			At:          at,
+			SessionPath: path,
+			Turn:        turn,
+		})
+		turn++
+	}
+	return true, nil
+}
+
+func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {
+	f, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	fallbackAt := promptHistoryFallbackMillis(path, info)
+
+	dec := json.NewDecoder(f)
+	turn := 0
+	for {
+		var rec previewEventRecord
+		if err := dec.Decode(&rec); err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			return nil // partial results are better than none
+		}
+		// Format compatibility:
+		// 1) Legacy event format: {"kind":"user.message","text":"..."}
+		// 2) Early event format:   {"type":"user.message","text":"..."}
+		// 3) Current provider.Message format: {"role":"user","content":"..."}
+		text := ""
+		kindOrType := strings.TrimSpace(rec.Kind)
+		if kindOrType == "" {
+			kindOrType = strings.TrimSpace(rec.Type)
+		}
+		if kindOrType == "user.message" {
+			text = strings.TrimSpace(rec.Text)
+		} else if strings.TrimSpace(rec.Role) == "user" {
+			text = strings.TrimSpace(rec.Content)
+		}
+		if text != "" {
+			text = resolveUserContent(text)
+			text = strings.TrimSpace(text)
+			if text == "" {
+				continue
+			}
+			if control.IsSyntheticUserMessage(text) {
+				continue
+			}
+			at := fallbackAt
+			if eventAt, ok := promptHistoryEventMillis(rec); ok {
+				at = eventAt
+			}
+			entry := PromptHistoryEntry{
+				Text:        text,
+				At:          at,
+				SessionPath: path,
+				Turn:        turn,
+			}
+			emit(entry)
+			turn++
+		}
+	}
+	return nil
+}
+
+func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {
+	if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {
+		return meta.UpdatedAt.UnixMilli()
+	}
+	if info != nil {
+		return info.ModTime().UnixMilli()
+	}
+	return 0
+}
+
+func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {
+	for _, raw := range []json.RawMessage{
+		rec.Time,
+		rec.Timestamp,
+		rec.CreatedAt,
+		rec.CreatedAtSnake,
+		rec.UpdatedAt,
+		rec.UpdatedAtSnake,
+	} {
+		if at, ok := parseJSONTimestampMillis(raw); ok {
+			return at, true
+		}
+	}
+	return 0, false
+}
+
+func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {
+	if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
+		return 0, false
+	}
+
+	var s string
+	if err := json.Unmarshal(raw, &s); err == nil {
+		s = strings.TrimSpace(s)
+		if s == "" {
+			return 0, false
+		}
+		if n, err := strconv.ParseInt(s, 10, 64); err == nil {
+			return normalizeTimestampMillis(n)
+		}
+		if f, err := strconv.ParseFloat(s, 64); err == nil {
+			return normalizeTimestampMillisFloat(f)
+		}
+		if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
+			return t.UnixMilli(), true
+		}
+		return 0, false
+	}
+
+	dec := json.NewDecoder(bytes.NewReader(raw))
+	dec.UseNumber()
+	var n json.Number
+	if err := dec.Decode(&n); err != nil {
+		return 0, false
+	}
+	if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {
+		return normalizeTimestampMillis(i)
+	}
+	if f, err := strconv.ParseFloat(n.String(), 64); err == nil {
+		return normalizeTimestampMillisFloat(f)
+	}
+	return 0, false
+}
+
+func normalizeTimestampMillis(v int64) (int64, bool) {
+	if v <= 0 {
+		return 0, false
+	}
+	switch {
+	case v >= 1_000_000_000_000_000_000:
+		return v / 1_000_000, true // nanoseconds
+	case v >= 1_000_000_000_000_000:
+		return v / 1_000, true // microseconds
+	case v >= 100_000_000_000:
+		return v, true // milliseconds
+	case v >= 1_000_000_000:
+		return v * 1_000, true // seconds
+	default:
+		return 0, false
+	}
+}
+
+func normalizeTimestampMillisFloat(v float64) (int64, bool) {
+	if v <= 0 {
+		return 0, false
+	}
+	switch {
+	case v >= 1_000_000_000_000_000_000:
+		return int64(v / 1_000_000), true
+	case v >= 1_000_000_000_000_000:
+		return int64(v / 1_000), true
+	case v >= 100_000_000_000:
+		return int64(v), true
+	case v >= 1_000_000_000:
+		return int64(v * 1_000), true
+	default:
+		return 0, false
+	}
+}
+
+// PickWorkspace opens a folder chooser and, on a pick, opens a new project tab
+// scoped to that folder. Returns the chosen path ("" if cancelled).
+func (a *App) PickWorkspace() (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	cur, _ := os.Getwd()
+	a.mu.RLock()
+	if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
+		cur = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+		Title:            "Choose working folder",
+		DefaultDirectory: dialogDefaultDirectory(cur),
+	})
+	if err != nil || dir == "" {
+		return "", err
+	}
+	return a.SwitchWorkspace(dir)
+}
+
+func dialogDefaultDirectory(preferred string) string {
+	if dir := nearestExistingDirectory(preferred); dir != "" {
+		return dir
+	}
+	if cwd, err := os.Getwd(); err == nil {
+		if dir := nearestExistingDirectory(cwd); dir != "" {
+			return dir
+		}
+	}
+	if home, err := os.UserHomeDir(); err == nil {
+		if dir := nearestExistingDirectory(home); dir != "" {
+			return dir
+		}
+	}
+	return ""
+}
+
+func nearestExistingDirectory(path string) string {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return ""
+	}
+	if abs, err := filepath.Abs(path); err == nil {
+		path = abs
+	}
+	for {
+		info, err := os.Stat(path)
+		if err == nil {
+			if info.IsDir() {
+				return path
+			}
+			path = filepath.Dir(path)
+			continue
+		}
+		parent := filepath.Dir(path)
+		if parent == path {
+			return ""
+		}
+		path = parent
+	}
+}
+
+func (a *App) ListWorkspaces() []WorkspaceMeta {
+	migrateLegacyWorkspacesIntoProjects()
+	activeRoot := ""
+	cur, _ := os.Getwd()
+	a.mu.RLock()
+	if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
+		activeRoot = normalizeProjectRoot(tab.WorkspaceRoot)
+	}
+	a.mu.RUnlock()
+	if activeRoot == "" {
+		activeRoot = normalizeProjectRoot(cur)
+	}
+	projects := loadProjectsFile().Projects
+	out := make([]WorkspaceMeta, 0, len(projects))
+	for _, project := range projects {
+		out = append(out, WorkspaceMeta{
+			Path:    project.Root,
+			Name:    projectDisplayName(project),
+			Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
+		})
+	}
+	return out
+}
+
+func (a *App) RemoveWorkspace(dir string) error {
+	if dir == "" {
+		return fmt.Errorf("workspace path is required")
+	}
+	dir = normalizeProjectRoot(dir)
+
+	var fallback *WorkspaceTab
+	// sessionRemovalMu covers every step that can still touch this workspace's
+	// session files: snapshotting, unlinking the tab/runtime bindings, and
+	// closing the unlinked runtimes (quiescing autosave). Once a runtime is
+	// unlinked from a.tabs/detachedSessions it is invisible to
+	// DeleteSession/TrashTopic/RestoreSession, so it must stop writing before
+	// the lock is released. Project bookkeeping, the fallback controller build,
+	// and notifications run after release.
+	if err := func() error {
+		a.sessionRemovalMu.Lock()
+		defer a.sessionRemovalMu.Unlock()
+
+		type workspaceTabCandidate struct {
+			id  string
+			tab *WorkspaceTab
+		}
+
+		var closeTabs []*WorkspaceTab
+		var closeDetached []*WorkspaceTab
+		a.mu.Lock()
+		for _, tab := range a.tabs {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		for _, tab := range a.detachedSessions {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		candidates := make([]workspaceTabCandidate, 0)
+		for id, tab := range a.tabs {
+			if !tabInWorkspace(tab, dir) {
+				continue
+			}
+			candidates = append(candidates, workspaceTabCandidate{id: id, tab: tab})
+		}
+		a.mu.Unlock()
+
+		snapshotted := make(map[string]*WorkspaceTab, len(candidates))
+		for _, candidate := range candidates {
+			id, tab := candidate.id, candidate.tab
+			snapshotted[id] = tab
+			if err := a.snapshotTab(tab); err != nil {
+				slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
+				return fmt.Errorf("save current session before removing workspace: %w", err)
+			}
+		}
+
+		a.mu.Lock()
+		for _, tab := range a.tabs {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		for _, tab := range a.detachedSessions {
+			if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace has running sessions; stop them before removing")
+			}
+		}
+		for id, tab := range a.tabs {
+			if tabInWorkspace(tab, dir) && snapshotted[id] != tab {
+				a.mu.Unlock()
+				return fmt.Errorf("workspace tabs changed while removing; retry")
+			}
+		}
+		for _, candidate := range candidates {
+			id, tab := candidate.id, candidate.tab
+			if tab == nil || a.tabs[id] != tab || !tabInWorkspace(tab, dir) {
+				continue
+			}
+			a.markTabRemovedLocked(tab)
+			closeTabs = append(closeTabs, tab)
+			delete(a.tabs, id)
+			a.removeTabOrderLocked(id)
+			if a.activeTabID == id {
+				a.activeTabID = ""
+			}
+		}
+		for key, tab := range a.detachedSessions {
+			if !tabInWorkspace(tab, dir) {
+				continue
+			}
+			closeDetached = append(closeDetached, tab)
+			delete(a.detachedSessions, key)
+		}
+		if len(a.tabs) == 0 {
+			fallback = a.createTabEntry("global", globalTabWorkspaceRoot(), "")
+			fallback.TopicTitle = "Global"
+			fallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}
+			a.tabs[fallback.ID] = fallback
+			a.tabOrder = append(a.tabOrder, fallback.ID)
+			a.activeTabID = fallback.ID
+		} else if a.activeTabID == "" {
+			if ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {
+				a.activeTabID = ordered[0]
+			}
+		}
+		a.saveTabsLocked()
+		a.mu.Unlock()
+
+		for _, tab := range closeTabs {
+			a.closeTabRuntime(tab)
+		}
+		for _, tab := range closeDetached {
+			a.closeTabRuntime(tab)
+		}
+		return nil
+	}(); err != nil {
+		return err
+	}
+
+	// The fallback tab is already linked into a.tabs; its controller build is
+	// asynchronous and does not touch removed session files, so it does not
+	// need the removal lock.
+	if fallback != nil {
+		a.startTabControllerBuild(fallback)
+	}
+
+	forgetWorkspace(dir)
+	if err := removeProject(dir); err != nil {
+		return err
+	}
+	// If the removed workspace was the active one, clear the pointer
+	// so we don't leave a stale reference to a deleted project.
+	if loadWorkspace() == dir {
+		if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
+			// Fall back to the first remaining project
+			saveWorkspace(remaining.Projects[0].Root)
+		} else {
+			// No projects left; clear the active pointer entirely
+			clearWorkspace()
+		}
+	}
+	a.emitProjectTreeChanged()
+	return nil
+}
+
+func migrateLegacyWorkspacesIntoProjects() {
+	legacy := loadWorkspaces()
+	if len(legacy) == 0 {
+		return
+	}
+	_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
+		seen := make(map[string]bool, len(f.Projects)+len(legacy))
+		for _, p := range f.Projects {
+			seen[p.Root] = true
+		}
+		changed := false
+		for _, path := range legacy {
+			root := normalizeProjectRoot(path)
+			if root == "" || seen[root] {
+				continue
+			}
+			f.Projects = append(f.Projects, desktopProject{Root: root})
+			seen[root] = true
+			changed = true
+		}
+		return changed, nil
+	})
+}
+
+func workspaceName(path string) string {
+	name := filepath.Base(path)
+	if name == "." || name == string(filepath.Separator) || name == "" {
+		return path
+	}
+	return name
+}
+
+// tabWorkspaceNameForScope resolves the display name for a tab's workspace.
+// Callers pass tab.Scope copied under a.mu instead of re-reading the tab.
+func tabWorkspaceNameForScope(scope, cwd string) string {
+	if scope == "global" {
+		return globalProjectTitle()
+	}
+	return workspaceName(cwd)
+}
+
+func (a *App) SwitchWorkspace(dir string) (string, error) {
+	if dir == "" {
+		home, err := os.UserHomeDir()
+		if err != nil {
+			return "", err
+		}
+		dir = home
+	}
+	if abs, err := filepath.Abs(dir); err == nil {
+		dir = abs
+	}
+	info, err := os.Stat(dir)
+	if err != nil {
+		return "", err
+	}
+	if !info.IsDir() {
+		return "", fmt.Errorf("%s is not a directory", dir)
+	}
+	saveWorkspace(dir)
+
+	// Open a registered topic so the new workspace appears in the project tree
+	// immediately instead of only existing as an in-memory tab.
+	topic, err := a.CreateTopic("project", dir, "")
+	if err != nil {
+		return "", err
+	}
+	var meta TabMeta
+	if a.singleSurfaceLayoutEnabled() {
+		meta, err = a.ActivateTopic("project", dir, topic.ID, "")
+	} else {
+		meta, err = a.OpenProjectTab(dir, topic.ID)
+	}
+	if err != nil {
+		return "", err
+	}
+	return meta.WorkspaceRoot, nil
+}
+
+func (a *App) singleSurfaceLayoutEnabled() bool {
+	cfg, _, err := a.loadDesktopUserConfigForView()
+	if err != nil {
+		return true
+	}
+	return singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())
+}
+
+// HistoryMessage is one prior turn, for the frontend to repopulate its transcript
+// after a reload.
+type HistoryMessage struct {
+	Role               string                    `json:"role"`
+	Content            string                    `json:"content"`
+	Detail             string                    `json:"detail,omitempty"`
+	Code               string                    `json:"code,omitempty"`
+	SubmitText         string                    `json:"submitText,omitempty"`
+	CheckpointTurn     *int                      `json:"checkpointTurn,omitempty"`
+	Reasoning          string                    `json:"reasoning,omitempty"`
+	MemoryCitations    []provider.MemoryCitation `json:"memoryCitations,omitempty"`
+	WorkDurationMs     int64                     `json:"workDurationMs,omitempty"`
+	Level              string                    `json:"level,omitempty"`
+	ToolCalls          []HistoryToolCall         `json:"toolCalls,omitempty"`
+	ToolCallID         string                    `json:"toolCallId,omitempty"`
+	ToolName           string                    `json:"toolName,omitempty"`
+	ToolResultArchived bool                      `json:"toolResultArchived,omitempty"`
+	ToolResultError    string                    `json:"toolResultError,omitempty"`
+	Pending            bool                      `json:"pending,omitempty"`
+	Trigger            string                    `json:"trigger,omitempty"`
+	Messages           int                       `json:"messages,omitempty"`
+	Summary            string                    `json:"summary,omitempty"`
+	Archive            string                    `json:"archive,omitempty"`
+}
+
+type HistoryToolCall struct {
+	ID                string `json:"id"`
+	Name              string `json:"name"`
+	Arguments         string `json:"arguments"`
+	Subject           string `json:"subject,omitempty"`
+	Summary           string `json:"summary,omitempty"`
+	Diff              string `json:"diff,omitempty"`
+	Added             int    `json:"added,omitempty"`
+	Removed           int    `json:"removed,omitempty"`
+	ArgumentsArchived bool   `json:"argumentsArchived,omitempty"`
+}
+
+const (
+	defaultHistoryPageTurns = 60
+	maxHistoryPageTurns     = 200
+)
+
+type HistoryPage struct {
+	Messages   []HistoryMessage `json:"messages"`
+	StartTurn  int              `json:"startTurn"`
+	EndTurn    int              `json:"endTurn"`
+	TotalTurns int              `json:"totalTurns"`
+	HasOlder   bool             `json:"hasOlder"`
+}
+
+// History returns the session's message log.
+func (a *App) History() []HistoryMessage {
+	return a.HistoryForTab("")
+}
+
+func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage {
+	return a.HistoryPageForTab("", beforeTurn, limit)
+}
+
+func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	var sessionDir, sessionPath string
+	if tab != nil {
+		ctrl = tab.Ctrl
+		sessionDir = tabSessionDir(tab)
+		sessionPath = tab.currentSessionPath()
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		if strings.TrimSpace(sessionPath) == "" {
+			return HistoryPage{Messages: []HistoryMessage{}}
+		}
+		page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit)
+		if err != nil {
+			return HistoryPage{Messages: []HistoryMessage{}}
+		}
+		return page
+	}
+	msgs := ctrl.History()
+	dir := controllerSessionDir(ctrl)
+	path := ctrl.SessionPath()
+	return historyPageFromProviderMessages(
+		msgs,
+		sessionDisplayResolver(dir, path),
+		sessionPlannerDisplayTurns(dir, path),
+		ctrl.CheckpointTurnsByMessageIndex(),
+		beforeTurn,
+		limit,
+	)
+}
+
+func normalizeHistoryPageLimit(limit int) int {
+	if limit <= 0 {
+		return defaultHistoryPageTurns
+	}
+	if limit > maxHistoryPageTurns {
+		return maxHistoryPageTurns
+	}
+	return limit
+}
+
+func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage {
+	limit = normalizeHistoryPageLimit(limit)
+	totalTurns := 0
+	for _, msg := range messages {
+		if msg.Role == "user" {
+			totalTurns++
+		}
+	}
+	if beforeTurn <= 0 || beforeTurn > totalTurns {
+		beforeTurn = totalTurns
+	}
+	startTurn := beforeTurn - limit
+	if startTurn < 0 {
+		startTurn = 0
+	}
+	page := HistoryPage{
+		StartTurn:  startTurn,
+		EndTurn:    beforeTurn,
+		TotalTurns: totalTurns,
+		HasOlder:   startTurn > 0,
+	}
+	if len(messages) == 0 || startTurn >= beforeTurn {
+		page.Messages = []HistoryMessage{}
+		return page
+	}
+	page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn)
+	return page
+}
+
+func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage {
+	out := make([]HistoryMessage, 0, len(messages))
+	turn := -1
+	for _, msg := range messages {
+		if msg.Role == "user" {
+			turn++
+		}
+		if turn < 0 {
+			if startTurn == 0 {
+				out = append(out, msg)
+			}
+			continue
+		}
+		if turn >= startTurn && turn < endTurn {
+			out = append(out, msg)
+		}
+	}
+	return out
+}
+
+func (a *App) HistoryForTab(tabID string) []HistoryMessage {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	var sessionDir, sessionPath string
+	if tab != nil {
+		ctrl = tab.Ctrl
+		sessionDir = tabSessionDir(tab)
+		sessionPath = tab.currentSessionPath()
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		if strings.TrimSpace(sessionPath) == "" {
+			return []HistoryMessage{}
+		}
+		messages, err := previewSessionMessages(sessionDir, sessionPath)
+		if err != nil {
+			return []HistoryMessage{}
+		}
+		return messages
+	}
+	msgs := ctrl.History()
+	dir := controllerSessionDir(ctrl)
+	path := ctrl.SessionPath()
+	return historyMessagesWithPlannerDisplays(
+		msgs,
+		sessionDisplayResolver(dir, path),
+		sessionPlannerDisplayTurns(dir, path),
+		ctrl.CheckpointTurnsByMessageIndex(),
+	)
+}
+
+func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	if tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return []int{}
+	}
+	return historyCheckpointTurns(
+		ctrl.History(),
+		sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()),
+		ctrl.CheckpointTurnsByMessageIndex(),
+	)
+}
+
+func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int {
+	out := make([]int, 0)
+	for index, msg := range msgs {
+		if msg.Role != provider.RoleUser {
+			continue
+		}
+		if _, isSteer := agent.SteerText(msg.Content); isSteer {
+			continue
+		}
+		if control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) {
+			continue
+		}
+		turn, ok := checkpointTurns[index]
+		if !ok {
+			turn = -1
+		}
+		out = append(out, turn)
+	}
+	return out
+}
+
+func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {
+	return historyMessagesWithPlannerDisplays(msgs, resolveUserContent, nil, nil)
+}
+
+func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage {
+	replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)
+	toolResults := historyToolResultsByID(msgs)
+	return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, replayedTodoArgs, toolResults)
+}
+
+func historyMessagesWithPlannerDisplaysAndLookups(
+	msgs []provider.Message,
+	resolveUserContent func(string) string,
+	plannerTurns []plannerDisplayTurn,
+	checkpointTurns map[int]int,
+	replayedTodoArgs map[string]string,
+	toolResults map[string]provider.Message,
+) []HistoryMessage {
+	out := make([]HistoryMessage, 0, len(msgs))
+	plannerByUserHash := plannerTurnsByUserHash(plannerTurns)
+	for index, m := range msgs {
+		content := m.Content
+		var checkpointTurn *int
+		if m.Role == provider.RoleUser {
+			// Mid-turn steer messages are persisted in the session so they
+			// survive tab switches. They are surfaced as a notice (↪ text)
+			// — matching the live Steer event look — rather than as a
+			// regular user bubble or being filtered as synthetic (#4044).
+			// Check against the raw m.Content: resolveUserContent applies
+			// StripComposePrefixes which trims trailing whitespace.
+			if steerText, isSteer := agent.SteerText(m.Content); isSteer {
+				out = append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText})
+				continue
+			}
+			content = resolveUserContent(m.Content)
+			if control.IsSyntheticUserMessage(content) {
+				continue
+			}
+			if turn, ok := checkpointTurns[index]; ok {
+				turnCopy := turn
+				checkpointTurn = &turnCopy
+			}
+		}
+		reasoning := ""
+		if m.Role == provider.RoleAssistant {
+			reasoning = m.ReasoningContent
+		}
+		hm := HistoryMessage{Role: string(m.Role), Content: content, CheckpointTurn: checkpointTurn, Reasoning: reasoning, WorkDurationMs: m.WorkDurationMs}
+		if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
+			hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
+		}
+		if m.Role == provider.RoleUser && content != m.Content {
+			if agent.ContainsMemoryCompilerExecution(m.Content) {
+				// Never expose the compiler contract itself. A safely unwrapped
+				// slash invocation is useful display metadata, though: it lets the
+				// frontend restore the selected skill/subagent in history and trash.
+				if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
+					hm.SubmitText = replay
+				}
+			} else {
+				hm.SubmitText = m.Content
+			}
+		}
+		if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
+			hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
+			for i, tc := range m.ToolCalls {
+				args := tc.Arguments
+				if tc.Name == "todo_write" {
+					if replayed, ok := replayedTodoArgs[tc.ID]; ok {
+						args = replayed
+					}
+				}
+				hm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])
+			}
+		}
+		if m.Role == provider.RoleTool {
+			hm.ToolCallID = m.ToolCallID
+			hm.ToolName = m.Name
+			hm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != "")
+		}
+		out = append(out, hm)
+		if m.Role == provider.RoleUser {
+			if turns := plannerByUserHash[messageDisplayKey(m.Content)]; len(turns) > 0 {
+				out = append(out, cloneHistoryMessages(turns[0].Messages)...)
+				plannerByUserHash[messageDisplayKey(m.Content)] = turns[1:]
+			}
+		}
+	}
+	return out
+}
+
+func historyPageFromProviderMessages(
+	msgs []provider.Message,
+	resolveUserContent func(string) string,
+	plannerTurns []plannerDisplayTurn,
+	checkpointTurns map[int]int,
+	beforeTurn, limit int,
+) HistoryPage {
+	limit = normalizeHistoryPageLimit(limit)
+	totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent)
+	if beforeTurn <= 0 || beforeTurn > totalTurns {
+		beforeTurn = totalTurns
+	}
+	startTurn := beforeTurn - limit
+	if startTurn < 0 {
+		startTurn = 0
+	}
+	page := HistoryPage{
+		StartTurn:  startTurn,
+		EndTurn:    beforeTurn,
+		TotalTurns: totalTurns,
+		HasOlder:   startTurn > 0,
+	}
+	if len(msgs) == 0 || startTurn >= beforeTurn {
+		page.Messages = []HistoryMessage{}
+		return page
+	}
+	pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn)
+	page.Messages = historyMessagesWithPlannerDisplaysAndLookups(
+		pageMessages,
+		resolveUserContent,
+		plannerTurns,
+		checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes),
+		historyTodoArgsWithCompleteSteps(msgs),
+		historyToolResultsByID(msgs),
+	)
+	return page
+}
+
+func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int {
+	total := 0
+	for _, msg := range msgs {
+		if isVisibleHistoryUser(msg, resolveUserContent) {
+			total++
+		}
+	}
+	return total
+}
+
+func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool {
+	if msg.Role != provider.RoleUser {
+		return false
+	}
+	if _, isSteer := agent.SteerText(msg.Content); isSteer {
+		return false
+	}
+	return !control.IsSyntheticUserMessage(resolveUserContent(msg.Content))
+}
+
+func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) {
+	out := make([]provider.Message, 0, len(msgs))
+	indexes := make([]int, 0, len(msgs))
+	turn := -1
+	for index, msg := range msgs {
+		if isVisibleHistoryUser(msg, resolveUserContent) {
+			turn++
+		}
+		if turn < 0 {
+			if startTurn == 0 {
+				out = append(out, msg)
+				indexes = append(indexes, index)
+			}
+			continue
+		}
+		if turn >= startTurn && turn < endTurn {
+			out = append(out, msg)
+			indexes = append(indexes, index)
+		}
+	}
+	return out, indexes
+}
+
+func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int {
+	if len(checkpointTurns) == 0 || len(originalIndexes) == 0 {
+		return nil
+	}
+	out := map[int]int{}
+	for pageIndex, originalIndex := range originalIndexes {
+		if turn, ok := checkpointTurns[originalIndex]; ok {
+			out[pageIndex] = turn
+		}
+	}
+	return out
+}
+
+func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn {
+	out := map[string][]plannerDisplayTurn{}
+	for _, turn := range turns {
+		if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
+			continue
+		}
+		out[turn.UserHash] = append(out[turn.UserHash], turn)
+	}
+	return out
+}
+
+func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage {
+	if len(in) == 0 {
+		return nil
+	}
+	out := make([]HistoryMessage, len(in))
+	copy(out, in)
+	for i := range out {
+		if len(in[i].MemoryCitations) > 0 {
+			out[i].MemoryCitations = append([]provider.MemoryCitation(nil), in[i].MemoryCitations...)
+		}
+		if len(in[i].ToolCalls) > 0 {
+			out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...)
+		}
+	}
+	return out
+}
+
+const historyToolPreviewLimit = 2_000
+
+func historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {
+	call := HistoryToolCall{
+		ID:      tc.ID,
+		Name:    tc.Name,
+		Subject: historyToolSubject(tc.Name, args),
+		Summary: historyToolSummary(tc.Name, args, result.Content),
+		Diff:    tc.Diff,
+		Added:   tc.Added,
+		Removed: tc.Removed,
+	}
+	if tc.Name == "todo_write" {
+		call.Arguments = args
+		return call
+	}
+	if tc.ID == "" {
+		call.Arguments = args
+		return call
+	}
+	if args != "" {
+		call.ArgumentsArchived = true
+	}
+	return call
+}
+
+func historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {
+	out := map[string]provider.Message{}
+	for _, msg := range msgs {
+		if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
+			continue
+		}
+		out[msg.ToolCallID] = msg
+	}
+	return out
+}
+
+func historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {
+	if content == "" {
+		return "", false, ""
+	}
+	if !canArchive {
+		if historyToolResultFailed(content) {
+			return content, false, content
+		}
+		return content, false, ""
+	}
+	if historyToolResultFailed(content) {
+		display = clipHistoryToolPreview(strings.TrimSpace(content))
+		return display, display != content, display
+	}
+	return "", true, ""
+}
+
+func clipHistoryToolPreview(s string) string {
+	if len(s) <= historyToolPreviewLimit {
+		return s
+	}
+	return strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + "\n..."
+}
+
+func historyToolSubject(name, args string) string {
+	a := parseHistoryToolArgs(args)
+	var subject string
+	switch name {
+	case "bash":
+		subject = historyArgString(a, "command")
+	case "grep", "glob":
+		subject = firstNonEmpty(historyArgString(a, "pattern"), historyArgString(a, "path"))
+	case "web_fetch":
+		subject = historyArgString(a, "url")
+	case "task":
+		subject = firstNonEmpty(historyArgString(a, "description"), historyArgString(a, "prompt"))
+	case "run_skill":
+		subject = historyArgString(a, "name")
+	case "move_file":
+		src := historyArgString(a, "source_path")
+		dst := historyArgString(a, "destination_path")
+		if src != "" && dst != "" {
+			subject = src + " -> " + dst
+		} else {
+			subject = firstNonEmpty(src, dst)
+		}
+	case "remember":
+		subject = firstNonEmpty(historyArgString(a, "name"), historyArgString(a, "description"))
+	case "todo_write", "exit_plan_mode":
+		subject = ""
+	default:
+		subject = firstNonEmpty(historyArgString(a, "path"), historyArgString(a, "file_path"))
+	}
+	return clipSingleLine(subject, 240)
+}
+
+func historyToolSummary(name, args, output string) string {
+	if historyToolResultFailed(output) {
+		return ""
+	}
+	a := parseHistoryToolArgs(args)
+	switch name {
+	case "write_file":
+		if content := historyArgString(a, "content"); content != "" {
+			return fmt.Sprintf("%d lines", historyLineCount(content))
+		}
+	case "edit_file":
+		oldText := historyArgString(a, "old_string")
+		newText := historyArgString(a, "new_string")
+		if oldText != "" || newText != "" {
+			return fmt.Sprintf("%d -> %d lines", historyLineCount(oldText), historyLineCount(newText))
+		}
+	case "multi_edit":
+		if edits, ok := a["edits"].([]any); ok && len(edits) > 0 {
+			return fmt.Sprintf("%d edits", len(edits))
+		}
+	}
+	if output == "" {
+		return ""
+	}
+	switch name {
+	case "read_file":
+		if strings.HasPrefix(output, "(empty file)") {
+			return "empty file"
+		}
+		if arrows := strings.Count(output, "→"); arrows > 0 {
+			return fmt.Sprintf("%d lines", arrows)
+		}
+		return fmt.Sprintf("%d lines", historyLineCount(output))
+	case "grep":
+		return fmt.Sprintf("%d matches", historyNonEmptyLineCount(output))
+	case "glob":
+		return fmt.Sprintf("%d files", historyNonEmptyLineCount(output))
+	case "ls":
+		return fmt.Sprintf("%d entries", historyNonEmptyLineCount(output))
+	case "web_fetch":
+		return clipSingleLine(strings.SplitN(output, "\n", 2)[0], 80)
+	case "bash":
+		if strings.TrimSpace(output) == "" {
+			return "no output"
+		}
+		return fmt.Sprintf("%d lines", historyLineCount(output))
+	default:
+		return ""
+	}
+}
+
+func parseHistoryToolArgs(args string) map[string]any {
+	if args == "" {
+		return map[string]any{}
+	}
+	var out map[string]any
+	if err := json.Unmarshal([]byte(args), &out); err != nil {
+		return map[string]any{}
+	}
+	return out
+}
+
+func historyArgString(args map[string]any, key string) string {
+	if v, ok := args[key].(string); ok {
+		return v
+	}
+	return ""
+}
+
+func historyLineCount(s string) int {
+	if s == "" {
+		return 0
+	}
+	s = strings.TrimSuffix(s, "\n")
+	if s == "" {
+		return 0
+	}
+	return strings.Count(s, "\n") + 1
+}
+
+func historyNonEmptyLineCount(s string) int {
+	count := 0
+	for _, line := range strings.Split(s, "\n") {
+		if strings.TrimSpace(line) != "" {
+			count++
+		}
+	}
+	return count
+}
+
+func clipSingleLine(s string, max int) string {
+	s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return clipStringBytes(s, max)
+	}
+	return clipStringBytes(s, max-3) + "..."
+}
+
+func clipStringBytes(s string, max int) string {
+	if max <= 0 {
+		return ""
+	}
+	if len(s) <= max {
+		return s
+	}
+	for max > 0 && !utf8.RuneStart(s[max]) {
+		max--
+	}
+	return s[:max]
+}
+
+func historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {
+	successful := successfulHistoryToolCallIDs(msgs)
+	out := map[string]string{}
+	var todos []evidence.TodoItem
+	latestTodoID := ""
+	for _, m := range msgs {
+		for _, tc := range m.ToolCalls {
+			if tc.ID == "" || !successful[tc.ID] {
+				continue
+			}
+			switch tc.Name {
+			case "todo_write":
+				rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
+				if len(rec.Todos) == 0 {
+					continue
+				}
+				todos = append([]evidence.TodoItem(nil), rec.Todos...)
+				latestTodoID = tc.ID
+				if args, ok := todoArgsJSON(todos); ok {
+					out[latestTodoID] = args
+				}
+			case "complete_step":
+				if latestTodoID == "" || len(todos) == 0 {
+					continue
+				}
+				rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
+				match, ok := evidence.MatchStep(rec.Step, todos)
+				if !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == "completed" {
+					continue
+				}
+				todos[match.Index-1].Status = "completed"
+				promoteNextHistoryTodo(todos)
+				if args, ok := todoArgsJSON(todos); ok {
+					out[latestTodoID] = args
+				}
+			}
+		}
+	}
+	return out
+}
+
+func successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {
+	successful := map[string]bool{}
+	for _, msg := range msgs {
+		if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
+			continue
+		}
+		if !historyToolResultFailed(msg.Content) {
+			successful[msg.ToolCallID] = true
+		}
+	}
+	return successful
+}
+
+func historyToolResultFailed(content string) bool {
+	content = strings.TrimSpace(content)
+	return strings.HasPrefix(content, "error:") ||
+		strings.HasPrefix(content, "blocked:") ||
+		strings.HasPrefix(content, "Error:") ||
+		strings.HasPrefix(content, "[error")
+}
+
+func todoArgsJSON(todos []evidence.TodoItem) (string, bool) {
+	b, err := json.Marshal(map[string]any{"todos": todos})
+	if err != nil {
+		return "", false
+	}
+	return string(b), true
+}
+
+func promoteNextHistoryTodo(todos []evidence.TodoItem) {
+	for _, todo := range todos {
+		if todoStatusForHistory(todo.Status) == "in_progress" {
+			return
+		}
+	}
+	for i := range todos {
+		if todoStatusForHistory(todos[i].Status) == "pending" {
+			todos[i].Status = "in_progress"
+			return
+		}
+	}
+}
+
+func todoStatusForHistory(status string) string {
+	status = strings.TrimSpace(status)
+	if status == "" {
+		return "pending"
+	}
+	return status
+}
+
+func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {
+	sessionPath, _, err := validateSessionPath(sessionDir, path)
+	if err != nil {
+		return nil, err
+	}
+	if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
+		return out, err
+	}
+	loaded, err := agent.LoadSession(sessionPath)
+	if err != nil {
+		return nil, err
+	}
+	return historyMessagesWithPlannerDisplays(
+		loaded.Snapshot(),
+		sessionDisplayResolver(sessionDir, sessionPath),
+		sessionPlannerDisplayTurns(sessionDir, sessionPath),
+		nil,
+	), nil
+}
+
+func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) {
+	sessionPath, _, err := validateSessionPath(sessionDir, path)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
+		if err != nil {
+			return HistoryPage{}, err
+		}
+		return historyPageFromMessages(out, beforeTurn, limit), nil
+	}
+	loaded, err := agent.LoadSession(sessionPath)
+	if err != nil {
+		return HistoryPage{}, err
+	}
+	return historyPageFromProviderMessages(
+		loaded.Snapshot(),
+		sessionDisplayResolver(sessionDir, sessionPath),
+		sessionPlannerDisplayTurns(sessionDir, sessionPath),
+		nil,
+		beforeTurn,
+		limit,
+	), nil
+}
+
+type previewEventRecord struct {
+	Kind             string                    `json:"kind"`
+	Type             string                    `json:"type"`
+	Role             string                    `json:"role"`
+	Time             json.RawMessage           `json:"time"`
+	Timestamp        json.RawMessage           `json:"timestamp"`
+	CreatedAt        json.RawMessage           `json:"createdAt"`
+	CreatedAtSnake   json.RawMessage           `json:"created_at"`
+	UpdatedAt        json.RawMessage           `json:"updatedAt"`
+	UpdatedAtSnake   json.RawMessage           `json:"updated_at"`
+	Text             string                    `json:"text"`
+	Detail           string                    `json:"detail"`
+	Code             string                    `json:"code"`
+	Content          string                    `json:"content"`
+	Reasoning        string                    `json:"reasoning"`
+	ReasoningContent string                    `json:"reasoningContent"`
+	MemoryCitations  []provider.MemoryCitation `json:"memoryCitations"`
+	Level            string                    `json:"level"`
+	ToolCalls        []previewToolCall         `json:"toolCalls"`
+	CallID           string                    `json:"callId"`
+	ToolCallID       string                    `json:"toolCallId"`
+	ToolName         string                    `json:"toolName"`
+	Name             string                    `json:"name"`
+	Output           string                    `json:"output"`
+	Compaction       *previewCompaction        `json:"compaction"`
+	Trigger          string                    `json:"trigger"`
+	Messages         int                       `json:"messages"`
+	Summary          string                    `json:"summary"`
+	Archive          string                    `json:"archive"`
+}
+
+type previewToolCall struct {
+	ID        string `json:"id"`
+	Name      string `json:"name"`
+	Arguments string `json:"arguments"`
+	Function  struct {
+		Name      string `json:"name"`
+		Arguments string `json:"arguments"`
+	} `json:"function"`
+}
+
+type previewCompaction struct {
+	Trigger  string `json:"trigger"`
+	Messages int    `json:"messages"`
+	Summary  string `json:"summary"`
+	Archive  string `json:"archive"`
+}
+
+func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, false, err
+	}
+	defer f.Close()
+
+	dec := json.NewDecoder(f)
+	out := []HistoryMessage{}
+	toolName := map[string]string{}
+	sawEvent := false
+	for {
+		var rec previewEventRecord
+		if err := dec.Decode(&rec); err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			if sawEvent {
+				return out, true, nil
+			}
+			return nil, false, nil
+		}
+		eventName := strings.TrimSpace(rec.Kind)
+		if eventName == "" {
+			eventName = strings.TrimSpace(rec.Type)
+		}
+		if eventName == "" {
+			continue
+		}
+		sawEvent = true
+		switch eventName {
+		case "user.message":
+			if rec.Text != "" {
+				out = append(out, HistoryMessage{Role: "user", Content: rec.Text})
+			}
+		case "model.final":
+			hm := HistoryMessage{Role: "assistant", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}
+			if len(rec.MemoryCitations) > 0 {
+				hm.MemoryCitations = append([]provider.MemoryCitation(nil), rec.MemoryCitations...)
+			}
+			for _, tc := range rec.ToolCalls {
+				id := tc.ID
+				name := firstNonEmpty(tc.Name, tc.Function.Name)
+				args := firstNonEmpty(tc.Arguments, tc.Function.Arguments)
+				hm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))
+				if id != "" {
+					toolName[id] = name
+				}
+			}
+			out = append(out, hm)
+		case "tool.result":
+			callID := firstNonEmpty(rec.CallID, rec.ToolCallID)
+			content := firstNonEmpty(rec.Output, rec.Content)
+			display, archived, errPreview := historyToolResultContent(content, callID != "")
+			if len(out) > 0 && callID != "" {
+				updateHistoryToolCallSummary(out, callID, content)
+			}
+			out = append(out, HistoryMessage{
+				Role:               "tool",
+				ToolCallID:         callID,
+				ToolName:           firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),
+				Content:            display,
+				ToolResultArchived: archived,
+				ToolResultError:    errPreview,
+			})
+		case "phase":
+			out = append(out, HistoryMessage{Role: "phase", Content: firstNonEmpty(rec.Text, rec.Content)})
+		case "notice":
+			level := rec.Level
+			if level != "warn" {
+				level = "info"
+			}
+			out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail, Code: rec.Code})
+		case "compaction_started":
+			c := rec.compactionPayload()
+			out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
+		case "compaction_done":
+			c := rec.compactionPayload()
+			out = append(out, HistoryMessage{
+				Role:     "compaction",
+				Trigger:  c.Trigger,
+				Messages: c.Messages,
+				Summary:  c.Summary,
+				Archive:  c.Archive,
+			})
+		}
+	}
+	return out, sawEvent, nil
+}
+
+func (r previewEventRecord) compactionPayload() previewCompaction {
+	if r.Compaction != nil {
+		return *r.Compaction
+	}
+	return previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}
+}
+
+func updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {
+	if callID == "" {
+		return
+	}
+	for i := len(out) - 1; i >= 0; i-- {
+		for j := range out[i].ToolCalls {
+			call := &out[i].ToolCalls[j]
+			if call.ID != callID {
+				continue
+			}
+			if call.Summary == "" {
+				call.Summary = historyToolSummary(call.Name, call.Arguments, output)
+			}
+			return
+		}
+	}
+}
+
+func firstNonEmpty(values ...string) string {
+	for _, value := range values {
+		if value != "" {
+			return value
+		}
+	}
+	return ""
+}
+
+// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
+// and Window both zero means no context-window data yet.
+type ContextInfo struct {
+	Used            int                         `json:"used"`
+	Window          int                         `json:"window"`
+	SessionTokens   int                         `json:"sessionTokens"`
+	CompactRatio    float64                     `json:"compactRatio,omitempty"`
+	SessionCost     float64                     `json:"sessionCost,omitempty"`
+	SessionCurrency string                      `json:"sessionCurrency,omitempty"`
+	CacheHitTokens  int                         `json:"cacheHitTokens,omitempty"`
+	CacheMissTokens int                         `json:"cacheMissTokens,omitempty"`
+	Sources         map[string]usageSourceStats `json:"sources,omitempty"`
+}
+
+// ContextUsage returns the latest context-window gauge numbers.
+func (a *App) ContextUsage() ContextInfo {
+	return a.ContextUsageForTab("")
+}
+
+func (a *App) ContextUsageForTab(tabID string) ContextInfo {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ctrl control.SessionAPI
+	if tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+
+	var info ContextInfo
+	if tab != nil {
+		// Re-key first: a controller-side rotation (typed /new) may have
+		// swapped sessions without the App noticing, and the stale totals
+		// would otherwise be reported — and then persisted — under the new
+		// session (#5850).
+		if ctrl != nil {
+			if sp := ctrl.SessionPath(); sp != "" {
+				tab.syncTelemetryToSession(sp)
+			}
+		}
+		snap := tab.telemetrySnapshot()
+		info.SessionTokens = snap.Usage.TotalTokens
+		info.SessionCost = snap.Usage.SessionCost
+		info.SessionCurrency = snap.Usage.SessionCurrency
+		info.CacheHitTokens = snap.Usage.CacheHitTokens
+		info.CacheMissTokens = snap.Usage.CacheMissTokens
+		info.Sources = snap.Usage.Sources
+	}
+	if ctrl == nil {
+		return info
+	}
+	used, window := ctrl.ContextSnapshot()
+	info.Used = used
+	info.Window = window
+	info.CompactRatio = ctrl.CompactRatio()
+	return info
+}
+
+// BalanceInfo is the wallet-balance readout for the status bar. Available is true
+// only when a balance was fetched; Display is the formatted amount (e.g. "¥110.00")
+// and is "" when the active provider declares no balance_url — the frontend then
+// omits the readout. Err carries a fetch failure for an optional tooltip.
+type BalanceInfo struct {
+	Available bool   `json:"available"`
+	Display   string `json:"display"`
+	Err       string `json:"err,omitempty"`
+}
+
+// Balance queries the active provider's wallet balance (a network call). It
+// returns an empty (unavailable) readout when no provider balance_url is set, the
+// controller is down, or the fetch fails — so the status bar simply shows nothing
+// rather than an error.
+func (a *App) Balance() BalanceInfo {
+	return a.BalanceForTab("")
+}
+
+func (a *App) BalanceForTab(tabID string) BalanceInfo {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return BalanceInfo{}
+	}
+	b, err := ctrl.Balance(a.ctx)
+	if err != nil {
+		return BalanceInfo{Err: err.Error()}
+	}
+	if b == nil {
+		return BalanceInfo{} // provider declares no balance endpoint
+	}
+	return BalanceInfo{Available: true, Display: b.Display()}
+}
+
+// JobView is one running background job (bash/task started with
+// run_in_background) for the status-bar indicator.
+type JobView struct {
+	ID        string `json:"id"`
+	Kind      string `json:"kind"`
+	Label     string `json:"label"`
+	Status    string `json:"status"`
+	StartedAt int64  `json:"startedAt"`
+}
+
+// Jobs returns the still-running background jobs for the status bar. It refreshes
+// on demand (mount, turn end, and on each notice the frontend receives).
+func (a *App) Jobs() []JobView {
+	out := []JobView{}
+	ctrl := a.ctrlByTabID("")
+	return a.jobsForCtrl(ctrl, out)
+}
+
+func (a *App) JobsForTab(tabID string) []JobView {
+	out := []JobView{}
+	ctrl := a.ctrlByTabID(tabID)
+	return a.jobsForCtrl(ctrl, out)
+}
+
+func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
+	if ctrl == nil {
+		return out
+	}
+	for _, v := range ctrl.Jobs() {
+		out = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})
+	}
+	return out
+}
+
+// Meta describes the session for the frontend's header and status line.
+type Meta struct {
+	Label             string                   `json:"label"`
+	Ready             bool                     `json:"ready"`
+	StartupErr        string                   `json:"startupErr,omitempty"`
+	EventChannel      string                   `json:"eventChannel"`
+	Cwd               string                   `json:"cwd"`
+	WorkspaceRoot     string                   `json:"workspaceRoot,omitempty"`
+	WorkspaceName     string                   `json:"workspaceName,omitempty"`
+	WorkspacePath     string                   `json:"workspacePath,omitempty"`
+	GitBranch         string                   `json:"gitBranch,omitempty"`
+	ImageInputEnabled bool                     `json:"imageInputEnabled"`
+	AutoApproveTools  bool                     `json:"autoApproveTools"`
+	Bypass            bool                     `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
+	CollaborationMode string                   `json:"collaborationMode"`
+	ToolApprovalMode  string                   `json:"toolApprovalMode"`
+	TokenMode         string                   `json:"tokenMode"`
+	Goal              string                   `json:"goal,omitempty"`
+	GoalStatus        string                   `json:"goalStatus,omitempty"`
+	AutoResearch      *AutoResearchCompactView `json:"autoResearch,omitempty"`
+}
+
+type AutoResearchCompactView struct {
+	TaskID        string `json:"taskId"`
+	Status        string `json:"status"`
+	Iteration     int    `json:"iteration"`
+	PivotRequired bool   `json:"pivotRequired"`
+	StaleCount    int    `json:"staleCount"`
+}
+
+type AutoResearchCriterionView struct {
+	ID            string `json:"id"`
+	Description   string `json:"description"`
+	Required      bool   `json:"required"`
+	EvidenceCount int    `json:"evidenceCount"`
+	Status        string `json:"status"`
+}
+
+type AutoResearchStatusView struct {
+	TaskID             string                      `json:"taskId"`
+	Goal               string                      `json:"goal"`
+	Status             string                      `json:"status"`
+	Iteration          int                         `json:"iteration"`
+	CurrentDirection   string                      `json:"currentDirection"`
+	StaleCount         int                         `json:"staleCount"`
+	PivotCount         int                         `json:"pivotCount"`
+	PivotRequired      bool                        `json:"pivotRequired"`
+	LastHeartbeatAt    string                      `json:"lastHeartbeatAt"`
+	FindingCount       int                         `json:"findingCount"`
+	OpenCriteria       []AutoResearchCriterionView `json:"openCriteria"`
+	Blocker            string                      `json:"blocker"`
+	TaskPath           string                      `json:"taskPath"`
+	NextRequiredAction string                      `json:"nextRequiredAction"`
+}
+
+type AutoResearchFindingView struct {
+	ID        string   `json:"id"`
+	Kind      string   `json:"kind"`
+	Summary   string   `json:"summary"`
+	Source    string   `json:"source"`
+	Command   string   `json:"command,omitempty"`
+	Paths     []string `json:"paths,omitempty"`
+	Accepted  bool     `json:"accepted"`
+	CreatedAt string   `json:"createdAt"`
+}
+
+type AutoResearchEvidenceView struct {
+	ID       string   `json:"id"`
+	Kind     string   `json:"kind"`
+	Summary  string   `json:"summary"`
+	Source   string   `json:"source"`
+	Command  string   `json:"command,omitempty"`
+	Paths    []string `json:"paths,omitempty"`
+	Accepted bool     `json:"accepted"`
+}
+
+// Meta reports the model label, readiness, any startup error, the working
+// directory (for the status line), and the runtime event channel the frontend
+// subscribes to.
+func (a *App) Meta() Meta {
+	return a.MetaForTab("")
+}
+
+func (a *App) imageInputEnabledForTab(tabID string) bool {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	var ref, root string
+	if tab != nil {
+		ref = tab.model
+		root = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	if tab == nil {
+		return false
+	}
+	cfg, err := config.LoadForRoot(root)
+	if err == nil && ref == "" {
+		ref = cfg.DefaultModel
+	}
+	if err != nil || ref == "" {
+		return false
+	}
+	entry, ok := cfg.ResolveModel(ref)
+	return ok && config.EffectiveVision(entry)
+}
+
+func (a *App) MetaForTab(tabID string) Meta {
+	a.mu.RLock()
+	tab := a.tabByIDLocked(tabID)
+	snap := snapshotTabRuntimeLocked(tab)
+	a.mu.RUnlock()
+	if tab == nil {
+		return Meta{EventChannel: eventChannel}
+	}
+	cwd := snap.workspaceRoot
+	if cwd == "" {
+		cwd, _ = os.Getwd()
+	}
+	autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
+	collaborationMode := snap.collaborationMode()
+	toolApprovalMode := snap.currentToolApprovalMode()
+	tokenMode := snap.currentTokenMode()
+	goal := snap.currentGoal()
+	goalStatus := snap.currentGoalStatus()
+	return Meta{
+		Label:             snap.label,
+		Ready:             snap.ready,
+		StartupErr:        snap.startupErr,
+		EventChannel:      eventChannel,
+		Cwd:               cwd,
+		WorkspaceRoot:     cwd,
+		WorkspaceName:     tabWorkspaceNameForScope(snap.scope, cwd),
+		WorkspacePath:     cwd,
+		GitBranch:         workspaceGitBranchForMeta(cwd),
+		ImageInputEnabled: a.imageInputEnabledForTab(tabID),
+		AutoApproveTools:  autoApproveTools,
+		Bypass:            autoApproveTools,
+		CollaborationMode: collaborationMode,
+		ToolApprovalMode:  toolApprovalMode,
+		TokenMode:         tokenMode,
+		Goal:              goal,
+		GoalStatus:        goalStatus,
+		AutoResearch:      compactAutoResearchFromController(snap.ctrl),
+	}
+}
+
+func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView {
+	if ctrl == nil {
+		return nil
+	}
+	summary, ok := ctrl.AutoResearchSummary()
+	if !ok || summary == nil || summary.TaskID == "" {
+		return nil
+	}
+	return &AutoResearchCompactView{
+		TaskID:        summary.TaskID,
+		Status:        summary.Status,
+		Iteration:     summary.Iteration,
+		PivotRequired: summary.PivotRequired,
+		StaleCount:    summary.StaleCount,
+	}
+}
+
+func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView {
+	if tab == nil || tab.Ctrl == nil {
+		return nil
+	}
+	summary, ok := tab.Ctrl.AutoResearchSummary()
+	if !ok || summary == nil || summary.TaskID == "" {
+		return nil
+	}
+	return &AutoResearchCompactView{
+		TaskID:        summary.TaskID,
+		Status:        summary.Status,
+		Iteration:     summary.Iteration,
+		PivotRequired: summary.PivotRequired,
+		StaleCount:    summary.StaleCount,
+	}
+}
+
+func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView {
+	if summary == nil {
+		return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
+	}
+	open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria))
+	for _, criterion := range summary.OpenCriteria {
+		open = append(open, AutoResearchCriterionView{
+			ID:            criterion.ID,
+			Description:   criterion.Description,
+			Required:      criterion.Required,
+			EvidenceCount: criterion.EvidenceCount,
+			Status:        criterion.Status,
+		})
+	}
+	return AutoResearchStatusView{
+		TaskID:             summary.TaskID,
+		Goal:               summary.Goal,
+		Status:             summary.Status,
+		Iteration:          summary.Iteration,
+		CurrentDirection:   summary.CurrentDirection,
+		StaleCount:         summary.StaleCount,
+		PivotCount:         summary.PivotCount,
+		PivotRequired:      summary.PivotRequired,
+		LastHeartbeatAt:    summary.LastHeartbeatAt.Format(time.RFC3339),
+		FindingCount:       summary.FindingCount,
+		OpenCriteria:       open,
+		Blocker:            summary.Blocker,
+		TaskPath:           summary.TaskPath,
+		NextRequiredAction: summary.NextRequiredAction,
+	}
+}
+
+func (a *App) AutoResearchCurrent() AutoResearchStatusView {
+	return a.AutoResearchStatus("")
+}
+
+func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
+	}
+	summary, ok := ctrl.AutoResearchSummary()
+	if !ok {
+		return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
+	}
+	return autoResearchStatusView(summary)
+}
+
+func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return []AutoResearchStatusView{}
+	}
+	summaries, ok := ctrl.AutoResearchList()
+	if !ok {
+		return []AutoResearchStatusView{}
+	}
+	out := make([]AutoResearchStatusView, 0, len(summaries))
+	for i := range summaries {
+		out = append(out, autoResearchStatusView(&summaries[i]))
+	}
+	return out
+}
+
+func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return []AutoResearchFindingView{}
+	}
+	findings, ok := ctrl.AutoResearchFindings(limit)
+	if !ok {
+		return []AutoResearchFindingView{}
+	}
+	out := make([]AutoResearchFindingView, 0, len(findings))
+	for _, finding := range findings {
+		out = append(out, AutoResearchFindingView{
+			ID:        finding.ID,
+			Kind:      finding.Kind,
+			Summary:   finding.Summary,
+			Source:    finding.Source,
+			Command:   finding.Command,
+			Paths:     append([]string(nil), finding.Paths...),
+			Accepted:  finding.Accepted,
+			CreatedAt: finding.CreatedAt.Format(time.RFC3339),
+		})
+	}
+	return out
+}
+
+func (a *App) AutoResearchOpenTask(tabID string) error {
+	status := a.AutoResearchStatus(tabID)
+	if strings.TrimSpace(status.TaskPath) == "" {
+		return os.ErrInvalid
+	}
+	return a.RevealPath(status.TaskPath)
+}
+
+func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error {
+	ctrl := a.ctrlByTabID(tabID)
+	if ctrl == nil {
+		return os.ErrInvalid
+	}
+	return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{
+		ID:       input.ID,
+		Kind:     input.Kind,
+		Summary:  input.Summary,
+		Source:   input.Source,
+		Command:  input.Command,
+		Paths:    append([]string(nil), input.Paths...),
+		Accepted: input.Accepted,
+	})
+}
+
+func (a *App) SetGoal(goal string) {
+	a.SetGoalForTab("", goal)
+}
+
+func (a *App) SetGoalForTab(tabID, goal string) {
+	goal = strings.TrimSpace(goal)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	tab.goal = goal
+	if goal != "" {
+		tab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo)
+	}
+	ctrl := tab.Ctrl
+	plan := tabModeHasPlan(tab.mode)
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	if ctrl != nil {
+		ctrl.SetPlanMode(plan)
+		ctrl.SetGoal(goal)
+	}
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+func (a *App) ClearGoal() {
+	a.SetGoal("")
+}
+
+func (a *App) ClearGoalForTab(tabID string) {
+	a.SetGoalForTab(tabID, "")
+}
+
+// SetAutoApproveTools toggles YOLO/full-access tool auto-approval:
+// approval-gated tool calls run without asking, while ask questions and plan
+// approvals still wait for the user. Runtime-only — not written to config.
+func (a *App) SetAutoApproveTools(on bool) {
+	if on {
+		a.SetToolApprovalModeForTab("", control.ToolApprovalYolo)
+		return
+	}
+	a.SetToolApprovalModeForTab("", control.ToolApprovalAsk)
+}
+
+// SetBypass is the legacy Wails binding for SetAutoApproveTools.
+func (a *App) SetBypass(on bool) {
+	a.SetAutoApproveTools(on)
+}
+
+func (a *App) SetToolApprovalMode(mode string) {
+	a.SetToolApprovalModeForTab("", mode)
+}
+
+func (a *App) SetToolApprovalModeForTab(tabID, mode string) {
+	mode = normalizeToolApprovalMode(mode)
+	a.mu.Lock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		a.mu.Unlock()
+		return
+	}
+	tab.toolApprovalMode = mode
+	tab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo)
+	ctrl := tab.Ctrl
+	tabIDForSave := tab.ID
+	a.mu.Unlock()
+	applyTabToolApprovalModeToController(ctrl, mode)
+	a.mu.Lock()
+	if a.tabs[tabIDForSave] == tab {
+		a.saveTabsLocked()
+	}
+	a.mu.Unlock()
+}
+
+// CommandInfo describes one available slash command for the composer's "/" menu.
+type CommandInfo struct {
+	Name        string `json:"name"` // without the leading slash
+	Description string `json:"description"`
+	Hint        string `json:"hint,omitempty"`  // argument hint, if any
+	Kind        string `json:"kind"`            // "builtin" | "custom" | "mcp" | "skill" | "subagent"
+	Group       string `json:"group,omitempty"` // menu group; older frontends can ignore it
+	Plugin      string `json:"plugin,omitempty"`
+	Color       string `json:"color,omitempty"`
+}
+
+// Commands lists the slash commands available this session — built-in actions,
+// custom commands (.reasonix/commands), and MCP prompts — for the composer's "/"
+// autocomplete menu.
+func (a *App) Commands() []CommandInfo {
+	out := []CommandInfo{
+		{Name: "new", Description: i18n.M.CmdNew, Kind: "builtin", Group: "actions"},
+		{Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin", Group: "actions"},
+		{Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin", Group: "actions"},
+		{Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions"},
+		{Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management"},
+		{Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions"},
+		{Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management"},
+		{Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management"},
+		{Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"},
+		{Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin", Group: "management"},
+		{Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin", Group: "integrations"},
+		{Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin", Group: "management"},
+		{Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin", Group: "integrations"},
+		{Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin", Group: "management"},
+		{Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin", Group: "skills"},
+		{Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin", Group: "management"},
+	}
+	a.mu.RLock()
+	ctrl := a.activeCtrlLocked()
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return out
+	}
+	// Skills are invocable as slash commands (the model runs inline ones; subagent ones
+	// run isolated). Listing them here is what surfaces /init, /explore, … in the
+	// composer's slash menu; selecting one submits its displayed slash name, which the controller
+	// resolves via RunSkill.
+	for _, s := range ctrl.SlashSkills() {
+		kind := "skill"
+		if s.RunAs == skill.RunSubagent {
+			kind = "subagent"
+		}
+		group := "skills"
+		if kind == "subagent" {
+			group = "subagents"
+		}
+		out = append(out, CommandInfo{Name: s.SlashName(), Description: s.Description, Kind: kind, Group: group, Plugin: s.Plugin, Color: s.Color})
+	}
+	for _, c := range ctrl.Commands() {
+		if c.Hidden {
+			continue
+		}
+		out = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: "custom", Group: "skills", Plugin: c.Plugin})
+	}
+	if h := ctrl.Host(); h != nil {
+		for _, p := range h.Prompts() {
+			out = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: "mcp", Group: "integrations"})
+		}
+	}
+	return out
+}
+
+// SlashArgItem is one sub-command / argument suggestion for the composer's slash
+// menu (the part after the command word). Mirrors the CLI's arg completion via
+// the shared control.SlashArgItems, so desktop and CLI offer the same hints.
+type SlashArgItem struct {
+	Label   string `json:"label"`
+	Insert  string `json:"insert"`
+	Hint    string `json:"hint"`
+	Descend bool   `json:"descend"`
+}
+
+// SlashArgsResult carries the suggestions plus the byte offset in the input where
+// the current token begins, so the composer replaces just that token.
+type SlashArgsResult struct {
+	Items []SlashArgItem `json:"items"`
+	From  int            `json:"from"`
+}
+
+// SlashArgs completes the arguments of a management slash command (/mcp, /model,
+// /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty
+// Items means the input has no structured arguments to complete.
+func (a *App) SlashArgs(input string) SlashArgsResult {
+	a.mu.RLock()
+	ctrl := a.activeCtrlLocked()
+	model := ""
+	if tab := a.activeTabLocked(); tab != nil {
+		model = tab.model
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return SlashArgsResult{Items: []SlashArgItem{}}
+	}
+	data := control.ArgData{
+		Skills:          ctrl.Skills(),
+		DisabledSkills:  ctrl.DisabledSkills(),
+		ConfiguredMCP:   ctrl.ConfiguredMCPNames(),
+		DisconnectedMCP: ctrl.DisconnectedMCPNames(),
+		CurrentModel:    model,
+	}
+	if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil {
+		data.PluginNames = names
+	}
+	seen := map[string]bool{}
+	for _, m := range a.Models() {
+		data.ModelRefs = append(data.ModelRefs, m.Ref)
+		if m.Provider != "" && !seen[m.Provider] {
+			seen[m.Provider] = true
+			data.ProviderNames = append(data.ProviderNames, m.Provider)
+		}
+		if m.Current {
+			data.CurrentProvider = m.Provider
+		}
+	}
+	if h := ctrl.Host(); h != nil {
+		data.ServerNames = h.ServerNames()
+	}
+	items, from := control.SlashArgItems(input, data)
+	// Non-nil so it serializes as a JSON array, never null — the frontend filters
+	// over it directly.
+	out := SlashArgsResult{Items: []SlashArgItem{}, From: from}
+	for _, it := range items {
+		out.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})
+	}
+	return out
+}
+
+// CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP
+// servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.
+type CapabilitiesView struct {
+	Servers    []ServerView    `json:"servers"`
+	Skills     []SkillView     `json:"skills"`
+	SkillRoots []SkillRootView `json:"skillRoots"`
+	Plugins    []PluginView    `json:"plugins"`
+}
+
+// SkillsSettingsView is the skills management page's data, split from MCP
+// status so opening MCP settings does not scan skill roots.
+type SkillsSettingsView struct {
+	Skills     []SkillView     `json:"skills"`
+	SkillRoots []SkillRootView `json:"skillRoots"`
+}
+
+// ServerView is one MCP server for the drawer. Status is "connected" (with
+// tool/prompt/resource counts), "deferred" (enabled but idle), "failed" (with
+// the connection error), "initializing" (background startup in progress), or
+// "disabled".
+type ServerView struct {
+	Name                 string     `json:"name"`
+	Transport            string     `json:"transport"`
+	Status               string     `json:"status"`
+	StartIntent          string     `json:"startIntent,omitempty"`
+	RuntimeState         string     `json:"runtimeState,omitempty"`
+	BuiltIn              bool       `json:"builtIn,omitempty"`
+	Configured           bool       `json:"configured,omitempty"`
+	AutoStart            bool       `json:"autoStart"`
+	Tier                 string     `json:"tier,omitempty"`
+	Command              string     `json:"command,omitempty"`
+	Args                 []string   `json:"args,omitempty"`
+	URL                  string     `json:"url,omitempty"`
+	EnvKeys              []string   `json:"envKeys,omitempty"`
+	HeaderKeys           []string   `json:"headerKeys,omitempty"`
+	Tools                int        `json:"tools"`
+	Prompts              int        `json:"prompts"`
+	Resources            int        `json:"resources"`
+	HasTools             bool       `json:"hasTools,omitempty"`
+	Error                string     `json:"error,omitempty"`
+	ToolList             []ToolView `json:"toolList,omitempty"`
+	TrustedReadOnlyTools []string   `json:"trustedReadOnlyTools,omitempty"`
+	AuthStatus           string     `json:"authStatus,omitempty"`
+	AuthURL              string     `json:"authUrl,omitempty"`
+	AuthConfigured       bool       `json:"authConfigured,omitempty"`
+	ManagedByPlugin      string     `json:"managedByPlugin,omitempty"`
+}
+
+type ToolView struct {
+	Name         string `json:"name"`
+	Description  string `json:"description"`
+	ReadOnlyHint bool   `json:"readOnlyHint,omitempty"`
+}
+
+// SkillView is one discoverable skill for the drawer. Also backs the
+// Subagents settings surface: the frontend filters this same list to
+// RunAs=="subagent" rather than calling a second, redundant endpoint.
+type SkillView struct {
+	Name         string   `json:"name"`
+	Description  string   `json:"description"`
+	Scope        string   `json:"scope"`
+	RunAs        string   `json:"runAs"`
+	Enabled      bool     `json:"enabled"`
+	Plugin       string   `json:"plugin,omitempty"`
+	Model        string   `json:"model,omitempty"`
+	Effort       string   `json:"effort,omitempty"`
+	AllowedTools []string `json:"allowedTools,omitempty"`
+	Color        string   `json:"color,omitempty"`
+	// Invocation is the user-facing slash name; InvocationMode preserves the
+	// frontmatter policy used by the subagent profile editor.
+	Invocation     string `json:"invocation,omitempty"`
+	InvocationMode string `json:"invocationMode,omitempty"`
+	// Body is the skill's full markdown body (post-frontmatter) — the
+	// subagent profile editor pre-fills its system-prompt field from this.
+	Body string `json:"body,omitempty"`
+	// ConfiguredModel/ConfiguredEffort are the per-name overrides from
+	// cfg.Agent.SubagentModels/SubagentEfforts (internal/boot's
+	// subagentModelRef/subagentEffortRef read the same map at dispatch time).
+	// This is the only lever for a built-in subagent's model/effort, since
+	// built-ins have no editable frontmatter file to carry Model/Effort.
+	ConfiguredModel  string `json:"configuredModel,omitempty"`
+	ConfiguredEffort string `json:"configuredEffort,omitempty"`
+}
+
+type SkillRootSkillView struct {
+	Name         string   `json:"name"`
+	Description  string   `json:"description"`
+	Scope        string   `json:"scope"`
+	RunAs        string   `json:"runAs"`
+	Plugin       string   `json:"plugin,omitempty"`
+	Model        string   `json:"model,omitempty"`
+	Effort       string   `json:"effort,omitempty"`
+	AllowedTools []string `json:"allowedTools,omitempty"`
+	Color        string   `json:"color,omitempty"`
+	Invocation   string   `json:"invocation,omitempty"`
+}
+
+// SkillRootView is one skill discovery root for the drawer's Sources section.
+type SkillRootView struct {
+	Dir        string               `json:"dir"`
+	Scope      string               `json:"scope"`
+	Priority   int                  `json:"priority"`
+	Status     string               `json:"status"`
+	Configured bool                 `json:"configured"`
+	Removable  bool                 `json:"removable"`
+	Skills     int                  `json:"skills"`
+	SkillItems []SkillRootSkillView `json:"skillItems,omitempty"`
+	Warning    string               `json:"warning,omitempty"`
+}
+
+// Capabilities projects the session's MCP servers (connected + failed) and skills
+// for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.
+func (a *App) Capabilities() CapabilitiesView {
+	skills := a.SkillsSettings()
+	return CapabilitiesView{
+		Servers:    a.MCPServers(),
+		Skills:     skills.Skills,
+		SkillRoots: skills.SkillRoots,
+		Plugins:    a.Plugins(),
+	}
+}
+
+// MCPServers returns only MCP server status for settings pages that do not need
+// skill discovery.
+func (a *App) MCPServers() []ServerView {
+	return a.mcpServersView()
+}
+
+// SkillsSettings returns the skills management snapshot without MCP status.
+func (a *App) SkillsSettings() SkillsSettingsView {
+	out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	var ctrl control.SessionAPI
+	if tab != nil {
+		ctrl = tab.Ctrl
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return out
+	}
+
+	disabled := map[string]bool{}
+	var configuredModels, configuredEfforts map[string]string
+	if cfg, err := config.Load(); err == nil {
+		for _, name := range cfg.Skills.DisabledSkills {
+			if key := config.SkillNameKey(name); key != "" {
+				disabled[key] = true
+			}
+		}
+		configuredModels = cfg.Agent.SubagentModels
+		configuredEfforts = cfg.Agent.SubagentEfforts
+	}
+	for _, s := range ctrl.AllSkills() {
+		view := SkillView{
+			Name: s.Name, Description: s.Description,
+			Scope: string(s.Scope), RunAs: string(s.RunAs),
+			Enabled:          !disabled[config.SkillNameKey(s.Name)],
+			Plugin:           s.Plugin,
+			Model:            s.Model,
+			Effort:           s.Effort,
+			AllowedTools:     append([]string{}, s.AllowedTools...),
+			Color:            s.Color,
+			Invocation:       "/" + s.SlashName(),
+			InvocationMode:   s.Invocation,
+			ConfiguredModel:  subagentOverrideFor(configuredModels, s.Name),
+			ConfiguredEffort: subagentOverrideFor(configuredEfforts, s.Name),
+		}
+		// Body feeds only the Subagents editor's prompt prefill. Inline skills
+		// fold references/ into Body at load time (hundreds of KB for a rich
+		// skill library), and every Capabilities/Settings fetch would ship all
+		// of it across the JSON bridge for nothing.
+		if s.RunAs == skill.RunSubagent {
+			view.Body = s.Body
+		}
+		out.Skills = append(out.Skills, view)
+	}
+	out.SkillRoots = a.cachedSkillRootsView()
+	return out
+}
+
+// subagentOverrideFor resolves a per-name subagent override with the same
+// underscore/hyphen alias fallback the runtime dispatch uses
+// (boot.SubagentModelKeys) — an exact-key read would show a legacy
+// `security_review` config entry as "inherit default" while it still won at
+// dispatch time.
+func subagentOverrideFor(overrides map[string]string, name string) string {
+	for _, key := range boot.SubagentModelKeys(name) {
+		if v := strings.TrimSpace(overrides[key]); v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+// AvailableSubagentTools lists the tool names a subagent profile's
+// "available tools" picker may offer. Scoped to compile-time builtins for
+// v1 — MCP/plugin tools are per-session/per-connection and would need a new
+// live-registry accessor on control.Capabilities to enumerate safely; a
+// profile's allowed-tools already degrades gracefully (FilterRegistry drops
+// unknown names silently) if extended to MCP names by hand later. Tools that
+// are always excluded from every subagent regardless of an explicit
+// allowlist (agent.AlwaysHiddenSubagentTools) are left out entirely — they'd
+// be a selectable no-op otherwise.
+func (a *App) AvailableSubagentTools() []ToolView {
+	hidden := map[string]bool{}
+	for _, name := range agent.AlwaysHiddenSubagentTools() {
+		hidden[name] = true
+	}
+	entries := tool.BuiltinContractEntries()
+	out := make([]ToolView, 0, len(entries))
+	for _, e := range entries {
+		if hidden[e.Name] {
+			continue
+		}
+		out = append(out, ToolView{Name: e.Name, Description: e.Description, ReadOnlyHint: e.ReadOnly})
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+	return out
+}
+
+func (a *App) mcpServersView() []ServerView {
+	out := []ServerView{}
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	if tab == nil {
+		a.mu.RUnlock()
+		return out
+	}
+	ctrl := tab.Ctrl
+	disabled := make(map[string]ServerView, len(tab.disabledMCP))
+	for name, s := range tab.disabledMCP {
+		disabled[name] = s
+	}
+	order := append([]string(nil), tab.mcpOrder...)
+	workspaceRoot := tab.WorkspaceRoot
+	tabID := tab.ID
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return out
+	}
+	seen := map[string]bool{}
+	connected := map[string]bool{}
+	retainedDisabled := map[string]ServerView{}
+	configured := map[string]config.PluginEntry{}
+	managedByPlugin := map[string]string{}
+	var configuredEntries []config.PluginEntry
+	if cfg, err := config.LoadForRoot(workspaceRoot); err == nil {
+		configuredEntries = append(configuredEntries, cfg.Plugins...)
+		for _, p := range configuredEntries {
+			configured[p.Name] = p
+			if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
+				managedByPlugin[p.Name] = owner
+			}
+		}
+	}
+	if h := ctrl.Host(); h != nil {
+		for _, s := range h.Servers() {
+			if disabledView, ok := disabled[s.Name]; ok {
+				disabledView.Status = "disabled"
+				disabledView.RuntimeState = "idle"
+				disabledView.StartIntent = "off"
+				disabledView.Error = ""
+				if p, ok := configured[s.Name]; ok {
+					disabledView = withPluginConfig(disabledView, p)
+				}
+				out = append(out, disabledView)
+				retainedDisabled[s.Name] = disabledView
+				seen[s.Name] = true
+				delete(disabled, s.Name)
+				continue
+			}
+			seen[s.Name] = true
+			connected[s.Name] = true
+			view := ServerView{
+				Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready",
+				Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
+				HasTools: s.HasTools,
+				ToolList: pluginToolsToView(s.ToolList),
+			}
+			if p, ok := configured[s.Name]; ok {
+				view = withPluginConfig(view, p)
+			}
+			out = append(out, view)
+		}
+		for _, f := range h.Failures() {
+			seen[f.Name] = true
+			view := ServerView{
+				Name: f.Name, Transport: f.Transport, Status: "failed", RuntimeState: "issue", Error: f.Error,
+			}
+			if p, ok := configured[f.Name]; ok {
+				view = withPluginConfig(view, p)
+			}
+			out = append(out, view)
+		}
+		for _, name := range h.ConnectingServers() {
+			if seen[name] {
+				continue
+			}
+			seen[name] = true
+			view := ServerView{Name: name, Status: "initializing", RuntimeState: "connecting"}
+			if p, ok := configured[name]; ok {
+				view = withPluginConfig(view, p)
+			}
+			out = append(out, view)
+		}
+	}
+	// Configured servers that are neither connected, connecting, nor failed are
+	// idle: disabled/off or automatic background startup waiting for its next kick.
+	if len(configuredEntries) > 0 {
+		for _, p := range configuredEntries {
+			if seen[p.Name] {
+				continue
+			}
+			if s, ok := disabled[p.Name]; ok {
+				s.Status = "disabled"
+				s.RuntimeState = "idle"
+				s.StartIntent = "off"
+				s = withPluginConfig(s, p)
+				s.Error = ""
+				out = append(out, s)
+				retainedDisabled[p.Name] = s
+				seen[p.Name] = true
+				delete(disabled, p.Name)
+				continue
+			}
+			status := "disabled"
+			startIntent := "off"
+			if p.ShouldAutoStart() {
+				status = "deferred"
+				startIntent = mcpStartIntent(p)
+			}
+			out = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: "idle"}, p))
+			seen[p.Name] = true
+		}
+	}
+	out = orderServerViews(out, order)
+	for i := range out {
+		out[i].ManagedByPlugin = managedByPlugin[out[i].Name]
+	}
+
+	a.mu.Lock()
+	if tab, ok := a.tabs[tabID]; ok {
+		for name := range connected {
+			delete(retainedDisabled, name)
+		}
+		tab.disabledMCP = retainedDisabled
+		tab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)
+	}
+	a.mu.Unlock()
+	return out
+}
+
+func mcpStartIntent(p config.PluginEntry) string {
+	if !p.ShouldAutoStart() {
+		return "off"
+	}
+	return "automatic"
+}
+
+func mcpRuntimeState(status string) string {
+	switch status {
+	case "connected":
+		return "ready"
+	case "initializing":
+		return "connecting"
+	case "failed":
+		return "issue"
+	default:
+		return "idle"
+	}
+}
+
+func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
+	tt := p.Type
+	if tt == "" {
+		tt = "stdio"
+	}
+	v.Transport = tt
+	v.Configured = true
+	v.AutoStart = p.ShouldAutoStart()
+	v.Tier = p.ResolvedTier()
+	if v.StartIntent == "" {
+		v.StartIntent = mcpStartIntent(p)
+	}
+	if v.Status == "disabled" {
+		v.StartIntent = "off"
+	}
+	if v.RuntimeState == "" {
+		v.RuntimeState = mcpRuntimeState(v.Status)
+	}
+	v.Command = p.Command
+	v.Args = append([]string(nil), p.Args...)
+	v.URL = p.URL
+	v.TrustedReadOnlyTools = uniqueStrings(p.TrustedReadOnlyTools)
+	v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
+	v.EnvKeys = nil
+	v.HeaderKeys = nil
+	if len(p.Env) > 0 {
+		v.EnvKeys = make([]string, 0, len(p.Env))
+		for k := range p.Env {
+			v.EnvKeys = append(v.EnvKeys, k)
+		}
+		sort.Strings(v.EnvKeys)
+	}
+	if len(p.Headers) > 0 {
+		v.HeaderKeys = make([]string, 0, len(p.Headers))
+		for k := range p.Headers {
+			v.HeaderKeys = append(v.HeaderKeys, k)
+		}
+		sort.Strings(v.HeaderKeys)
+	}
+	auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)
+	v.AuthStatus = auth.Status
+	v.AuthURL = auth.URL
+	return v
+}
+
+const skillRootsCacheTTL = 10 * time.Second
+
+func (a *App) cachedSkillRootsView() []SkillRootView {
+	cwd, _ := os.Getwd()
+	cfg, _ := config.Load()
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	key := skillRootsCacheKey(cwd, cfg, userCfg)
+
+	now := time.Now()
+	a.skillRootsMu.Lock()
+	if a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {
+		roots := cloneSkillRootViews(a.skillRootsCache.roots)
+		a.skillRootsMu.Unlock()
+		return roots
+	}
+	a.skillRootsMu.Unlock()
+
+	roots := skillRootsViewFrom(cwd, cfg, userCfg)
+
+	a.skillRootsMu.Lock()
+	a.skillRootsCache = skillRootsCache{
+		key:   key,
+		at:    now,
+		roots: cloneSkillRootViews(roots),
+	}
+	a.skillRootsMu.Unlock()
+	return roots
+}
+
+func (a *App) invalidateSkillRootsCache() {
+	a.skillRootsMu.Lock()
+	a.skillRootsCache = skillRootsCache{}
+	a.skillRootsMu.Unlock()
+}
+
+func skillRootsView() []SkillRootView {
+	cwd, _ := os.Getwd()
+	cfg, _ := config.Load()
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	return skillRootsViewFrom(cwd, cfg, userCfg)
+}
+
+func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {
+	var custom []string
+	var excluded []string
+	maxDepth := 3
+	if cfg != nil {
+		custom = cfg.SkillCustomPaths()
+		excluded = cfg.SkillExcludedPaths()
+		maxDepth = cfg.SkillMaxDepth()
+	}
+	var pluginPaths map[string][]string
+	if cfg != nil {
+		pluginPaths = cfg.PluginPackageSkillOwners()
+	}
+	st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
+	counts := map[string]int{}
+	skillItems := map[string][]SkillRootSkillView{}
+	roots := st.Roots()
+	for _, sk := range st.SlashList() {
+		root := skillDisplayRoot(sk, roots)
+		counts[root]++
+		skillItems[root] = append(skillItems[root], SkillRootSkillView{
+			Name:         sk.Name,
+			Description:  sk.Description,
+			Scope:        string(sk.Scope),
+			RunAs:        string(sk.RunAs),
+			Plugin:       sk.Plugin,
+			Model:        sk.Model,
+			Effort:       sk.Effort,
+			AllowedTools: append([]string{}, sk.AllowedTools...),
+			Color:        sk.Color,
+			Invocation:   "/" + sk.SlashName(),
+		})
+	}
+	for root := range skillItems {
+		sort.Slice(skillItems[root], func(i, j int) bool {
+			return skillItems[root][i].Invocation < skillItems[root][j].Invocation
+		})
+	}
+	userConfigured := map[string]bool{}
+	if userCfg != nil {
+		for _, p := range userCfg.Skills.Paths {
+			userConfigured[config.CanonicalSkillPath(p)] = true
+		}
+	}
+	out := []SkillRootView{}
+	seenRoots := map[string]int{}
+	for _, r := range roots {
+		dir := config.CanonicalSkillPath(r.Dir)
+		view := SkillRootView{
+			Dir:        r.Dir,
+			Scope:      string(r.Scope),
+			Priority:   r.Priority + 1,
+			Status:     string(r.Status),
+			Configured: r.Scope == skill.ScopeCustom && userConfigured[dir],
+			Removable:  true,
+			Skills:     counts[dir],
+			SkillItems: skillItems[dir],
+		}
+		if idx, ok := seenRoots[dir]; ok {
+			out[idx] = mergeDuplicateSkillRootView(out[idx], view)
+			continue
+		}
+		seenRoots[dir] = len(out)
+		out = append(out, view)
+	}
+	if userCfg != nil {
+		for _, p := range userCfg.Skills.Paths {
+			if rootActive(out, p) {
+				continue
+			}
+			out = append(out, SkillRootView{
+				Dir:        p,
+				Scope:      string(skill.ScopeCustom),
+				Status:     "inactive",
+				Configured: true,
+				Removable:  true,
+				Warning:    "configured in user config but not active in this workspace; project [skills].paths may override it",
+			})
+		}
+	}
+	return out
+}
+
+func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {
+	existing.Configured = existing.Configured || duplicate.Configured
+	existing.Removable = existing.Removable || duplicate.Removable
+	if existing.Status != "ok" && duplicate.Status == "ok" {
+		existing.Status = duplicate.Status
+	}
+	if existing.Skills == 0 && duplicate.Skills > 0 {
+		existing.Skills = duplicate.Skills
+		existing.SkillItems = duplicate.SkillItems
+	}
+	if existing.Warning == "" {
+		existing.Warning = duplicate.Warning
+	}
+	return existing
+}
+
+func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
+	type cacheKey struct {
+		CWD       string   `json:"cwd"`
+		Custom    []string `json:"custom"`
+		Plugins   []string `json:"plugins"`
+		Excluded  []string `json:"excluded"`
+		MaxDepth  int      `json:"maxDepth"`
+		UserPaths []string `json:"userPaths"`
+	}
+	key := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}
+	if cfg != nil {
+		key.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())
+		for path, owners := range cfg.PluginPackageSkillOwners() {
+			for _, owner := range owners {
+				key.Plugins = append(key.Plugins, config.CanonicalSkillPath(path)+"\x00"+owner)
+			}
+		}
+		sort.Strings(key.Plugins)
+		key.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())
+		key.MaxDepth = cfg.SkillMaxDepth()
+	}
+	if userCfg != nil {
+		key.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)
+	}
+	b, err := json.Marshal(key)
+	if err != nil {
+		return fmt.Sprintf("%s|%v|%v|%v|%d|%v", key.CWD, key.Custom, key.Plugins, key.Excluded, key.MaxDepth, key.UserPaths)
+	}
+	return string(b)
+}
+
+func canonicalSkillPaths(paths []string) []string {
+	out := make([]string, 0, len(paths))
+	for _, p := range paths {
+		out = append(out, config.CanonicalSkillPath(p))
+	}
+	sort.Strings(out)
+	return out
+}
+
+func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
+	out := make([]SkillRootView, len(in))
+	for i, r := range in {
+		out[i] = r
+		out[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)
+	}
+	return out
+}
+
+func rootActive(roots []SkillRootView, path string) bool {
+	want := config.CanonicalSkillPath(path)
+	for _, r := range roots {
+		if config.CanonicalSkillPath(r.Dir) == want {
+			return true
+		}
+	}
+	return false
+}
+
+// PickSkillFolder opens a directory picker for adding custom skill roots. It only
+// returns a path; AddSkillPath performs normalization and writes config.
+func (a *App) PickSkillFolder() (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	cur, _ := os.Getwd()
+	dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+		Title:            "Choose skills folder",
+		DefaultDirectory: dialogDefaultDirectory(cur),
+	})
+	if err != nil || dir == "" {
+		return "", err
+	}
+	return normalizeSkillPath(dir), nil
+}
+
+// PickPluginFolder opens a directory picker for choosing a local plugin package
+// source. It returns the selected directory path; plugin install/plan performs
+// manifest validation and decides whether to copy or link the package.
+func (a *App) PickPluginFolder() (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	cur := a.activeWorkspaceRoot()
+	if strings.TrimSpace(cur) == "" {
+		cur, _ = os.Getwd()
+	}
+	dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
+		Title:            "Choose plugin folder",
+		DefaultDirectory: dialogDefaultDirectory(cur),
+	})
+	if err != nil || dir == "" {
+		return "", err
+	}
+	return filepath.Clean(dir), nil
+}
+
+// AddSkillPath adds a custom skill root to the user config and rebuilds the
+// controller so the skills index and slash menu reflect it immediately.
+func (a *App) AddSkillPath(path string) error {
+	path = normalizeSkillPath(path)
+	workspaceRoot := a.activeWorkspaceRoot()
+	err := a.applyConfigChange(func(c *config.Config) error {
+		if isConventionSkillRoot(path, workspaceRoot) {
+			return c.RestoreSkillPath(path)
+		}
+		return c.AddSkillPath(path)
+	})
+	if err == nil {
+		a.invalidateSkillRootsCache()
+	}
+	return err
+}
+
+// RemoveSkillPath removes a skill source from the user config and rebuilds. For
+// convention roots, it records a pseudo-delete in excluded_paths.
+func (a *App) RemoveSkillPath(path string) error {
+	path = normalizeSkillPath(path)
+	err := a.applyConfigChange(func(c *config.Config) error {
+		removed, err := c.RemoveSkillPath(path)
+		if err != nil || removed {
+			return err
+		}
+		return c.ExcludeSkillPath(path)
+	})
+	if err == nil {
+		a.invalidateSkillRootsCache()
+	}
+	return err
+}
+
+// RefreshSkills rebuilds the controller without changing config, reloading skill
+// discovery, the system prompt index, and slash completions.
+func (a *App) RefreshSkills() error {
+	a.invalidateSkillRootsCache()
+	if err := a.rebuild(); err != nil {
+		// The skill cache is already invalidated; refresh the runtime once the
+		// other window releases the session lease.
+		if _, ok := a.deferredRebuildWarning("skills", err); ok {
+			return nil
+		}
+		return err
+	}
+	return nil
+}
+
+// ReloadCommands rescans command directories and hot-swaps without restarting
+// the controller — no MCP disconnect, no hook rerun.
+func (a *App) ReloadCommands() error {
+	if a.ctx == nil {
+		return nil
+	}
+	_, ctrl := a.activeTabAndCtrl()
+	if ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if ctrl.Running() {
+		return fmt.Errorf("wait for the current turn to finish, then retry")
+	}
+	return ctrl.ReloadCommands(a.ctx)
+}
+
+// SetSkillEnabled persists a skill toggle and rebuilds the controller so the
+// prompt index, slash menu, and skill tools reflect it immediately.
+func (a *App) SetSkillEnabled(name string, enabled bool) error {
+	err := a.applyConfigChange(func(c *config.Config) error {
+		return c.SetSkillEnabled(name, enabled)
+	})
+	if err == nil {
+		a.invalidateSkillRootsCache()
+	}
+	return err
+}
+
+func normalizeSkillPath(path string) string {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return ""
+	}
+	if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
+		if home, err := os.UserHomeDir(); err == nil {
+			if path == "~" {
+				path = home
+			} else {
+				path = filepath.Join(home, path[2:])
+			}
+		}
+	}
+	if abs, err := filepath.Abs(path); err == nil {
+		path = abs
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		return filepath.Clean(path)
+	}
+	if info.Mode().IsRegular() {
+		if filepath.Base(path) == skill.SkillFile {
+			return filepath.Clean(filepath.Dir(filepath.Dir(path)))
+		}
+		return filepath.Clean(filepath.Dir(path))
+	}
+	if info.IsDir() {
+		if _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {
+			return filepath.Clean(filepath.Dir(path))
+		}
+	}
+	return filepath.Clean(path)
+}
+
+func isConventionSkillRoot(path, workspaceRoot string) bool {
+	want := config.CanonicalSkillPath(path)
+	if want == "" {
+		return false
+	}
+	bases := []string{workspaceRoot}
+	if home, err := os.UserHomeDir(); err == nil {
+		bases = append(bases, home)
+	}
+	for _, base := range bases {
+		base = strings.TrimSpace(base)
+		if base == "" {
+			continue
+		}
+		for _, dir := range config.ConventionDirs {
+			if want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func skillRootPath(path string) string {
+	if filepath.Base(path) == skill.SkillFile {
+		return filepath.Dir(path)
+	}
+	return path
+}
+
+func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
+	cleanPath := filepath.Clean(sk.Path)
+	for _, r := range roots {
+		if r.Scope != sk.Scope {
+			continue
+		}
+		cleanRoot := filepath.Clean(r.Dir)
+		prefix := cleanRoot + string(filepath.Separator)
+		if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {
+			return config.CanonicalSkillPath(r.Dir)
+		}
+	}
+	return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
+}
+
+// MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
+// + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
+type MCPServerInput struct {
+	Name                 string            `json:"name"`
+	Transport            string            `json:"transport"`
+	Command              string            `json:"command"`
+	Args                 []string          `json:"args"`
+	URL                  string            `json:"url"`
+	Env                  map[string]string `json:"env"`
+	Headers              map[string]string `json:"headers"`
+	TrustedReadOnlyTools []string          `json:"trustedReadOnlyTools"`
+}
+
+// AddMCPServer connects a server live and persists it to config (Customize → MCP →
+// Add). Returns the number of tools it exposed.
+func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
+	ctrl := a.activeCtrl()
+	if ctrl == nil {
+		return 0, fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return 0, rebuildControllerActiveWorkError("MCP server")
+	}
+	entry := config.PluginEntry{
+		Name:                 in.Name,
+		Type:                 normalizeMCPTransport(in.Transport),
+		Command:              in.Command,
+		Args:                 in.Args,
+		URL:                  in.URL,
+		Env:                  in.Env,
+		Headers:              in.Headers,
+		TrustedReadOnlyTools: uniqueStrings(in.TrustedReadOnlyTools),
+	}
+	entry, _ = config.NormalizePluginCommandLine(entry)
+	if err := a.saveDesktopMCPServer(entry); err != nil {
+		return 0, err
+	}
+	return ctrl.ConnectMCPServer(entry)
+}
+
+// UpdateMCPServer edits a persisted external MCP server. The name is the stable
+// identity; callers must remove + add if they want to rename a server.
+func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
+	ctrl := a.activeCtrl()
+	if ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	if strings.TrimSpace(in.Name) != "" && strings.TrimSpace(in.Name) != name {
+		return fmt.Errorf("renaming MCP servers is not supported; remove and add a new server")
+	}
+	updated, found, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("no configured MCP server named %q", name)
+	}
+	updated.Type = normalizeMCPTransport(in.Transport)
+	updated.Command = strings.TrimSpace(in.Command)
+	updated.Args = append([]string(nil), in.Args...)
+	updated.URL = strings.TrimSpace(in.URL)
+	updated.Tier = ""
+	if in.Env != nil {
+		updated.Env = in.Env
+	}
+	if in.Headers != nil {
+		updated.Headers = in.Headers
+	}
+	if in.TrustedReadOnlyTools != nil {
+		updated.TrustedReadOnlyTools = uniqueStrings(in.TrustedReadOnlyTools)
+	}
+	updated, _ = config.NormalizePluginCommandLine(updated)
+	if updated.Type == "stdio" {
+		updated.URL = ""
+	} else {
+		updated.Command = ""
+		updated.Args = nil
+	}
+	if err := a.saveDesktopMCPServer(updated); err != nil {
+		return err
+	}
+
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	sessionDisabled := false
+	if tab != nil {
+		_, sessionDisabled = tab.disabledMCP[name]
+	}
+	a.mu.RUnlock()
+	wasConnected := mcpConnected(ctrl, name)
+	if wasConnected {
+		ctrl.DisconnectMCPServer(name)
+	}
+	if !sessionDisabled {
+		if _, err := ctrl.ConnectMCPServer(updated); err != nil {
+			recordMCPFailure(ctrl, updated, err)
+			return nil
+		}
+	}
+	return nil
+}
+
+// RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
+func (a *App) RemoveMCPServer(name string) error {
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil {
+		return err
+	}
+	removed, err := a.removeDesktopMCPServer(tab.WorkspaceRoot, name)
+	if err != nil {
+		return err
+	}
+	if !removed {
+		return fmt.Errorf("no removable MCP server named %q", name)
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	a.mu.Lock()
+	delete(tab.disabledMCP, name)
+	tab.mcpOrder = removeServerOrder(tab.mcpOrder, name)
+	a.mu.Unlock()
+	return nil
+}
+
+// ReconnectMCPServer disconnects the server if it is already connected (to force
+// a fresh handshake and tool re-registration), then reconnects.  Failures are
+// recorded on the Host so the UI can render them.
+func (a *App) ReconnectMCPServer(name string) error {
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	_, err := a.connectConfiguredMCPServerForTab(tab, name)
+	if err != nil {
+		if plugin.IsServerAlreadyConnected(err) {
+			a.mu.Lock()
+			delete(tab.disabledMCP, name)
+			a.mu.Unlock()
+			return nil
+		}
+		entry := config.PluginEntry{Name: name}
+		if p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found {
+			entry = p
+		}
+		recordMCPFailure(ctrl, entry, err)
+		return err
+	}
+	a.mu.Lock()
+	delete(tab.disabledMCP, name)
+	a.mu.Unlock()
+	return nil
+}
+
+// ClearMCPServerAuthentication removes local auth-like config for one MCP and
+// clears the current session's cached connection failure. It does not remove the
+// server itself or try to sign the user out of the third-party browser session.
+func (a *App) ClearMCPServerAuthentication(name string) error {
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil {
+		return err
+	}
+	if _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil {
+		return err
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	return nil
+}
+
+func (a *App) updateMCPServerTrustedReadOnlyTools(name string, update func([]string) []string) error {
+	ctrl := a.activeCtrl()
+	if ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return fmt.Errorf("MCP server name is required")
+	}
+	updated, found, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("no configured MCP server named %q", name)
+	}
+	trusted := uniqueStrings(updated.TrustedReadOnlyTools)
+	next := uniqueStrings(update(trusted))
+	if sameStringList(trusted, next) {
+		updated.TrustedReadOnlyTools = trusted
+		return nil
+	}
+	updated.TrustedReadOnlyTools = next
+	if err := a.saveDesktopMCPServer(updated); err != nil {
+		return err
+	}
+
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	sessionDisabled := false
+	if tab != nil {
+		_, sessionDisabled = tab.disabledMCP[name]
+	}
+	a.mu.RUnlock()
+	if !mcpConnected(ctrl, name) {
+		return nil
+	}
+	ctrl.DisconnectMCPServer(name)
+	if h := ctrl.Host(); h != nil {
+		h.ClearFailure(name)
+	}
+	if !sessionDisabled {
+		if _, err := ctrl.ConnectMCPServer(updated); err != nil {
+			recordMCPFailure(ctrl, updated, err)
+			return nil
+		}
+	}
+	return nil
+}
+
+// TrustMCPServerTool marks one raw MCP tool name as trusted read-only and
+// refreshes the live connection so plan mode can use the updated trust boundary.
+func (a *App) TrustMCPServerTool(name, toolName string) error {
+	toolName = strings.TrimSpace(toolName)
+	if toolName == "" {
+		return fmt.Errorf("MCP tool name is required")
+	}
+	return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
+		return append(trusted, toolName)
+	})
+}
+
+// TrustMCPServerTools marks multiple raw MCP tool names as trusted read-only in
+// one config write and one live reconnect.
+func (a *App) TrustMCPServerTools(name string, toolNames []string) error {
+	if len(uniqueStrings(toolNames)) == 0 {
+		return fmt.Errorf("at least one MCP tool name is required")
+	}
+	return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
+		return append(trusted, toolNames...)
+	})
+}
+
+// UntrustMCPServerTool removes one raw MCP tool name from the trusted read-only
+// list and refreshes the live connection.
+func (a *App) UntrustMCPServerTool(name, toolName string) error {
+	toolName = strings.TrimSpace(toolName)
+	if toolName == "" {
+		return fmt.Errorf("MCP tool name is required")
+	}
+	return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string {
+		return removeString(trusted, toolName)
+	})
+}
+
+// SetMCPServerEnabled is the connector toggle: on reconnects a configured server
+// for this session, off disconnects it (config untouched either way — like Claude
+// Code's per-conversation enable/disable, it resets on the next session start).
+func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
+	a.mu.RLock()
+	tab := a.activeTabLocked()
+	var ctrl control.SessionAPI
+	hostKey := ""
+	if tab != nil {
+		ctrl = tab.Ctrl
+		hostKey = tab.SharedHostKey
+	}
+	a.mu.RUnlock()
+	if tab == nil || ctrl == nil {
+		return fmt.Errorf("no active session")
+	}
+	if controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	configuredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if enabled {
+		_, err := a.connectConfiguredMCPServerForTab(tab, name)
+		if err == nil {
+			a.mu.Lock()
+			delete(tab.disabledMCP, name)
+			a.mu.Unlock()
+		}
+		return err
+	}
+	if s, ok := findMCPServerView(ctrl, name); ok {
+		s.Status = "disabled"
+		s.Error = ""
+		a.mu.Lock()
+		if tab.disabledMCP == nil {
+			tab.disabledMCP = map[string]ServerView{}
+		}
+		tab.disabledMCP[name] = s
+		tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
+		a.mu.Unlock()
+	} else if hasConfiguredEntry {
+		s := withPluginConfig(ServerView{Name: name, Status: "disabled"}, configuredEntry)
+		a.mu.Lock()
+		if tab.disabledMCP == nil {
+			tab.disabledMCP = map[string]ServerView{}
+		}
+		tab.disabledMCP[name] = s
+		tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
+		a.mu.Unlock()
+	}
+	if hostKey != "" {
+		ctrl.UnregisterMCPServerTools(name)
+	} else {
+		ctrl.DisconnectMCPServer(name)
+	}
+	return nil
+}
+
+func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {
+	a.mu.RLock()
+	var ctrl control.SessionAPI
+	root := ""
+	if tab != nil {
+		ctrl = tab.Ctrl
+		root = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	if ctrl == nil {
+		return 0, fmt.Errorf("no active session")
+	}
+	cfg, err := config.LoadForRoot(root)
+	if err != nil {
+		return 0, err
+	}
+	for _, p := range cfg.Plugins {
+		if p.Name == name {
+			return ctrl.ConnectMCPServer(p)
+		}
+	}
+	return 0, fmt.Errorf("no configured MCP server named %q", name)
+}
+
+// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
+// retired tier field.
+func (a *App) SetMCPServerTier(name, tier string) error {
+	tier = normalizeMCPTier(tier)
+	tab, ctrl := a.activeTabAndCtrl()
+	if tab != nil && controllerHasActiveRuntimeWork(ctrl) {
+		return rebuildControllerActiveWorkError("MCP server")
+	}
+	updated, found, err := a.desktopMCPServerForEdit(name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return fmt.Errorf("no configured MCP server named %q", name)
+	}
+	updated.Tier = tier
+	if !updated.ShouldAutoStart() {
+		on := true
+		updated.AutoStart = &on
+	}
+	if err := a.saveDesktopMCPServer(updated); err != nil {
+		return err
+	}
+	if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
+		if _, err := ctrl.ConnectMCPServer(updated); err != nil {
+			recordMCPFailure(ctrl, updated, err)
+			return nil
+		}
+		a.mu.Lock()
+		delete(tab.disabledMCP, name)
+		a.mu.Unlock()
+	}
+	return nil
+}
+
+func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) {
+	// Read-only lookup of the entry to edit; loads credentials because callers
+	// hand the entry to ConnectMCPServer, which resolves env-based secrets.
+	// The actual config write goes through saveDesktopMCPServer under the
+	// config edit lock.
+	cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
+	if err != nil {
+		return config.PluginEntry{}, false, err
+	}
+	if p, ok := findPluginEntry(cfg.Plugins, name); ok {
+		return p, true, nil
+	}
+	if merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil {
+		if p, ok := findPluginEntry(merged.Plugins, name); ok {
+			return p, true, nil
+		}
+	}
+	return config.PluginEntry{}, false, nil
+}
+
+func (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {
+	root := a.activeWorkspaceRoot()
+	if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
+		return err
+	}
+	if a.desktopMCPServerOwnedByProjectMCPJSON(root, entry.Name) {
+		_, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(root), entry)
+		return err
+	}
+	// Lock only the user-config load-modify-save; the project-override cleanup
+	// below writes the project config, which this lock does not cover.
+	if err := func() error {
+		unlock := config.LockUserConfigEdits()
+		defer unlock()
+		cfg, path, err := a.loadDesktopUserConfigForEdit()
+		if err != nil {
+			return err
+		}
+		if err := cfg.UpsertPlugin(entry); err != nil {
+			return err
+		}
+		return cfg.SaveTo(path)
+	}(); err != nil {
+		return err
+	}
+	_, err := a.removeProjectMCPOverride(root, entry.Name)
+	return err
+}
+
+func ensureMCPServerDirectlyWritable(root, name string) error {
+	cfg, err := config.LoadForRoot(root)
+	if err != nil {
+		return err
+	}
+	if owner, ok := cfg.PluginPackageOwner(name); ok {
+		return fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
+	}
+	return nil
+}
+
+func (a *App) removeDesktopMCPServer(root, name string) (bool, error) {
+	return config.RemovePluginFromSourcesForRoot(root, name)
+}
+
+func (a *App) removeProjectMCPOverride(root, name string) (bool, error) {
+	path := projectConfigPathForRoot(root)
+	userPath := config.UserConfigPath()
+	if path == "" || sameConfigPath(path, userPath) {
+		return false, nil
+	}
+	if _, err := os.Stat(path); err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		}
+		return false, err
+	}
+	cfg := config.LoadForEdit(path)
+	if !cfg.RemovePlugin(name) {
+		return false, nil
+	}
+	if err := cfg.SaveTo(path); err != nil {
+		return false, err
+	}
+	return true, nil
+}
+
+func (a *App) desktopMCPServerOwnedByProjectMCPJSON(root, name string) bool {
+	if strings.TrimSpace(name) == "" {
+		return false
+	}
+	// Read-only ownership check: only looks for the name in the user config's
+	// plugin list, so no credentials and no config edit lock are needed.
+	cfg, _, err := a.loadDesktopUserConfigForView()
+	if err == nil {
+		if _, ok := findPluginEntry(cfg.Plugins, name); ok {
+			return false
+		}
+	}
+	projectCfg := config.LoadForEdit(projectConfigPathForRoot(root))
+	if _, ok := findPluginEntry(projectCfg.Plugins, name); ok {
+		return false
+	}
+	_, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(root), name)
+	return err == nil && ok
+}
+
+func projectMCPJSONPathForRoot(root string) string {
+	if strings.TrimSpace(root) == "" || root == "." {
+		return ".mcp.json"
+	}
+	return filepath.Join(root, ".mcp.json")
+}
+
+func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
+	for _, p := range entries {
+		if p.Name == name {
+			return p, true
+		}
+	}
+	return config.PluginEntry{}, false
+}
+
+func normalizeMCPTier(tier string) string {
+	switch strings.ToLower(strings.TrimSpace(tier)) {
+	case "eager":
+		return "eager"
+	case "background", "lazy":
+		return "background"
+	case "":
+		return "background"
+	default:
+		return "background"
+	}
+}
+
+func normalizeMCPTransport(transport string) string {
+	switch strings.ToLower(strings.TrimSpace(transport)) {
+	case "http", "streamable-http":
+		return "http"
+	case "sse":
+		return "sse"
+	default:
+		return "stdio"
+	}
+}
+
+func mcpConnected(ctrl control.SessionAPI, name string) bool {
+	if ctrl == nil || ctrl.Host() == nil {
+		return false
+	}
+	for _, s := range ctrl.Host().Servers() {
+		if s.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func mcpFailed(ctrl control.SessionAPI, name string) bool {
+	if ctrl == nil || ctrl.Host() == nil {
+		return false
+	}
+	for _, f := range ctrl.Host().Failures() {
+		if f.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {
+	if ctrl == nil || ctrl.Host() == nil || err == nil {
+		return
+	}
+	exp := e.ExpandedPlugin()
+	ctrl.Host().RecordFailure(plugin.Spec{
+		Name:    exp.Name,
+		Type:    exp.Type,
+		Command: exp.Command,
+		Args:    exp.Args,
+		Env:     exp.Env,
+		URL:     exp.URL,
+		Headers: exp.Headers,
+	}, err)
+}
+
+func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {
+	if ctrl == nil || ctrl.Host() == nil {
+		return ServerView{}, false
+	}
+	for _, s := range ctrl.Host().Servers() {
+		if s.Name == name {
+			return ServerView{
+				Name: s.Name, Transport: s.Transport, Status: "connected",
+				Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
+				HasTools: s.HasTools,
+				ToolList: pluginToolsToView(s.ToolList),
+			}, true
+		}
+	}
+	for _, f := range ctrl.Host().Failures() {
+		if f.Name == name {
+			return ServerView{Name: f.Name, Transport: f.Transport, Status: "failed", Error: f.Error}, true
+		}
+	}
+	return ServerView{}, false
+}
+
+func pluginToolsToView(tools []plugin.ToolInfo) []ToolView {
+	if len(tools) == 0 {
+		return nil
+	}
+	out := make([]ToolView, 0, len(tools))
+	for _, t := range tools {
+		out = append(out, ToolView{Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint})
+	}
+	return out
+}
+
+func sameStringList(a, b []string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		if a[i] != b[i] {
+			return false
+		}
+	}
+	return true
+}
+
+func orderServerViews(servers []ServerView, order []string) []ServerView {
+	pos := make(map[string]int, len(order))
+	for i, name := range order {
+		pos[name] = i
+	}
+	sort.SliceStable(servers, func(i, j int) bool {
+		pi, iok := pos[servers[i].Name]
+		pj, jok := pos[servers[j].Name]
+		switch {
+		case iok && jok:
+			return pi < pj
+		case iok:
+			return true
+		case jok:
+			return false
+		default:
+			return false
+		}
+	})
+	return servers
+}
+
+func mergeServerOrder(order []string, servers []ServerView) []string {
+	seen := make(map[string]bool, len(order)+len(servers))
+	next := make([]string, 0, len(order)+len(servers))
+	for _, name := range order {
+		if name == "" || seen[name] {
+			continue
+		}
+		seen[name] = true
+		next = append(next, name)
+	}
+	for _, s := range servers {
+		if s.Name == "" || seen[s.Name] {
+			continue
+		}
+		seen[s.Name] = true
+		next = append(next, s.Name)
+	}
+	return next
+}
+
+func removeServerOrder(order []string, name string) []string {
+	if name == "" || len(order) == 0 {
+		return order
+	}
+	next := order[:0]
+	for _, n := range order {
+		if n != name {
+			next = append(next, n)
+		}
+	}
+	return next
+}
+
+// ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
+// model") is what SetModel takes; Provider/Model are for display.
+type ModelInfo struct {
+	Ref      string `json:"ref"`
+	Provider string `json:"provider"`
+	Model    string `json:"model"`
+	Current  bool   `json:"current"`
+}
+
+type EffortInfo struct {
+	Supported bool     `json:"supported"`
+	Current   string   `json:"current"`
+	Default   string   `json:"default"`
+	Levels    []string `json:"levels"`
+}
+
+// Models flattens the configured providers into their (provider, model) pairs —
+// the switcher's options — marking the active one. A vendor with a `models` list
+// yields one entry per model, all sharing the same endpoint/key. Unconfigured
+// providers are skipped. Result is non-nil: the frontend reads .length, so a nil
+// slice (JSON null) would crash the switcher on an empty list.
+func (a *App) Models() []ModelInfo {
+	return a.ModelsForTab("")
+}
+
+func (a *App) ModelsForTab(tabID string) []ModelInfo {
+	a.mu.RLock()
+	curModel := ""
+	workspaceRoot := ""
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		curModel = tab.model
+		workspaceRoot = tab.WorkspaceRoot
+	}
+	a.mu.RUnlock()
+	cfg, err := config.LoadForRoot(workspaceRoot)
+	if err != nil {
+		return []ModelInfo{}
+	}
+	if entry, ok := cfg.ResolveModel(curModel); ok {
+		curModel = entry.Name + "/" + entry.Model
+	}
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	out := []ModelInfo{}
+	for i := range cfg.Providers {
+		p := &cfg.Providers[i]
+		if !modelProviderAccessAllowed(access, p.Name) || !p.Configured() {
+			continue
+		}
+		for _, m := range p.ChatModelList() {
+			ref := p.Name + "/" + m
+			out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
+		}
+	}
+	return out
+}
+
+func modelProviderAccessAllowed(access map[string]bool, name string) bool {
+	if len(access) == 0 {
+		return true
+	}
+	return access[strings.TrimSpace(name)]
+}
+
+func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
+	if ctrl == nil {
+		return false
+	}
+	status := ctrl.RuntimeStatus()
+	return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
+}
+
+// rebuildBusyError reports a rebuild rejected because the controller still has
+// a running turn, pending prompt, or background jobs. Typed so the
+// deferred-rebuild retry loop can keep waiting instead of giving up.
+type rebuildBusyError struct{ setting string }
+
+func (e *rebuildBusyError) Error() string {
+	return fmt.Sprintf("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s", e.setting)
+}
+
+func rebuildControllerActiveWorkError(setting string) error {
+	return &rebuildBusyError{setting: setting}
+}
+
+type sessionLeaseBusyError struct {
+	setting string
+	err     error
+}
+
+func (e *sessionLeaseBusyError) Error() string {
+	// The raw SessionLeaseError text carries the session path and the
+	// holder's host-pid-writer id; every user-facing surface must render
+	// this wrapper instead. An empty setting means the failure gated opening
+	// the session itself (startup bind), not changing a setting on it.
+	setting := strings.TrimSpace(e.setting)
+	if setting == "" {
+		return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
+	}
+	return fmt.Sprintf("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing %s", setting)
+}
+
+func (e *sessionLeaseBusyError) Unwrap() error {
+	if e == nil {
+		return nil
+	}
+	return e.err
+}
+
+func userFacingSessionLeaseError(setting string, err error) error {
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, agent.ErrSessionLeaseHeld) {
+		return &sessionLeaseBusyError{setting: setting, err: err}
+	}
+	return err
+}
+
+// sessionPathAfterSnapshot returns where a controller rebuild should keep
+// persisting after the old controller was snapshotted. Snapshotting is not
+// path-neutral: a snapshot conflict can recover by retargeting the controller
+// (and the tab's session lease, via handleTabSessionRecovered) to a recovery
+// branch, so a prevPath captured before the snapshot may be stale. Reusing the
+// stale path would bind the rebuilt controller — carrying the just-recovered
+// transcript — back to the original file, turning every later save into a new
+// conflict that derives yet another recovery branch. Falls back to fallback
+// when the controller is gone or persistence is disabled (empty SessionPath).
+func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
+	if ctrl == nil {
+		return fallback
+	}
+	if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
+		return path
+	}
+	return fallback
+}
+
+func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
+	if err := tab.ensureSessionLease(path); err != nil {
+		if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
+			if lease, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
+				tab.adoptSessionLease(lease)
+				return nil
+			} else {
+				err = reclaimErr
+			}
+		}
+		return userFacingSessionLeaseError(setting, err)
+	}
+	return nil
+}
+
+func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path string, err error) bool {
+	key := sessionRuntimeKey(path)
+	if tab == nil || key == "" || !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		return false
+	}
+	var leaseErr *agent.SessionLeaseError
+	if !errors.As(err, &leaseErr) || leaseErr == nil {
+		return false
+	}
+	// A readable info naming a foreign runtime is respected here; reclaim
+	// would refuse it anyway. A nil Info (lease.json deleted by the user,
+	// quarantined by AV, or torn by a crash) must still attempt the reclaim:
+	// the OS lock is the arbiter there, and refusing on missing metadata
+	// wedges a session nobody actually holds as permanently busy.
+	if leaseErr.Info != nil &&
+		(leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
+		return false
+	}
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	for _, candidate := range a.runtimeTabsLocked() {
+		if candidate == nil || candidate == tab {
+			continue
+		}
+		if candidate.sessionLeaseRuntimeKey() == key {
+			return false
+		}
+		if candidate.Ctrl != nil && sessionRuntimeKey(candidate.currentSessionPath()) == key {
+			return false
+		}
+	}
+	return true
+}
+
+// SetModel switches the active model and carries the current conversation into the
+// new model's session, so the chat continues seamlessly and subsequent turns use
+// the new model. No-op if name is already active or the controller is down.
+func (a *App) SetModel(name string) error {
+	return a.SetModelForTab("", name)
+}
+
+func (a *App) SetModelForTab(tabID, name string) error {
+	if a.ctx == nil || name == "" {
+		return nil
+	}
+	tab := a.tabByID(tabID)
+	if tab == nil {
+		return nil
+	}
+	a.mu.RLock()
+	currentModel := tab.model
+	a.mu.RUnlock()
+	if name == currentModel {
+		return nil
+	}
+	// Same build+swap shape as rebuildSetting; hold the same lock so a settings
+	// rebuild (manual or from the deferred-rebuild retry loop) and a model
+	// switch cannot interleave on one tab.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	prevPath := a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" {
+		a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
+	}
+	if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+		return rebuildControllerActiveWorkError("model")
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	prevPath = a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
+		prevPath = a.reconciledSessionPathForTab(tab)
+		if prevPath == "" {
+			prevPath = a.currentSessionPathFor(tab)
+		}
+		if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+			return rebuildControllerActiveWorkError("model")
+		}
+	}
+	// Snapshot the tab profile under a.mu: SetModeForTab/SetGoalForTab and the
+	// event sink write these fields under the lock while this rebuild runs
+	// off-lock.
+	snap := a.tabRuntimeSnapshot(tab)
+	cfg, err := config.LoadForRoot(snap.workspaceRoot)
+	if err != nil {
+		return err
+	}
+	entry, ok := cfg.ResolveModel(name)
+	if !ok {
+		return fmt.Errorf("unknown model %q", name)
+	}
+	if !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) {
+		return fmt.Errorf("model %q is not available because provider %q is not added", name, entry.Name)
+	}
+	name = entry.Name + "/" + entry.Model
+	effortOverride := cloneStringPtr(snap.effort)
+	if effortOverride != nil {
+		normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))
+		if err != nil {
+			effortOverride = nil
+		} else {
+			effortOverride = &normalized
+		}
+	}
+
+	var carried []provider.Message
+	oldCtrl := a.controllerForTab(tab)
+	if oldCtrl != nil {
+		if prevPath == "" {
+			prevPath = oldCtrl.SessionPath()
+		}
+		if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "model"); err != nil {
+			return err
+		}
+		if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
+			return err
+		}
+		prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
+		carried = oldCtrl.History()
+	}
+
+	// Preserve the shared plugin host across controller rebuilds — the tab
+	// stays in the same workspace root, so MCP processes must not be restarted.
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    name,
+		RequireKey:               false,
+		Sink:                     snap.sink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           cloneStringPtr(effortOverride),
+		TokenMode:                snap.currentTokenMode(),
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		return err
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	newCtrl.SetGoal(snap.goal)
+
+	path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
+	if err := a.ensureTabSessionLeaseForRebuild(tab, path, "model"); err != nil {
+		newCtrl.Close()
+		return err
+	}
+	resumeWithFreshSystemPrompt(newCtrl, carried, path)
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		// The tab was closed/replaced while we built the new controller off-lock;
+		// adopting it now would leak the runtime onto an orphaned tab and pin the
+		// session lease forever.
+		a.mu.Unlock()
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		return fmt.Errorf("tab %q changed while switching model; retry", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.model = name
+	tab.effort = cloneStringPtr(effortOverride)
+	tab.Label = newCtrl.Label()
+	// Supersede any in-flight startup build: it would otherwise finish later,
+	// overwrite this controller, and release/steal the tab's session lease.
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	if oldCtrl != nil {
+		oldCtrl.Close()
+	}
+	// The runtime now reflects the on-disk config; drop any deferred refresh.
+	a.clearDeferredRebuild(tab.ID)
+	a.persistTabSessionPath(tab, path)
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func (a *App) Effort() EffortInfo {
+	return a.EffortForTab("")
+}
+
+func (a *App) EffortForTab(tabID string) EffortInfo {
+	entry, err := a.currentProviderEntryForTab(tabID)
+	if err != nil {
+		return EffortInfo{Current: "auto", Levels: []string{}}
+	}
+	cap := config.EffortCapabilityForEntry(entry)
+	if !cap.Supported {
+		return EffortInfo{Supported: false, Current: "auto", Default: cap.Default, Levels: []string{}}
+	}
+	levels := cap.Levels
+	if levels == nil {
+		levels = []string{}
+	}
+	return EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}
+}
+
+func (a *App) SetEffort(level string) error {
+	return a.SetEffortForTab("", level)
+}
+
+func (a *App) SetEffortForTab(tabID, level string) error {
+	tab := a.tabByID(tabID)
+	if tab == nil {
+		if strings.TrimSpace(tabID) == "" {
+			entry, err := a.currentProviderEntryForTab("")
+			if err != nil {
+				return err
+			}
+			effort, err := config.NormalizeEffort(entry, level)
+			if err != nil {
+				return err
+			}
+			return a.applyProviderEffortConfig(entry, effort)
+		}
+		return fmt.Errorf("tab %q not found", tabID)
+	}
+	// Build+swap path; serialize with the other rebuild paths (see
+	// runtimeRebuildMu). The tab==nil branch above goes through
+	// applyProviderEffortConfig → rebuildSetting, which takes the lock itself.
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	prevPath := a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	// Recomputing prevPath after this attach would be a dead store: it is
+	// unconditionally derived again after ensureTabControllerWorkspace below.
+	if a.controllerForTab(tab) == nil && prevPath != "" {
+		a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
+	}
+	if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+		return rebuildControllerActiveWorkError("effort")
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	prevPath = a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
+		prevPath = a.reconciledSessionPathForTab(tab)
+		if prevPath == "" {
+			prevPath = a.currentSessionPathFor(tab)
+		}
+		if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+			return rebuildControllerActiveWorkError("effort")
+		}
+	}
+	snap := a.tabRuntimeSnapshot(tab)
+	entry, err := a.currentProviderEntryForTab(tabID)
+	if err != nil {
+		return err
+	}
+	modelRef := entry.Name + "/" + entry.Model
+	effort, err := config.NormalizeEffort(entry, level)
+	if err != nil {
+		return err
+	}
+	var carried []provider.Message
+	oldCtrl := a.controllerForTab(tab)
+	if oldCtrl != nil {
+		if prevPath == "" {
+			prevPath = oldCtrl.SessionPath()
+		}
+		if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "effort"); err != nil {
+			return err
+		}
+		if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
+			return err
+		}
+		prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
+		carried = oldCtrl.History()
+	}
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    modelRef,
+		RequireKey:               false,
+		Sink:                     snap.sink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           &effort,
+		TokenMode:                snap.currentTokenMode(),
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		return err
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	newCtrl.SetGoal(snap.goal)
+	path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
+	if err := a.ensureTabSessionLeaseForRebuild(tab, path, "effort"); err != nil {
+		newCtrl.Close()
+		return err
+	}
+	resumeWithFreshSystemPrompt(newCtrl, carried, path)
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		a.mu.Unlock()
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		return fmt.Errorf("tab %q changed while switching effort; retry", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.model = modelRef
+	tab.effort = &effort
+	tab.Label = newCtrl.Label()
+	clearTabStartupError(tab)
+	tab.Ready = true
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	if oldCtrl != nil {
+		oldCtrl.Close()
+	}
+	// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
+	a.clearDeferredRebuild(tab.ID)
+	a.persistTabSessionPath(tab, path)
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func (a *App) SetTokenMode(mode string) error {
+	return a.SetTokenModeForTab("", mode)
+}
+
+func (a *App) SetTokenModeForTab(tabID, mode string) error {
+	mode = boot.NormalizeTokenMode(mode)
+	tab := a.tabByID(tabID)
+	if tab == nil {
+		if strings.TrimSpace(tabID) == "" {
+			return nil
+		}
+		return fmt.Errorf("tab %q not found", tabID)
+	}
+	a.mu.RLock()
+	currentMode := boot.NormalizeTokenMode(tab.tokenMode)
+	a.mu.RUnlock()
+	if mode == currentMode {
+		return nil
+	}
+	// Build+swap path; serialize with the other rebuild paths (see runtimeRebuildMu).
+	a.runtimeRebuildMu.Lock()
+	defer a.runtimeRebuildMu.Unlock()
+	prevPath := a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	// Recomputing prevPath after this attach would be a dead store: it is
+	// unconditionally derived again after ensureTabControllerWorkspace below.
+	if a.controllerForTab(tab) == nil && prevPath != "" {
+		a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
+	}
+	if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+		return rebuildControllerActiveWorkError("token mode")
+	}
+	if err := a.ensureTabControllerWorkspace(tab); err != nil {
+		return err
+	}
+	prevPath = a.reconciledSessionPathForTab(tab)
+	if prevPath == "" {
+		prevPath = a.currentSessionPathFor(tab)
+	}
+	if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
+		prevPath = a.reconciledSessionPathForTab(tab)
+		if prevPath == "" {
+			prevPath = a.currentSessionPathFor(tab)
+		}
+		if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) {
+			return rebuildControllerActiveWorkError("token mode")
+		}
+	}
+	modelRef, fallback, err := a.resolvedModelForTab(tab)
+	if err != nil {
+		return err
+	}
+	snap := a.tabRuntimeSnapshot(tab)
+	if fallback && strings.TrimSpace(snap.model) != "" {
+		a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", snap.model, modelRef))
+	}
+
+	var carried []provider.Message
+	oldCtrl := a.controllerForTab(tab)
+	if oldCtrl != nil {
+		if prevPath == "" {
+			prevPath = oldCtrl.SessionPath()
+		}
+		if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "token mode"); err != nil {
+			return err
+		}
+		if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
+			return err
+		}
+		prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
+		carried = oldCtrl.History()
+	}
+	sharedHost := a.lookupSharedHost(snap.sharedHostKey)
+	newCtrl, err := boot.Build(a.bootContext(), boot.Options{
+		Model:                    modelRef,
+		RequireKey:               false,
+		Sink:                     snap.sink,
+		WorkspaceRoot:            snap.workspaceRoot,
+		SessionDir:               sessionDirForSnapshot(snap),
+		EffortOverride:           cloneStringPtr(snap.effort),
+		TokenMode:                mode,
+		SharedHost:               sharedHost,
+		CleanupPendingReconciler: reconcileDesktopCleanupPending,
+		SessionRecoveryMeta:      a.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:       a.handleTabSessionRecovered(tab),
+	})
+	if err != nil {
+		return err
+	}
+	a.bindControllerDisplayRecorder(newCtrl)
+	newCtrl.EnableInteractiveApproval()
+	applyTabModeToController(newCtrl, snap.mode)
+	applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
+	newCtrl.SetGoal(snap.goal)
+	path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
+	if err := a.ensureTabSessionLeaseForRebuild(tab, path, "token mode"); err != nil {
+		newCtrl.Close()
+		return err
+	}
+	resumeWithFreshSystemPrompt(newCtrl, carried, path)
+	a.mu.Lock()
+	if current := a.tabs[tab.ID]; current != tab {
+		a.mu.Unlock()
+		newCtrl.Close()
+		tab.releaseSessionLease()
+		return fmt.Errorf("tab %q changed while switching token mode; retry", tab.ID)
+	}
+	tab.Ctrl = newCtrl
+	tab.model = modelRef
+	tab.tokenMode = mode
+	tab.Label = newCtrl.Label()
+	clearTabStartupError(tab)
+	tab.Ready = true
+	a.supersedeTabBuildLocked(tab)
+	a.saveTabsLocked()
+	a.mu.Unlock()
+	if oldCtrl != nil {
+		oldCtrl.Close()
+	}
+	// The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
+	a.clearDeferredRebuild(tab.ID)
+	a.persistTabSessionPath(tab, path)
+	a.notifyTabRuntimeRebuilt(tab)
+	return nil
+}
+
+func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {
+	return a.applyConfigChange(func(cfg *config.Config) error {
+		if _, ok := cfg.Provider(entry.Name); !ok {
+			if err := cfg.UpsertProvider(*entry); err != nil {
+				return err
+			}
+		}
+		if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" {
+			if err := cfg.SetProviderThinking(entry.Name, "adaptive"); err != nil {
+				return err
+			}
+		}
+		for _, name := range providerEffortTargetNames(cfg, entry) {
+			if err := cfg.SetProviderEffort(name, effort); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
+	if cfg == nil || entry == nil {
+		return nil
+	}
+	out := []string{entry.Name}
+	seen := map[string]bool{entry.Name: true}
+	kind := officialProviderKindFromEntry(*entry)
+	if kind == "" {
+		return out
+	}
+	var family []string
+	switch kind {
+	case "deepseek":
+		family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
+	}
+	for _, name := range family {
+		if seen[name] {
+			continue
+		}
+		p, ok := cfg.Provider(name)
+		if !ok || officialProviderKindFromEntry(*p) != kind {
+			continue
+		}
+		seen[name] = true
+		out = append(out, name)
+	}
+	return out
+}
+
+// DirEntry is one entry in the "@" file-reference menu.
+type DirEntry struct {
+	Name        string `json:"name"`
+	Path        string `json:"path,omitempty"`
+	IsDir       bool   `json:"isDir"`
+	DisplayName string `json:"displayName,omitempty"`
+	DisplayPath string `json:"displayPath,omitempty"`
+}
+
+// FilePreview is a bounded, read-only file payload for the workspace side panel.
+type FilePreview struct {
+	Path      string `json:"path"`
+	Body      string `json:"body"`
+	Size      int64  `json:"size"`
+	Truncated bool   `json:"truncated"`
+	Binary    bool   `json:"binary"`
+	Kind      string `json:"kind,omitempty"`
+	Mime      string `json:"mime,omitempty"`
+	URL       string `json:"url,omitempty"`
+	Err       string `json:"err,omitempty"`
+}
+
+type WorkspaceChangeView struct {
+	Path         string   `json:"path"`
+	OldPath      string   `json:"oldPath,omitempty"`
+	Sources      []string `json:"sources"`
+	GitStatus    string   `json:"gitStatus,omitempty"`
+	Turns        []int    `json:"turns,omitempty"`
+	LatestPrompt string   `json:"latestPrompt,omitempty"`
+	LatestTime   int64    `json:"latestTime,omitempty"`
+}
+
+type WorkspaceChangesView struct {
+	Files        []WorkspaceChangeView `json:"files"`
+	GitAvailable bool                  `json:"gitAvailable"`
+	GitErr       string                `json:"gitErr,omitempty"`
+	GitBranch    string                `json:"gitBranch,omitempty"`
+}
+
+// workspaceNoiseNames are local cache/vendor entries hidden from the file tree
+// and "@" menu regardless of where they appear.
+var workspaceNoiseNames = map[string]bool{
+	".codex":       true,
+	".DS_Store":    true,
+	".git":         true,
+	".npm":         true,
+	".pnpm-store":  true,
+	"node_modules": true,
+	"Thumbs.db":    true,
+}
+
+var workspaceNoiseDirs = map[string]bool{
+	"bin":                      true,
+	"desktop/build":            true,
+	"desktop/frontend/dist":    true,
+	"desktop/frontend/wailsjs": true,
+	"dist":                     true,
+	"npm/.stage":               true,
+	"site/.astro":              true,
+	"site/dist":                true,
+	"stage":                    true,
+	"tmp":                      true,
+}
+
+const filePreviewLimit = 256 * 1024
+const fileRefSearchLimit = 20
+
+var previewMediaMIMEs = map[string]string{
+	".bmp":  "image/bmp",
+	".gif":  "image/gif",
+	".jpeg": "image/jpeg",
+	".jpg":  "image/jpeg",
+	".pdf":  "application/pdf",
+	".png":  "image/png",
+	".svg":  "image/svg+xml",
+	".webp": "image/webp",
+}
+
+func trimUTF8PartialSuffix(data []byte) []byte {
+	if utf8.Valid(data) {
+		return data
+	}
+	for i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {
+		if !utf8.RuneStart(data[i]) {
+			continue
+		}
+		if !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {
+			return data
+		}
+		return data[:i]
+	}
+	return data
+}
+
+func previewMediaKind(path string) (kind string, mime string) {
+	mime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]
+	if mime == "" {
+		return "", ""
+	}
+	if strings.HasPrefix(mime, "image/") {
+		return "image", mime
+	}
+	if mime == "application/pdf" {
+		return "pdf", mime
+	}
+	return "", ""
+}
+
+func workspaceEntryRel(rel, name string) string {
+	rel = strings.Trim(filepath.ToSlash(rel), "/")
+	if rel == "" || rel == "." {
+		return name
+	}
+	return rel + "/" + name
+}
+
+func skipWorkspaceEntry(rel, name string, isDir bool) bool {
+	if workspaceNoiseNames[name] {
+		return true
+	}
+	return isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)]
+}
+
+func (a *App) activeWorkspaceBase() (string, error) {
+	return workspaceBaseFromRoot(a.activeWorkspaceRoot())
+}
+
+func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
+	tabID = strings.TrimSpace(tabID)
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	tab := a.tabByIDLocked(tabID)
+	if tab == nil {
+		if tabID == "" {
+			return ".", nil, true
+		}
+		return "", nil, false
+	}
+	return tab.WorkspaceRoot, tab.Ctrl, true
+}
+
+func workspaceBaseFromRoot(root string) (string, error) {
+	if strings.TrimSpace(root) == "" || root == "." {
+		return os.Getwd()
+	}
+	if abs, err := filepath.Abs(root); err == nil {
+		root = abs
+	}
+	return filepath.Clean(root), nil
+}
+
+func workspacePathForBase(base, rel string) (string, bool, error) {
+	base = filepath.Clean(base)
+	if rel == "" {
+		return "", false, os.ErrInvalid
+	}
+	path := rel
+	if !filepath.IsAbs(path) {
+		path = filepath.Join(base, rel)
+	}
+	path = filepath.Clean(path)
+	r, err := filepath.Rel(base, path)
+	if err != nil {
+		return "", false, err
+	}
+	if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) {
+		return "", false, os.ErrPermission
+	}
+	return path, true, nil
+}
+
+// ListDir lists one directory level (directories first, then files, each
+// alphabetical) for the "@" file-reference menu. rel resolves against the active
+// tab workspace. The menu navigates one level at a time, never recursively —
+// bounded for huge trees.
+func (a *App) ListDir(rel string) []DirEntry {
+	return a.ListDirForTab("", rel)
+}
+
+// ListDirForTab is the tab-scoped variant used by multi-tab frontend surfaces.
+func (a *App) ListDirForTab(tabID, rel string) []DirEntry {
+	root, ctrl, ok := a.workspaceTargetForTab(tabID)
+	if !ok {
+		return []DirEntry{}
+	}
+	if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
+		if entries, handled := browser.ListExternalFolderRefDir(rel); handled {
+			return externalFolderDirEntries(entries)
+		}
+	}
+	base, err := workspaceBaseFromRoot(root)
+	if err != nil {
+		return []DirEntry{}
+	}
+	dir := base
+	if rel != "" {
+		path, ok, err := workspacePathForBase(base, rel)
+		if err != nil || !ok {
+			return []DirEntry{}
+		}
+		dir = path
+	}
+	es, err := os.ReadDir(dir)
+	if err != nil {
+		return []DirEntry{}
+	}
+	dirs, files := []DirEntry{}, []DirEntry{}
+	for _, e := range es {
+		name := e.Name()
+		if skipWorkspaceEntry(rel, name, e.IsDir()) {
+			continue
+		}
+		if e.IsDir() {
+			dirs = append(dirs, DirEntry{Name: name, IsDir: true})
+			continue
+		}
+		info, err := e.Info()
+		if err != nil || !info.Mode().IsRegular() {
+			continue
+		}
+		files = append(files, DirEntry{Name: name, IsDir: false})
+	}
+	sort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })
+	sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })
+	return append(dirs, files...)
+}
+
+// SearchFileRefs finds workspace files by basename for bare "@token" completion.
+func (a *App) SearchFileRefs(query string) []DirEntry {
+	return a.SearchFileRefsForTab("", query)
+}
+
+// SearchFileRefsForTab is the tab-scoped variant used by multi-tab frontend surfaces.
+func (a *App) SearchFileRefsForTab(tabID, query string) []DirEntry {
+	root, ctrl, ok := a.workspaceTargetForTab(tabID)
+	if !ok {
+		return []DirEntry{}
+	}
+	base, err := workspaceBaseFromRoot(root)
+	if err != nil {
+		return []DirEntry{}
+	}
+	results := fileref.Search(base, query, fileRefSearchLimit)
+	out := make([]DirEntry, 0, len(results))
+	for _, r := range results {
+		out = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})
+	}
+	if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
+		out = append(out, externalFolderDirEntries(browser.SearchExternalFolderRefs(query, fileRefSearchLimit))...)
+	}
+	return out
+}
+
+type externalFolderRefBrowser interface {
+	ListExternalFolderRefDir(tokenPath string) ([]control.ExternalFolderRefEntry, bool)
+	SearchExternalFolderRefs(query string, limit int) []control.ExternalFolderRefEntry
+	ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool)
+}
+
+func externalFolderRefBrowserFromController(ctrl control.SessionAPI) externalFolderRefBrowser {
+	if browser, ok := ctrl.(externalFolderRefBrowser); ok {
+		return browser
+	}
+	return nil
+}
+
+func externalFolderDirEntries(entries []control.ExternalFolderRefEntry) []DirEntry {
+	out := make([]DirEntry, 0, len(entries))
+	for _, e := range entries {
+		out = append(out, DirEntry{
+			Name:        e.Name,
+			Path:        e.Path,
+			IsDir:       e.IsDir,
+			DisplayName: e.DisplayName,
+			DisplayPath: e.DisplayPath,
+		})
+	}
+	return out
+}
+
+func (a *App) workspaceOrExternalPathForTab(tabID, rel string) (string, bool, error) {
+	root, ctrl, ok := a.workspaceTargetForTab(tabID)
+	if !ok {
+		return "", false, os.ErrNotExist
+	}
+	if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
+		if path, _, ok := browser.ExternalFolderRefLocalPath(rel); ok {
+			return path, true, nil
+		}
+	}
+	base, err := workspaceBaseFromRoot(root)
+	if err != nil {
+		return "", false, err
+	}
+	return workspacePathForBase(base, rel)
+}
+
+// ReadFile returns a small text preview for a file under the current workspace
+// or a session-authorized external folder ref.
+func (a *App) ReadFile(rel string) FilePreview {
+	return a.ReadFileForTab("", rel)
+}
+
+// ReadFileForTab returns a preview resolved against the requested tab.
+func (a *App) ReadFileForTab(tabID, rel string) FilePreview {
+	out := FilePreview{Path: rel}
+	path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
+	if err != nil || !ok {
+		out.Err = "invalid path"
+		return out
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		out.Err = err.Error()
+		return out
+	}
+	if info.IsDir() {
+		out.Err = "path is a directory"
+		return out
+	}
+	if !info.Mode().IsRegular() {
+		out.Err = "path is not a regular file"
+		return out
+	}
+	out.Size = info.Size()
+	if kind, mime := previewMediaKind(path); kind != "" {
+		token := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())
+		out.Kind = kind
+		out.Mime = mime
+		out.URL = "/__reasonix_workspace_media/" + token + "/" + url.PathEscape(info.Name())
+		return out
+	}
+	f, err := os.Open(path)
+	if err != nil {
+		out.Err = err.Error()
+		return out
+	}
+	defer f.Close()
+
+	buf := make([]byte, filePreviewLimit+1)
+	n, err := f.Read(buf)
+	if err != nil && err != io.EOF {
+		out.Err = err.Error()
+		return out
+	}
+	data := buf[:n]
+	if len(data) > filePreviewLimit {
+		data = data[:filePreviewLimit]
+		out.Truncated = true
+	}
+
+	// Check for BOM first (just the first 2-3 bytes — always complete
+	// even at a truncation boundary). BOM-prefixed files skip the NUL
+	// check since UTF-16 normally contains 0x00 for ASCII characters.
+	bomKind := fileenc.DetectQuick(data)
+	if bomKind != fileenc.UTF8 {
+		enc, _ := fileenc.Detect(data)
+		if enc == fileenc.LossyUTF8 {
+			out.Binary = true
+			return out
+		}
+		decoded := fileenc.Decode(data, enc)
+		out.Body = string(decoded)
+		return out
+	}
+
+	// No BOM — NUL in raw bytes is a binary signal.
+	if bytes.Contains(data, []byte{0}) {
+		out.Binary = true
+		return out
+	}
+
+	// Trim any partial multi-byte rune at the truncation boundary BEFORE
+	// encoding detection. Without this, a large UTF-8 file truncated
+	// mid-character would fail utf8.Valid and be misdetected as GB18030
+	// or LossyUTF8, producing mojibake or a false binary classification.
+	if out.Truncated {
+		data = trimUTF8PartialSuffix(data)
+	}
+	enc, _ := fileenc.Detect(data)
+	if enc == fileenc.LossyUTF8 {
+		out.Binary = true
+		return out
+	}
+	out.Body = string(fileenc.Decode(data, enc))
+	return out
+}
+
+// OpenWorkspacePath opens a workspace or authorized external-ref file/folder in
+// the OS default app.
+func (a *App) OpenWorkspacePath(rel string) error {
+	return a.OpenWorkspacePathForTab("", rel)
+}
+
+// OpenWorkspacePathForTab opens a path resolved against the requested tab.
+func (a *App) OpenWorkspacePathForTab(tabID, rel string) error {
+	path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
+	if err != nil || !ok {
+		return os.ErrInvalid
+	}
+	return openWorkspacePath(path)
+}
+
+// RevealWorkspacePath shows a workspace or authorized external-ref file in the
+// native file manager.
+func (a *App) RevealWorkspacePath(rel string) error {
+	return a.RevealWorkspacePathForTab("", rel)
+}
+
+// RevealWorkspacePathForTab reveals a path resolved against the requested tab.
+func (a *App) RevealWorkspacePathForTab(tabID, rel string) error {
+	path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
+	if err != nil || !ok {
+		return os.ErrInvalid
+	}
+	return revealPath(path)
+}
+
+// RevealPath shows an arbitrary absolute path in the native file manager.
+func (a *App) RevealPath(path string) error {
+	path = strings.TrimSpace(path)
+	if path == "" {
+		return os.ErrInvalid
+	}
+	if abs, err := filepath.Abs(path); err == nil {
+		path = abs
+	}
+	return revealPath(path)
+}
+
+var revealPath = defaultRevealPath
+
+func defaultRevealPath(path string) error {
+	switch goruntime.GOOS {
+	case "darwin":
+		return exec.Command("open", "-R", path).Start()
+	case "windows":
+		// explorer.exe lives in %SystemRoot%, which isn't always on PATH (the
+		// launch environment can strip it), so resolve it directly rather than
+		// relying on a PATH lookup.
+		explorer := "explorer.exe"
+		root := os.Getenv("SystemRoot")
+		if root == "" {
+			root = os.Getenv("windir")
+		}
+		if root != "" {
+			explorer = filepath.Join(root, "explorer.exe")
+		}
+		return exec.Command(explorer, "/select,", path).Start()
+	default:
+		dir := path
+		if info, err := os.Stat(path); err == nil && !info.IsDir() {
+			dir = filepath.Dir(path)
+		}
+		return exec.Command("xdg-open", dir).Start()
+	}
+}
+
+func (a *App) noticeForTab(tabID, text string) {
+	tab := a.tabByID(tabID)
+	if tab != nil && tab.sink != nil {
+		tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
+	}
+}
+
+func (a *App) warnForTab(tabID, text string) {
+	tab := a.tabByID(tabID)
+	if tab != nil && tab.sink != nil {
+		tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
+	}
+}
+
+func (a *App) runEffortCommandForTab(tabID, input string) {
+	entry, err := a.currentProviderEntryForTab(tabID)
+	if err != nil {
+		a.noticeForTab(tabID, "effort: "+err.Error())
+		return
+	}
+	cap := config.EffortCapabilityForEntry(entry)
+	if !cap.Supported {
+		a.noticeForTab(tabID, fmt.Sprintf("effort is not configurable for %s", entry.Name))
+		return
+	}
+	args := strings.Fields(input)
+	if len(args) < 2 {
+		a.noticeForTab(tabID, fmt.Sprintf("effort for %s: %s (default: %s; options: %s)", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, "|")))
+		return
+	}
+	if len(args) > 2 {
+		a.noticeForTab(tabID, "usage: /effort "+strings.Join(cap.Levels, "|"))
+		return
+	}
+	effort, err := config.NormalizeEffort(entry, args[1])
+	if err != nil {
+		a.noticeForTab(tabID, err.Error())
+		return
+	}
+	if err := a.SetEffortForTab(tabID, args[1]); err != nil {
+		a.noticeForTab(tabID, "effort: "+err.Error())
+		return
+	}
+	display := effort
+	if display == "" {
+		display = "auto"
+	}
+	a.noticeForTab(tabID, fmt.Sprintf("effort for %s set to %s", entry.Name, display))
+}
+
+func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {
+	if tab := a.tabByID(tabID); tab != nil {
+		a.reconcileTabWithPinnedSessionMeta(tab)
+	}
+	a.mu.RLock()
+	ref := ""
+	workspaceRoot := ""
+	effortOverride := (*string)(nil)
+	if tab := a.tabByIDLocked(tabID); tab != nil {
+		ref = tab.model
+		workspaceRoot = tab.WorkspaceRoot
+		effortOverride = cloneStringPtr(tab.effort)
+	}
+	a.mu.RUnlock()
+	cfg, err := config.LoadForRoot(workspaceRoot)
+	if err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(ref) == "" {
+		ref = cfg.DefaultModel
+	}
+	config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
+	resolved, _, ok := cfg.ResolveModelWithFallback(ref)
+	if !ok {
+		return nil, fmt.Errorf("unknown model %q", ref)
+	}
+	entry, ok := cfg.ResolveModel(resolved)
+	if !ok {
+		return nil, fmt.Errorf("unknown model %q", resolved)
+	}
+	if effortOverride != nil {
+		entry.Effort = *effortOverride
+	}
+	return entry, nil
+}
+
+func (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {
+	if tab == nil {
+		return "", false, fmt.Errorf("no active tab")
+	}
+	cfg, err := config.LoadForRoot(tab.WorkspaceRoot)
+	if err != nil {
+		return "", false, err
+	}
+	ref := strings.TrimSpace(tab.model)
+	if ref == "" {
+		ref = cfg.DefaultModel
+	}
+	config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
+	resolved, fallback, ok := cfg.ResolveModelWithFallback(ref)
+	if !ok {
+		return "", false, fmt.Errorf("unknown model %q", ref)
+	}
+	return resolved, fallback, nil
+}
+
+func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {
+	var result string
+	err := a.withActiveWorkspaceDo(func() error {
+		var err error
+		result, err = fn()
+		return err
+	})
+	return result, err
+}
+
+func (a *App) withActiveWorkspaceDo(fn func() error) error {
+	root := a.activeWorkspaceRoot()
+	if root != "" && root != "." {
+		prev, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		if err := os.Chdir(root); err != nil {
+			return err
+		}
+		defer func() { _ = os.Chdir(prev) }()
+	}
+	return fn()
+}
+
+// SavePastedImage stores a browser clipboard image data URL under the active
+// tab's workspace .reasonix/attachments and returns the relative @-reference path.
+func (a *App) SavePastedImage(dataURL string) (string, error) {
+	return a.withActiveWorkspace(func() (string, error) {
+		return control.SaveImageDataURL(dataURL)
+	})
+}
+
+// SaveClipboardImage reads the native OS clipboard image under the active tab's
+// workspace .reasonix/attachments and returns the relative @-reference path.
+func (a *App) SaveClipboardImage() (string, error) {
+	return a.withActiveWorkspace(control.SaveClipboardImage)
+}
+
+// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
+// as a data URL but not a real path) under the active tab's workspace
+// .reasonix/attachments and returns the relative @-reference path.
+func (a *App) SavePastedFile(name, dataURL string) (string, error) {
+	return a.withActiveWorkspace(func() (string, error) {
+		return control.SaveAttachmentDataURL(name, dataURL)
+	})
+}
+
+// PickExportFile opens the native save dialog and returns the selected path. It
+// returns "" when the user cancels.
+func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {
+	if a.ctx == nil {
+		return "", nil
+	}
+	defaultFilename = safeExportFilename(defaultFilename)
+	ext := strings.ToLower(filepath.Ext(defaultFilename))
+	path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
+		Title:                "Export session",
+		DefaultDirectory:     dialogDefaultDirectory(a.activeWorkspaceRoot()),
+		DefaultFilename:      defaultFilename,
+		CanCreateDirectories: true,
+		Filters:              exportFileFilters(mimeType, ext),
+	})
+	if err != nil || path == "" {
+		return "", err
+	}
+	if ext != "" && filepath.Ext(path) == "" {
+		path += ext
+	}
+	return path, nil
+}
+
+// SaveExportFile writes an exported session payload to a path previously picked
+// by PickExportFile. An empty path is treated as a cancelled export.
+func (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {
+	if strings.TrimSpace(path) == "" {
+		return nil
+	}
+	var data []byte
+	var err error
+	if base64Encoded {
+		data, err = base64.StdEncoding.DecodeString(payload)
+		if err != nil {
+			return fmt.Errorf("decode export payload: %w", err)
+		}
+	} else {
+		data = []byte(payload)
+	}
+	if err := os.WriteFile(path, data, 0o644); err != nil {
+		return err
+	}
+	return nil
+}
+
+func safeExportFilename(name string) string {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return "reasonix-session.md"
+	}
+	return filepath.Base(name)
+}
+
+func exportFileFilters(mimeType, ext string) []runtime.FileFilter {
+	switch mimeType {
+	case "text/markdown":
+		return []runtime.FileFilter{{DisplayName: "Markdown (*.md)", Pattern: "*.md"}}
+	case "application/json":
+		return []runtime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}
+	case "application/pdf":
+		return []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}
+	case "image/png":
+		return []runtime.FileFilter{{DisplayName: "PNG image (*.png)", Pattern: "*.png"}}
+	}
+	if ext != "" {
+		return []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, ".")) + " files (*" + ext + ")", Pattern: "*" + ext}}
+	}
+	return []runtime.FileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}}
+}
+
+// AttachmentDataURL returns a safe data URL for a stored image attachment.
+func (a *App) AttachmentDataURL(path string) (string, error) {
+	return a.withActiveWorkspace(func() (string, error) {
+		return control.ImageDataURL(path)
+	})
+}
+
+// DroppedItem is one OS-dropped file resolved into a composer context entry: an
+// in-tree file becomes a workspace @reference (read in place, no copy), while an
+// outside directory becomes a session-scoped workspace @reference; an image or
+// out-of-tree file is copied into .reasonix/attachments.
+type DroppedItem struct {
+	Kind        string `json:"kind"` // "workspace" | "attachment"
+	Path        string `json:"path"`
+	IsDir       bool   `json:"isDir,omitempty"`
+	DisplayPath string `json:"displayPath,omitempty"`
+	PreviewURL  string `json:"previewUrl,omitempty"`
+}
+
+// AttachDropped turns an absolute path from the native file-drop bridge into a
+// composer context entry. Images are stored as attachments so the chip shows a
+// thumbnail; in-workspace files are referenced relatively (no copy); directories
+// outside the workspace are registered as current-session folder references;
+// files outside the workspace are copied into .reasonix/attachments.
+func (a *App) AttachDropped(path string) (DroppedItem, error) {
+	var item DroppedItem
+	err := a.withActiveWorkspaceDo(func() error {
+		info, err := os.Lstat(path)
+		if err != nil {
+			return err
+		}
+		if isImageExt(path) {
+			if rel, err := control.SaveImageFile(path); err == nil {
+				preview, _ := control.ImageDataURL(rel)
+				item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}
+				return nil
+			}
+		}
+		if rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {
+			item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}
+			return nil
+		}
+		if info.IsDir() {
+			tab, ctrl := a.tabAndCtrlByID("")
+			if err := a.ensureTabControllerWorkspace(tab); err != nil {
+				return err
+			}
+			if tab != nil {
+				ctrl = a.controllerForTab(tab)
+			}
+			if ctrl == nil {
+				return fmt.Errorf("workspace is not ready")
+			}
+			token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
+			if err != nil {
+				return err
+			}
+			item = DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}
+			return nil
+		}
+		rel, err := control.SaveAttachmentFile(path)
+		if err != nil {
+			return err
+		}
+		item = DroppedItem{Kind: "attachment", Path: rel}
+		return nil
+	})
+	if err != nil {
+		return DroppedItem{}, err
+	}
+	return item, nil
+}
+
+func isImageExt(path string) bool {
+	switch strings.ToLower(filepath.Ext(path)) {
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp":
+		return true
+	}
+	return false
+}
+
+func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
+	root := workspaceRoot
+	if !filepath.IsAbs(root) {
+		abs, err := filepath.Abs(root)
+		if err != nil {
+			return "", false
+		}
+		root = abs
+	}
+	rel, err := filepath.Rel(root, path)
+	if err != nil {
+		return "", false
+	}
+	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
+		return "", false
+	}
+	return filepath.ToSlash(rel), true
+}
+
+// --- memory panel (frontend ⇄ controller) ---
+
+// MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body.
+type MemoryDoc struct {
+	Path  string `json:"path"`
+	Scope string `json:"scope"`
+	Body  string `json:"body"`
+}
+
+// MemoryFact is one saved auto-memory, surfaced read-only in the panel.
+type MemoryFact struct {
+	Name        string `json:"name"`
+	Title       string `json:"title,omitempty"`
+	Description string `json:"description"`
+	Type        string `json:"type"`
+	Body        string `json:"body"`
+}
+
+// MemoryArchive is one archived auto-memory kept only for inspection.
+type MemoryArchive struct {
+	Name        string `json:"name"`
+	Title       string `json:"title,omitempty"`
+	Description string `json:"description"`
+	Type        string `json:"type"`
+	Body        string `json:"body"`
+	Path        string `json:"path"`
+	ArchivedAt  string `json:"archivedAt,omitempty"`
+}
+
+// MemoryScope is one writable quick-add target (scope id + the file it writes to).
+type MemoryScope struct {
+	Scope string `json:"scope"`
+	Path  string `json:"path"`
+}
+
+// MemoryView is the whole memory panel payload: hierarchical docs, active saved
+// facts, archived facts, and the writable scopes for the quick-add selector.
+type MemoryView struct {
+	Docs           []MemoryDoc     `json:"docs"`
+	Facts          []MemoryFact    `json:"facts"`
+	Archives       []MemoryArchive `json:"archives"`
+	Scopes         []MemoryScope   `json:"scopes"`
+	StoreDir       string          `json:"storeDir"`
+	StoreGlobalDir string          `json:"storeGlobalDir,omitempty"`
+	Available      bool            `json:"available"`
+}
+
+// writableScopes are the quick-add targets the panel offers, broad → specific.
+var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}
+
+// Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,
+// active/archived auto-memories, and the writable scopes. Read-only; mutations
+// go through Remember / SaveDoc.
+func (a *App) Memory() MemoryView {
+	return a.memoryForCtrl(nil, true)
+}
+
+// MemoryForTab returns the loaded memory for a specific tab's controller,
+// so the panel can show memory for any open project, not just the active tab.
+// If the tab does not exist or has no controller, returns an empty view
+// instead of falling back to the active tab (which would show the wrong data).
+// An empty tabID is treated as "no tab specified" and falls back to the
+// active tab for backward compatibility.
+func (a *App) MemoryForTab(tabID string) MemoryView {
+	if tabID == "" {
+		return a.memoryForCtrl(nil, true)
+	}
+	return a.memoryForCtrl(a.ctrlByTabID(tabID), false)
+}
+
+func (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {
+	view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}}
+	if ctrl == nil {
+		if !fallback {
+			return view
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return view
+		}
+	}
+	set := ctrl.Memory()
+	if set == nil {
+		return view
+	}
+	view.StoreDir = set.Store.Dir
+	view.StoreGlobalDir = set.Store.GlobalDir
+	view.Available = true
+	for _, d := range set.Docs {
+		view.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body})
+	}
+	for _, f := range set.Store.List() {
+		view.Facts = append(view.Facts, MemoryFact{
+			Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,
+		})
+	}
+	for _, f := range set.Store.ListArchived() {
+		archivedAt := ""
+		if !f.ArchivedAt.IsZero() {
+			archivedAt = f.ArchivedAt.Format(time.RFC3339)
+		}
+		view.Archives = append(view.Archives, MemoryArchive{
+			Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body,
+			Path: f.Path, ArchivedAt: archivedAt,
+		})
+	}
+	for _, sc := range writableScopes {
+		if p := set.DocPath(sc); p != "" {
+			view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})
+		}
+	}
+	return view
+}
+
+// Remember quick-adds a one-line note to the doc-memory file for scope — the
+// panel's explicit "remember" action, equivalent to typing "/remember ".
+// An unknown scope falls back to project. Returns the file written.
+func (a *App) Remember(scope, note string) (string, error) {
+	return a.rememberForCtrl(nil, scope, note, true)
+}
+
+func (a *App) RememberForTab(tabID, scope, note string) (string, error) {
+	if tabID == "" {
+		return a.rememberForCtrl(nil, scope, note, true)
+	}
+	return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)
+}
+
+func (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {
+	if ctrl == nil {
+		if !fallback {
+			return "", nil
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return "", nil
+		}
+	}
+	return ctrl.QuickAdd(parseScope(scope), note)
+}
+
+// Forget deletes a saved auto-memory by name — the panel's delete action for a
+// fact the model owns. A no-op when no controller is attached.
+func (a *App) Forget(name string) error {
+	return a.forgetForCtrl(nil, name, true)
+}
+
+func (a *App) ForgetForTab(tabID, name string) error {
+	if tabID == "" {
+		return a.forgetForCtrl(nil, name, true)
+	}
+	return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)
+}
+
+func (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {
+	if ctrl == nil {
+		if !fallback {
+			return nil
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return nil
+		}
+	}
+	return ctrl.ForgetMemory(name)
+}
+
+// SaveDoc overwrites a memory doc with the panel editor's contents. The controller
+// validates path against the recognized memory files. Returns the file written.
+func (a *App) SaveDoc(path, body string) (string, error) {
+	return a.saveDocForCtrl(nil, path, body, true)
+}
+
+func (a *App) SaveDocForTab(tabID, path, body string) (string, error) {
+	if tabID == "" {
+		return a.saveDocForCtrl(nil, path, body, true)
+	}
+	return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)
+}
+
+func (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {
+	if ctrl == nil {
+		if !fallback {
+			return "", nil
+		}
+		a.mu.RLock()
+		ctrl = a.activeCtrlLocked()
+		a.mu.RUnlock()
+		if ctrl == nil {
+			return "", nil
+		}
+	}
+	return ctrl.SaveDoc(path, body)
+}
+
+// parseScope maps a frontend scope id to a memory.Scope, defaulting to project.
+func parseScope(s string) memory.Scope {
+	switch memory.Scope(s) {
+	case memory.ScopeUser:
+		return memory.ScopeUser
+	case memory.ScopeLocal:
+		return memory.ScopeLocal
+	default:
+		return memory.ScopeProject
+	}
+}
+
+// onboardingKeyEnv is the default provider (deepseek) key from config.Default().
+const onboardingKeyEnv = "DEEPSEEK_API_KEY"
+
+// onboardingBalanceURL doubles as a zero-token connectivity + auth probe:
+// billing.FetchWithClient surfaces 401/403 for a bad key.
+const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
+
+var connectKeyBalanceFetch = billing.FetchWithClient
+
+// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
+// dialog that replaces web-style confirm() for destructive or important actions.
+type NativeConfirmRequest struct {
+	Title        string `json:"title"`
+	Message      string `json:"message"`
+	Detail       string `json:"detail"`
+	ConfirmLabel string `json:"confirmLabel"`
+	CancelLabel  string `json:"cancelLabel"`
+	Destructive  bool   `json:"destructive"`
+}
+
+// ConfirmAction shows a native confirmation dialog and returns true when the user
+// clicks the confirm button. For destructive actions the dialog type is Warning so
+// the platform can apply its danger styling (red tint on macOS, etc.).
+func (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {
+	if a.ctx == nil {
+		return false, nil
+	}
+	dialogType := runtime.QuestionDialog
+	if req.Destructive {
+		dialogType = runtime.WarningDialog
+	}
+	confirm := req.ConfirmLabel
+	if confirm == "" {
+		confirm = "OK"
+	}
+	cancel := req.CancelLabel
+	if cancel == "" {
+		cancel = "Cancel"
+	}
+	title := req.Title
+	if title == "" {
+		title = req.Message
+	}
+	body := req.Message
+	if req.Detail != "" {
+		if body != "" {
+			body += "\n\n" + req.Detail
+		} else {
+			body = req.Detail
+		}
+	}
+	defaultBtn := confirm
+	if req.Destructive {
+		// On destructive actions, make cancel the default so Enter / Space
+		// does NOT accidentally confirm. ESC always maps to CancelButton.
+		defaultBtn = cancel
+	}
+	result, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
+		Type:          dialogType,
+		Title:         title,
+		Message:       body,
+		Buttons:       []string{confirm, cancel},
+		DefaultButton: defaultBtn,
+		CancelButton:  cancel,
+	})
+	if err != nil {
+		return false, err
+	}
+	return result == confirm, nil
+}
+
+func (a *App) NeedsOnboarding() bool {
+	return !config.CredentialStored(onboardingKeyEnv)
+}
+
+// ConnectKey validates apiKey against the balance endpoint, persists it to
+// Reasonix's global .env, and rebuilds the controller so the new key takes effect.
+func (a *App) ConnectKey(apiKey string) (string, error) {
+	apiKey = strings.TrimSpace(apiKey)
+	if apiKey == "" {
+		return "", fmt.Errorf("key is required")
+	}
+	if tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) {
+		return "", rebuildControllerActiveWorkError("provider key")
+	}
+	ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
+	defer cancel()
+	if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
+		return "", fmt.Errorf("validate: %w", err)
+	}
+	warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
+	if err != nil {
+		return "", fmt.Errorf("save: %w", err)
+	}
+	if err := a.rebuildSetting("provider key"); err != nil {
+		if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok {
+			warning = appendSettingsWarning(warning, rebuildWarning)
+		} else {
+			return "", err
+		}
+	}
+	return warning, nil
+}
diff --git a/desktop/app_autosave_test.go b/desktop/app_autosave_test.go
new file mode 100644
index 0000000..d4792ad
--- /dev/null
+++ b/desktop/app_autosave_test.go
@@ -0,0 +1,660 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/provider"
+	"reasonix/internal/tool"
+)
+
+type stubProvider struct{}
+
+func (stubProvider) Name() string { return "stub" }
+
+func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
+	ch := make(chan provider.Chunk, 1)
+	close(ch)
+	return ch, nil
+}
+
+func controllerWithContent(t *testing.T, path string) *control.Controller {
+	t.Helper()
+	sess := agent.NewSession("system")
+	sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
+	sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
+}
+
+func waitForFile(t *testing.T, path, want string) {
+	t.Helper()
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
+			return
+		}
+		time.Sleep(5 * time.Millisecond)
+	}
+	t.Fatalf("session file %q never contained %q", path, want)
+}
+
+func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
+	t.Helper()
+	waitForAutosaveIdleWithin(t, tab, 2*time.Second)
+}
+
+func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
+	t.Helper()
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		tab.saveMu.Lock()
+		idle := !tab.saving && !tab.saveAgain
+		tab.saveMu.Unlock()
+		if idle {
+			return
+		}
+		time.Sleep(5 * time.Millisecond)
+	}
+	t.Fatal("autosave loop did not become idle")
+}
+
+func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
+	t.Helper()
+	ctrl := controllerWithContent(t, path)
+	tab := &WorkspaceTab{
+		ID:            "test_tab",
+		Ctrl:          ctrl,
+		Scope:         "global",
+		WorkspaceRoot: "",
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
+	a := &App{
+		tabs:        map[string]*WorkspaceTab{"test_tab": tab},
+		activeTabID: "test_tab",
+	}
+	tab.sink.app = a
+	return a, tab
+}
+
+// TestTurnDonePersistsSession proves a completed turn is written to disk without
+// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
+// nil sink ctx (no webview) must not disable persistence.
+func TestTurnDonePersistsSession(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+	_, tab := appWithTab(t, path)
+
+	tab.sink.Emit(event.Event{Kind: event.TurnDone})
+
+	waitForFile(t, path, "remember this turn")
+	waitForAutosaveIdle(t, tab)
+}
+
+// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
+// per-token event storm doesn't thrash the disk.
+func TestNonTurnDoneDoesNotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+	a, tab := appWithTab(t, path)
+	_ = a
+
+	tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
+
+	time.Sleep(50 * time.Millisecond)
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
+	}
+}
+
+// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
+// single-flight loop neither panics nor drops the final write.
+func TestScheduleSnapshotCoalesces(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+	a, tab := appWithTab(t, path)
+	_ = a
+
+	var wg sync.WaitGroup
+	for i := 0; i < 64; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			tab.sink.Emit(event.Event{Kind: event.TurnDone})
+		}()
+	}
+	wg.Wait()
+
+	waitForFile(t, path, "acknowledged")
+	waitForAutosaveIdle(t, tab)
+}
+
+func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, tab := appWithTab(t, path)
+	_ = a
+
+	tab.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForAutosaveIdleWithin(t, tab, 5*time.Second)
+
+	tab.saveMu.Lock()
+	failures := tab.saveFailures
+	tab.saveMu.Unlock()
+	if failures == 0 {
+		t.Fatal("autosave failure should be recorded and retried")
+	}
+	if info, err := os.Stat(path); err != nil || !info.IsDir() {
+		t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
+	}
+
+	if err := os.Remove(path); err != nil {
+		t.Fatalf("remove blocked dir: %v", err)
+	}
+	tab.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForFile(t, path, "remember this turn")
+	waitForAutosaveIdle(t, tab)
+
+	tab.saveMu.Lock()
+	failures = tab.saveFailures
+	tab.saveMu.Unlock()
+	if failures != 0 {
+		t.Fatalf("autosave failures after recovery = %d, want 0", failures)
+	}
+}
+
+func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	root := globalTabWorkspaceRoot()
+	dir := desktopSessionDir(root)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	originalPath := filepath.Join(dir, "session.jsonl")
+	originalTopic := "topic_original"
+	if err := setTopicTitle("", originalTopic, "Original"); err != nil {
+		t.Fatalf("set original topic title: %v", err)
+	}
+	current := agent.NewSession("sys")
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := current.Save(originalPath); err != nil {
+		t.Fatalf("Save current: %v", err)
+	}
+	if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
+		Scope:         "global",
+		TopicID:       originalTopic,
+		TopicTitle:    "Original",
+		Preview:       "first",
+		Turns:         2,
+		SchemaVersion: agent.BranchMetaCountsVersion,
+	}); err != nil {
+		t.Fatalf("SaveBranchMeta original: %v", err)
+	}
+
+	staleSess := agent.NewSession("sys")
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
+	app := &App{
+		tabs:             map[string]*WorkspaceTab{},
+		detachedSessions: map[string]*WorkspaceTab{},
+		activeTabID:      "recovery_tab",
+	}
+	tab := &WorkspaceTab{
+		ID:            "recovery_tab",
+		Scope:         "global",
+		WorkspaceRoot: root,
+		TopicID:       originalTopic,
+		TopicTitle:    "Original",
+		SessionPath:   originalPath,
+		Ready:         true,
+		model:         "test-model",
+		disabledMCP:   map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	tab.Ctrl = control.New(control.Options{
+		Executor:            staleExec,
+		SessionDir:          dir,
+		SessionPath:         originalPath,
+		Label:               "test",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	app.tabs[tab.ID] = tab
+
+	if err := tab.Ctrl.Snapshot(); err != nil {
+		t.Fatalf("Snapshot: %v", err)
+	}
+	recoveryPath := tab.Ctrl.SessionPath()
+	if recoveryPath == "" || recoveryPath == originalPath {
+		t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
+	}
+	if tab.SessionPath != recoveryPath {
+		t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
+	}
+	if tab.TopicID != originalTopic {
+		t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
+	}
+	meta, ok, err := agent.LoadBranchMeta(recoveryPath)
+	if err != nil || !ok {
+		t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
+	}
+	if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
+		t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
+	}
+	tabMeta := app.tabMeta(tab, true)
+	if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
+		t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
+	}
+	nodes := app.ListProjectTree()
+	foundOriginal := false
+	var walk func([]ProjectNode)
+	walk = func(list []ProjectNode) {
+		for _, node := range list {
+			if node.Recovered {
+				t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
+			}
+			if node.TopicID == originalTopic {
+				foundOriginal = true
+			}
+			walk(node.Children)
+		}
+	}
+	walk(nodes)
+	if !foundOriginal {
+		t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
+	}
+}
+
+func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	root := globalTabWorkspaceRoot()
+	dir := desktopSessionDir(root)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	originalPath := filepath.Join(dir, "session.jsonl")
+	current := agent.NewSession("sys")
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := current.Save(originalPath); err != nil {
+		t.Fatalf("Save current: %v", err)
+	}
+
+	staleSess := agent.NewSession("sys")
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
+		OriginalPath: originalPath,
+		BranchMeta: agent.BranchMeta{
+			Name:       agent.RecoveryBranchDefaultName,
+			Scope:      "global",
+			TopicID:    "topic_recovery",
+			TopicTitle: "Recovery",
+		},
+	})
+	if err != nil {
+		t.Fatalf("SaveRecoveryBranch: %v", err)
+	}
+	lease, err := agent.TryAcquireSessionLease(recovery.Path)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease recovery: %v", err)
+	}
+	defer lease.Release()
+
+	staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
+	runtimeEvents := make(chan runtimeEventEnvelope, 4)
+	app := &App{
+		ctx:              context.Background(),
+		tabs:             map[string]*WorkspaceTab{},
+		detachedSessions: map[string]*WorkspaceTab{},
+		activeTabID:      "recovery_tab",
+	}
+	app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
+		runtimeEvents <- runtimeEventEnvelope{
+			ctx:     ctx,
+			name:    name,
+			payload: append([]interface{}(nil), payload...),
+		}
+	}
+	tab := &WorkspaceTab{
+		ID:            "recovery_tab",
+		Scope:         "global",
+		WorkspaceRoot: root,
+		TopicID:       "topic_original",
+		TopicTitle:    "Original",
+		SessionPath:   originalPath,
+		Ready:         true,
+		model:         "test-model",
+		disabledMCP:   map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	tab.Ctrl = control.New(control.Options{
+		Executor:            staleExec,
+		SessionDir:          dir,
+		SessionPath:         originalPath,
+		Label:               "test",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	app.tabs[tab.ID] = tab
+
+	err = tab.Ctrl.Snapshot()
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if got := tab.Ctrl.SessionPath(); got != originalPath {
+		t.Fatalf("controller session path = %q, want original %q", got, originalPath)
+	}
+	if tab.SessionPath != originalPath {
+		t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
+	}
+	if tab.TopicID != "topic_original" {
+		t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
+	}
+
+	deadline := time.After(time.Second)
+	for {
+		select {
+		case emitted := <-runtimeEvents:
+			if emitted.name != "session:recovery-failed" {
+				continue
+			}
+			if len(emitted.payload) != 1 {
+				t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
+			}
+			failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
+			if !ok {
+				t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
+			}
+			if failed.Reason != "lease_held" {
+				t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
+			}
+			return
+		case <-deadline:
+			t.Fatal("session:recovery-failed event was not emitted")
+		}
+	}
+}
+
+func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, _ := appWithTab(t, path)
+	a.tabs["target_tab"] = &WorkspaceTab{
+		ID:          "target_tab",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	a.tabOrder = []string{"test_tab", "target_tab"}
+
+	err := a.SetActiveTab("target_tab")
+	if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
+		t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
+	}
+	if a.activeTabID != "test_tab" {
+		t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
+	}
+}
+
+func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, tab := appWithTab(t, path)
+	target := filepath.Join(t.TempDir(), "target.jsonl")
+	sess := agent.NewSession("system")
+	sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
+	if err := sess.Save(target); err != nil {
+		t.Fatalf("save target: %v", err)
+	}
+	loaded, err := agent.LoadSession(target)
+	if err != nil {
+		t.Fatalf("load target: %v", err)
+	}
+
+	err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
+	if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
+		t.Fatalf("rebind error = %v, want persistence failure", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
+		t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
+	}
+}
+
+// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
+// It proves that after CloseTab returns, the per-turn autosave goroutine can no
+// longer write the session file — even when it is in flight at the moment the
+// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
+// captured session path, so its Snapshot() call landed after DeleteSession
+// trashed the file, "resurrecting" it.
+func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "session.jsonl")
+
+	doomed, doomedTab := appWithTab(t, path)
+	// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
+	survivor := &WorkspaceTab{
+		ID:          "survivor_tab",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
+	doomed.tabs["survivor_tab"] = survivor
+	doomed.activeTabID = "test_tab"
+
+	// Write the session file once via the autosave loop, then wait for idle so
+	// the next TurnDone reliably kicks off a fresh loop.
+	doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForFile(t, path, "acknowledged")
+	waitForAutosaveIdle(t, doomedTab)
+
+	// Kick the autosave loop and close the tab in close succession. The loop
+	// will be in flight when CloseTab runs — exactly the #4384 window.
+	doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
+	if err := doomed.CloseTab("test_tab"); err != nil {
+		t.Fatalf("CloseTab: %v", err)
+	}
+
+	// CloseTab must have returned only after the autosave loop finished. Remove
+	// the file the way DeleteSession would (move to trash is just a remove here
+	// since we only care that nothing rewrites the original path).
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		t.Fatalf("remove session file: %v", err)
+	}
+
+	// Give any would-be resurrection a chance to strike. If the autosave loop
+	// were still alive (the bug), the file reappears here.
+	time.Sleep(100 * time.Millisecond)
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
+	}
+
+	// And the controller's session path must be cleared so no future Snapshot
+	// can write either.
+	if got := doomedTab.Ctrl.SessionPath(); got != "" {
+		t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
+	}
+}
+
+func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "blocked.jsonl")
+	if err := os.Mkdir(path, 0o755); err != nil {
+		t.Fatalf("mkdir blocked path: %v", err)
+	}
+	a, tab := appWithTab(t, path)
+	survivor := &WorkspaceTab{
+		ID:          "survivor_tab",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
+	a.tabs[survivor.ID] = survivor
+	a.tabOrder = []string{tab.ID, survivor.ID}
+
+	err := a.CloseTab(tab.ID)
+	if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
+		t.Fatalf("CloseTab error = %v, want persistence failure", err)
+	}
+	if _, ok := a.tabs[tab.ID]; !ok {
+		t.Fatal("tab was removed even though its session could not be saved")
+	}
+	if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
+		t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
+	}
+}
+
+// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
+// closing/drain logic is per-tab and must not leak to other tabs.
+func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
+	doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
+	survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
+
+	a, _ := appWithTab(t, doomedPath)
+	survivorCtrl := controllerWithContent(t, survivorPath)
+	survivor := &WorkspaceTab{
+		ID:          "survivor_tab",
+		Ctrl:        survivorCtrl,
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
+	a.tabs["survivor_tab"] = survivor
+	a.activeTabID = "test_tab"
+
+	survivor.sink.Emit(event.Event{Kind: event.TurnDone})
+	waitForFile(t, survivorPath, "acknowledged")
+	waitForAutosaveIdle(t, survivor)
+
+	if err := a.CloseTab("test_tab"); err != nil {
+		t.Fatalf("CloseTab: %v", err)
+	}
+
+	if got := survivor.Ctrl.SessionPath(); got != survivorPath {
+		t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
+	}
+	if survivor.closing {
+		t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
+	}
+}
+
+func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := t.TempDir()
+	path := filepath.Join(dir, "delete-open.jsonl")
+	ctrl := controllerWithContent(t, path)
+	tab := &WorkspaceTab{
+		ID:          "delete_open",
+		Scope:       "global",
+		Ready:       true,
+		Ctrl:        ctrl,
+		disabledMCP: map[string]ServerView{},
+	}
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"delete_open": tab},
+		activeTabID: "delete_open",
+	}
+	if err := ctrl.Snapshot(); err != nil {
+		t.Fatalf("snapshot: %v", err)
+	}
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession: %v", err)
+	}
+
+	if got := ctrl.SessionPath(); got != "" {
+		t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("session should be in trash: %v", err)
+	}
+}
+
+func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_clear_removed_runtime"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := t.TempDir()
+	path := filepath.Join(dir, "trash-open-topic.jsonl")
+	ctrl := controllerWithContent(t, path)
+	if err := ctrl.Snapshot(); err != nil {
+		t.Fatalf("snapshot: %v", err)
+	}
+	if err := agent.SaveBranchMeta(path, agent.BranchMeta{
+		CreatedAt:     time.Now().Add(-time.Minute),
+		UpdatedAt:     time.Now(),
+		Scope:         "project",
+		WorkspaceRoot: projectRoot,
+		TopicID:       topicID,
+		TopicTitle:    "Clear removed runtime",
+	}); err != nil {
+		t.Fatalf("save branch meta: %v", err)
+	}
+	tab := &WorkspaceTab{
+		ID:            "trash_open",
+		Scope:         "project",
+		WorkspaceRoot: projectRoot,
+		TopicID:       topicID,
+		TopicTitle:    "Clear removed runtime",
+		Ready:         true,
+		Ctrl:          ctrl,
+		disabledMCP:   map[string]ServerView{},
+	}
+	survivor := &WorkspaceTab{
+		ID:          "survivor",
+		Scope:       "global",
+		Ready:       true,
+		disabledMCP: map[string]ServerView{},
+	}
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
+		tabOrder:    []string{"trash_open", "survivor"},
+		activeTabID: "trash_open",
+	}
+
+	if err := app.TrashTopic(topicID); err != nil {
+		t.Fatalf("TrashTopic: %v", err)
+	}
+
+	if got := ctrl.SessionPath(); got != "" {
+		t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("topic session should be in trash: %v", err)
+	}
+}
diff --git a/desktop/app_platform_test.go b/desktop/app_platform_test.go
new file mode 100644
index 0000000..af6eea1
--- /dev/null
+++ b/desktop/app_platform_test.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+	"runtime"
+	"testing"
+)
+
+func TestAppPlatformReturnsRuntimeGOOS(t *testing.T) {
+	app := NewApp()
+
+	if got := app.Platform(); got != runtime.GOOS {
+		t.Fatalf("Platform() = %q, want %q", got, runtime.GOOS)
+	}
+}
diff --git a/desktop/app_session_dedup_test.go b/desktop/app_session_dedup_test.go
new file mode 100644
index 0000000..f3e8ca9
--- /dev/null
+++ b/desktop/app_session_dedup_test.go
@@ -0,0 +1,799 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/provider"
+	"reasonix/internal/tool"
+)
+
+func carryingController(carried []provider.Message, path string) *control.Controller {
+	sess := &agent.Session{}
+	sess.Replace(carried)
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
+}
+
+// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
+// config change rebuilds the controller and carries the conversation forward. Each
+// rebuild must keep writing to the same file, so a run of them leaves exactly one
+// history entry — not a new identical duplicate per rebuild.
+func TestCarriedRebuildsKeepOneSession(t *testing.T) {
+	dir := t.TempDir()
+	path := agent.NewSessionPath(dir, "model-a")
+	ctrl := controllerWithContent(t, path)
+	if err := ctrl.Snapshot(); err != nil {
+		t.Fatal(err)
+	}
+
+	for i := 0; i < 5; i++ {
+		prevPath := ctrl.SessionPath()
+		carried := ctrl.History()
+		ctrl.Close()
+
+		newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
+		ctrl = carryingController(carried, newPath)
+		if err := ctrl.Snapshot(); err != nil {
+			t.Fatal(err)
+		}
+	}
+	ctrl.Close()
+
+	infos, err := agent.ListSessions(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(infos) != 1 {
+		paths := make([]string, len(infos))
+		for i, s := range infos {
+			paths[i] = filepath.Base(s.Path)
+		}
+		t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
+	}
+}
+
+// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
+
+func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if first.SessionPath == "" {
+		t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
+	}
+	if _, err := os.Stat(first.SessionPath); err != nil {
+		t.Fatalf("pre-created blank session should exist: %v", err)
+	}
+	second, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.ID != first.ID {
+		t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
+	}
+	if tabs := app.ListTabs(); len(tabs) != 1 {
+		t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
+	}
+}
+
+func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	globalRoot := globalWorkspaceRoot()
+	if err := os.MkdirAll(globalRoot, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
+	if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	topic, err := app.CreateTopic("global", "", "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	app.tabs["blank"] = &WorkspaceTab{
+		ID:            "blank",
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		TopicID:       topic.ID,
+		TopicTitle:    defaultTopicTitle,
+		SessionPath:   sessionPath,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabOrder = []string{"blank"}
+	app.activeTabID = "blank"
+
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.ID != "blank" {
+		t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
+	}
+}
+
+func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	topic, err := app.CreateTopic("global", "", "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	globalRoot := globalWorkspaceRoot()
+	dir := desktopSessionDir(globalRoot)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	stubPath := filepath.Join(dir, "empty-stub.jsonl")
+	if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
+		t.Fatalf("write empty stub: %v", err)
+	}
+	now := time.Now()
+	if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
+		CreatedAt:     now.Add(-time.Minute),
+		UpdatedAt:     now,
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		TopicID:       topic.ID,
+		TopicTitle:    defaultTopicTitle,
+	}); err != nil {
+		t.Fatalf("save branch meta: %v", err)
+	}
+
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID != topic.ID {
+		t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
+	}
+}
+
+func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	before := time.Now().UnixMilli()
+	meta, err := app.EnsureBlankTab("global", "")
+	after := time.Now().UnixMilli()
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+
+	createdAt := loadTopicCreatedAt("", meta.TopicID)
+	if createdAt < before || createdAt > after {
+		t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
+	}
+
+	nodes := app.ListProjectTree()
+	if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
+		t.Fatalf("project tree = %#v, want Global with one topic", nodes)
+	}
+	if got := nodes[0].Children[0].CreatedAt; got != createdAt {
+		t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
+	}
+}
+
+func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	const topicID = "topic_20260704-104018_deadbeef"
+	if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	if err := prependTopicInProjectsFile("", topicID, false); err != nil {
+		t.Fatalf("prepend topic: %v", err)
+	}
+	if got := loadTopicCreatedAt("", topicID); got != 0 {
+		t.Fatalf("createdAt before reuse = %d, want 0", got)
+	}
+
+	app := NewApp()
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID != topicID {
+		t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
+	}
+
+	expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
+	if got := loadTopicCreatedAt("", topicID); got != expected {
+		t.Fatalf("repaired createdAt = %d, want %d", got, expected)
+	}
+}
+
+// EnsureBlankTab reuses an already-open project-scoped blank tab.
+
+func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatal(err)
+	}
+	second, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.ID != first.ID {
+		t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
+	}
+	if tabs := app.ListTabs(); len(tabs) != 1 {
+		t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
+	}
+}
+
+func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectA)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project A): %v", err)
+	}
+	tabA := waitForTabReady(t, app, first.ID)
+	if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
+	}
+
+	second, err := app.EnsureBlankTab("project", projectB)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project B): %v", err)
+	}
+	if second.ID == first.ID {
+		t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
+	}
+	tabB := waitForTabReady(t, app, second.ID)
+
+	if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
+		t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
+	}
+	if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
+		t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
+	}
+	sys := systemPromptFrom(tabB.Ctrl.History())
+	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
+		t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
+	}
+	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
+		t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
+	}
+}
+
+func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
+	if err != nil {
+		t.Fatalf("create project A empty session: %v", err)
+	}
+	tab := &WorkspaceTab{
+		ID:            "blank-project-b",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		SessionPath:   pathA,
+	}
+
+	if blankTabSessionPathHasNoContent(tab) {
+		t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
+	}
+}
+
+func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectA)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project A): %v", err)
+	}
+	waitForTabReady(t, app, first.ID)
+
+	second, err := app.EnsureBlankTab("project", projectB)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project B): %v", err)
+	}
+	tabB := waitForTabReady(t, app, second.ID)
+	ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
+	turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
+
+	forked, err := app.Fork(turn)
+	if err != nil {
+		t.Fatalf("Fork: %v", err)
+	}
+	if forked.ID == "" || forked.ID == second.ID {
+		t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
+	}
+	forkTab := waitForTabReady(t, app, forked.ID)
+	if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	sys := systemPromptFrom(forkTab.Ctrl.History())
+	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
+		t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
+	}
+	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
+		t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
+	}
+}
+
+func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := robustTempDir(t)
+	projectB := robustTempDir(t)
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	app := NewApp()
+	first, err := app.EnsureBlankTab("project", projectA)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project A): %v", err)
+	}
+	waitForTabReady(t, app, first.ID)
+
+	second, err := app.EnsureBlankTab("project", projectB)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab(project B): %v", err)
+	}
+	tabB := waitForTabReady(t, app, second.ID)
+	ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
+	turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
+
+	if err := app.Rewind(turn, "conversation"); err != nil {
+		t.Fatalf("Rewind: %v", err)
+	}
+	if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
+		t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
+	}
+	sys := systemPromptFrom(tabB.Ctrl.History())
+	if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
+		t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
+	}
+	if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
+		t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
+	}
+}
+
+func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
+	t.Helper()
+	if tab == nil || tab.Ctrl == nil {
+		t.Fatal("tab controller is required")
+	}
+	sys := systemPromptFrom(tab.Ctrl.History())
+	if strings.TrimSpace(sys) == "" {
+		t.Fatal("tab controller did not expose a system prompt")
+	}
+	sessionDir := tab.Ctrl.SessionDir()
+	sessionPath := tab.Ctrl.SessionPath()
+	workspaceRoot := tab.Ctrl.WorkspaceRoot()
+	label := tab.Ctrl.Label()
+	tab.Ctrl.Close()
+
+	sess := agent.NewSession(sys)
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	ctrl := control.New(control.Options{
+		Runner:        ag,
+		Executor:      ag,
+		SessionDir:    sessionDir,
+		SessionPath:   sessionPath,
+		WorkspaceRoot: workspaceRoot,
+		Label:         label,
+		SystemPrompt:  sys,
+		Sink:          event.Discard,
+	})
+	tab.Ctrl = ctrl
+	app.bindControllerDisplayRecorder(ctrl)
+	return ctrl
+}
+
+func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
+	t.Helper()
+	ctrl.SubmitUserTurn(input, input)
+	waitNotRunning(t, ctrl)
+
+	deadline := time.Now().Add(time.Second)
+	for {
+		checkpoints := ctrl.Checkpoints()
+		if len(checkpoints) > 0 {
+			return checkpoints[len(checkpoints)-1].Turn
+		}
+		if time.Now().After(deadline) {
+			t.Fatal("controller did not record a checkpoint")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
+		t.Fatalf("set stale auto title: %v", err)
+	}
+	tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
+	app.tabs[tab.ID] = tab
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if got := meta.TopicTitle; got != defaultTopicTitle {
+		t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
+	}
+	if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
+		t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
+	}
+	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
+		t.Fatalf("title source = %q, want auto", got)
+	}
+}
+
+func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "Manual title")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
+	app.tabs[tab.ID] = tab
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if got := meta.TopicTitle; got != "Manual title" {
+		t.Fatalf("reused manual topic title = %q, want Manual title", got)
+	}
+	if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
+		t.Fatalf("stored title = %q, want Manual title", got)
+	}
+	if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
+		t.Fatalf("title source = %q, want manual", got)
+	}
+}
+
+func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatalf("create topic: %v", err)
+	}
+	if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
+		t.Fatalf("set stale auto title: %v", err)
+	}
+	activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
+	reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
+	app.tabs[activeTab.ID] = activeTab
+	app.tabs[reusableTab.ID] = reusableTab
+	app.tabOrder = []string{activeTab.ID, reusableTab.ID}
+	app.activeTabID = activeTab.ID
+
+	titlePath := topicTitlesPath(projectRoot)
+	if err := os.Remove(titlePath); err != nil {
+		t.Fatalf("remove title file: %v", err)
+	}
+	if err := os.Mkdir(titlePath, 0o755); err != nil {
+		t.Fatalf("replace title file with directory: %v", err)
+	}
+
+	if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
+		t.Fatal("EnsureBlankTab succeeded, want title reset error")
+	}
+	if got := app.activeTabID; got != activeTab.ID {
+		t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
+	}
+}
+
+// EnsureBlankTab picks up an existing blank topic created in the sidebar
+// instead of creating a fresh topic, for global scope.
+
+func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	topic, err := app.CreateTopic("global", "", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	meta, err := app.EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if meta.TopicID != topic.ID {
+		t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
+	}
+	if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
+		t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
+	}
+}
+
+// EnsureBlankTab picks up an existing blank topic created in the sidebar
+// instead of creating a fresh topic, for project scope.
+
+func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if meta.TopicID != topic.ID {
+		t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
+	}
+	var topics []string
+	for _, project := range loadProjectsFile().Projects {
+		if project.Root == projectRoot {
+			topics = project.Topics
+			break
+		}
+	}
+	if len(topics) != 1 {
+		t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
+	}
+}
+
+func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := robustTempDir(t)
+	app := NewApp()
+	topic, err := app.CreateTopic("project", projectRoot, "")
+	if err != nil {
+		t.Fatalf("CreateTopic: %v", err)
+	}
+	dir := desktopSessionDir(projectRoot)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
+	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
+		t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
+	}
+
+	meta, err := app.EnsureBlankTab("project", projectRoot)
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID == topic.ID {
+		t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
+	}
+	if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
+		t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
+	}
+}
+
+// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
+// into ensureTopicIndexed, whose intentional prepend clears the delete
+// tombstone and resurrects the topic the user removed.
+func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	// Race product on disk: deleted topic whose default title lingered in the
+	// global title map (title-only, absent from GlobalTopics, no sessions).
+	tombstonedID := "topic_tombstone_blank"
+	if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
+		t.Fatalf("set lingering title: %v", err)
+	}
+	if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
+		f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
+		return true, nil
+	}); err != nil {
+		t.Fatalf("seed tombstone: %v", err)
+	}
+
+	meta, err := NewApp().EnsureBlankTab("global", "")
+	if err != nil {
+		t.Fatalf("EnsureBlankTab: %v", err)
+	}
+	if meta.TopicID == tombstonedID {
+		t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
+	}
+	f := loadProjectsFile()
+	if !containsDesktopString(f.DeletedTopics, tombstonedID) {
+		t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
+	}
+	if containsDesktopString(f.GlobalTopics, tombstonedID) {
+		t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
+	}
+}
+
+// NewSession skips the snapshot when the current tab has no real conversation content.
+
+func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := t.TempDir()
+	path := agent.NewSessionPath(dir, "model-a")
+	ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
+	app := NewApp()
+	app.setTestCtrl(ctrl, "model-a")
+
+	if err := app.NewSession(); err != nil {
+		t.Fatal(err)
+	}
+	if got := ctrl.SessionPath(); got != path {
+		t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
+	}
+}
+
+func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	oldTopicID := "topic_old"
+	oldTopicTitle := "Old topic"
+	oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
+	sess := &agent.Session{}
+	sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
+	ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
+	ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "model-a")
+	tab := app.tabs["test"]
+	tab.Scope = "project"
+	tab.WorkspaceRoot = projectRoot
+	tab.TopicID = oldTopicID
+	tab.TopicTitle = oldTopicTitle
+	tab.SessionPath = oldPath
+	app.projectTreeChangedHook = func() {}
+
+	if err := app.NewSession(); err != nil {
+		t.Fatalf("NewSession: %v", err)
+	}
+	if got := tab.TopicID; got == "" || got == oldTopicID {
+		t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
+	}
+	if got := tab.TopicTitle; got != defaultTopicTitle {
+		t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
+	}
+	newPath := ctrl.SessionPath()
+	if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
+		t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
+	}
+
+	if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write new session: %v", err)
+	}
+	if !app.maybeAutoTitleTopic(tab) {
+		t.Fatalf("new session should auto-title its fresh topic")
+	}
+
+	oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
+	if err != nil || !ok {
+		t.Fatalf("load old meta: ok=%v err=%v", ok, err)
+	}
+	if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
+		t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
+	}
+	newMeta, ok, err := agent.LoadBranchMeta(newPath)
+	if err != nil || !ok {
+		t.Fatalf("load new meta: ok=%v err=%v", ok, err)
+	}
+	if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
+		t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
+	}
+}
+
+func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	path := agent.NewSessionPath(dir, "model-a")
+	ctrl := controllerWithContent(t, path)
+	app := NewApp()
+	app.projectTreeChangedHook = func() {}
+	app.setTestCtrl(ctrl, "model-a")
+	tab := app.tabs["test"]
+	tab.TopicID = "topic_old"
+	tab.TopicTitle = "Old topic"
+
+	// Block desktopConfigDir-backed topic-index writes without affecting the
+	// session directory, which exercises the post-NewSession repair failure path.
+	if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
+		t.Fatalf("mkdir desktop config parent: %v", err)
+	}
+	if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
+		t.Fatalf("block desktop config dir: %v", err)
+	}
+
+	if err := app.NewSession(); err != nil {
+		t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
+	}
+	if got := tab.TopicID; got == "" || got == "topic_old" {
+		t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
+	}
+	if got := tab.TopicTitle; got != defaultTopicTitle {
+		t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
+	}
+	if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
+		t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
+	}
+}
diff --git a/desktop/app_test.go b/desktop/app_test.go
new file mode 100644
index 0000000..74b0ccd
--- /dev/null
+++ b/desktop/app_test.go
@@ -0,0 +1,8718 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"reflect"
+	"strconv"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"reasonix/internal/agent"
+	"reasonix/internal/billing"
+	"reasonix/internal/boot"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+	"reasonix/internal/jobs"
+	"reasonix/internal/memory"
+	"reasonix/internal/plugin"
+	"reasonix/internal/pluginpkg"
+	"reasonix/internal/provider"
+	"reasonix/internal/sandbox"
+	"reasonix/internal/skill"
+	"reasonix/internal/store"
+	"reasonix/internal/tool"
+)
+
+func desktopMCPHTTPServer(t *testing.T) *httptest.Server {
+	t.Helper()
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		var req struct {
+			ID     *int            `json:"id"`
+			Method string          `json:"method"`
+			Params json.RawMessage `json:"params"`
+		}
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			http.Error(w, "bad body", http.StatusBadRequest)
+			return
+		}
+		if req.ID == nil {
+			w.WriteHeader(http.StatusAccepted)
+			return
+		}
+		var result any
+		switch req.Method {
+		case "initialize":
+			result = map[string]any{
+				"protocolVersion": "2024-11-05",
+				"serverInfo":      map[string]any{"name": "h", "version": "0"},
+			}
+		case "tools/list":
+			result = map[string]any{"tools": []map[string]any{{
+				"name":        "greet",
+				"description": "Greet someone.",
+				"inputSchema": map[string]any{"type": "object"},
+			}}}
+		default:
+			result = map[string]any{}
+		}
+		resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(resp)
+	}))
+}
+
+// setTestCtrl creates a minimal workspace tab (if needed) and sets its
+// controller, so tests don't depend on the old App.ctrl field.
+func (a *App) setTestCtrl(ctrl control.SessionAPI, model string) {
+	if len(a.tabs) == 0 {
+		tab := &WorkspaceTab{
+			ID:          "test",
+			Scope:       "global",
+			Ready:       true,
+			disabledMCP: map[string]ServerView{},
+		}
+		a.tabs = map[string]*WorkspaceTab{"test": tab}
+		a.activeTabID = "test"
+	}
+	tab := a.tabs["test"]
+	tab.Ctrl = ctrl
+	a.bindControllerDisplayRecorder(ctrl)
+	tab.model = model
+}
+
+func isolateDesktopUserDirs(t *testing.T) string {
+	t.Helper()
+	home := robustTempDir(t)
+	xdg := filepath.Join(home, ".config")
+	appData := filepath.Join(home, "AppData")
+	for _, dir := range []string{xdg, appData} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			t.Fatal(err)
+		}
+	}
+	t.Setenv("HOME", home)
+	t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
+	t.Setenv("USERPROFILE", home)
+	t.Setenv("XDG_CONFIG_HOME", xdg)
+	t.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
+	t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
+	t.Setenv("AppData", appData)
+	return home
+}
+
+func primarySessionFiles(paths []string) []string {
+	out := make([]string, 0, len(paths))
+	for _, path := range paths {
+		if store.IsSessionTranscriptName(filepath.Base(path)) {
+			out = append(out, path)
+		}
+	}
+	return out
+}
+
+func readConflictLogLines(t *testing.T, path string) []string {
+	t.Helper()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read conflict log: %v", err)
+	}
+	text := strings.TrimSpace(string(data))
+	if text == "" {
+		return nil
+	}
+	return strings.Split(text, "\n")
+}
+
+func setDesktopTestCredential(t *testing.T, key, value string) {
+	t.Helper()
+	if _, err := config.SetCredential(key, value); err != nil {
+		t.Fatalf("SetCredential(%s): %v", key, err)
+	}
+}
+
+func TestNeedsOnboardingIgnoresInheritedEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv(onboardingKeyEnv, "inherited-key")
+
+	app := NewApp()
+	if !app.NeedsOnboarding() {
+		t.Fatal("NeedsOnboarding should require a key saved in Reasonix global .env")
+	}
+	setDesktopTestCredential(t, onboardingKeyEnv, "saved-key")
+	if app.NeedsOnboarding() {
+		t.Fatal("NeedsOnboarding should be false after saving the global credential")
+	}
+}
+
+func TestNeedsOnboardingTreatsBlankSavedKeyAsMissing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(config.UserCredentialsPath(), []byte(onboardingKeyEnv+"=\n"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	if !app.NeedsOnboarding() {
+		t.Fatal("NeedsOnboarding should require a non-empty saved credential")
+	}
+}
+
+func providerNamesFromView(providers []ProviderView) []string {
+	out := make([]string, 0, len(providers))
+	for _, p := range providers {
+		out = append(out, p.Name)
+	}
+	return out
+}
+
+func modelRefsFromView(models []ModelInfo) map[string]bool {
+	out := map[string]bool{}
+	for _, m := range models {
+		out[m.Ref] = true
+	}
+	return out
+}
+
+type desktopFakeTool struct {
+	name string
+}
+
+func (t desktopFakeTool) Name() string { return t.name }
+
+func (desktopFakeTool) Description() string { return "fake desktop tool" }
+
+func (desktopFakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
+
+func (desktopFakeTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
+
+func (desktopFakeTool) ReadOnly() bool { return true }
+
+type desktopAskRuntimeRunner struct {
+	ask func(context.Context) error
+}
+
+func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
+	if r.ask == nil {
+		return nil
+	}
+	return r.ask(ctx)
+}
+
+func TestCommandsIncludesEffortNotThinking(t *testing.T) {
+	app := NewApp()
+	cmds := app.Commands()
+	if !hasCommand(cmds, "effort") {
+		t.Fatalf("Commands() should include effort: %+v", cmds)
+	}
+	if hasCommand(cmds, "thinking") {
+		t.Fatalf("Commands() should not include thinking: %+v", cmds)
+	}
+}
+
+func TestCommandsClassifiesSubagentSkills(t *testing.T) {
+	ctrl := control.New(control.Options{Skills: []skill.Skill{
+		{Name: "init", Description: "inline skill", RunAs: skill.RunInline},
+		{Name: "explore", Description: "isolated skill", RunAs: skill.RunSubagent, Color: "amber"},
+	}})
+	defer ctrl.Close()
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+
+	kinds := map[string]string{}
+	groups := map[string]string{}
+	colors := map[string]string{}
+	for _, cmd := range app.Commands() {
+		kinds[cmd.Name] = cmd.Kind
+		groups[cmd.Name] = cmd.Group
+		colors[cmd.Name] = cmd.Color
+	}
+	if kinds["init"] != "skill" {
+		t.Fatalf("inline skill kind = %q, want skill", kinds["init"])
+	}
+	if kinds["explore"] != "subagent" {
+		t.Fatalf("subagent skill kind = %q, want subagent", kinds["explore"])
+	}
+	if colors["explore"] != "amber" {
+		t.Fatalf("subagent skill color = %q, want amber", colors["explore"])
+	}
+	if groups["new"] != "actions" {
+		t.Fatalf("new command group = %q, want actions", groups["new"])
+	}
+	if groups["mcp"] != "integrations" || groups["plugins"] != "integrations" {
+		t.Fatalf("integration command groups = mcp:%q plugins:%q", groups["mcp"], groups["plugins"])
+	}
+	if groups["skill"] != "skills" {
+		t.Fatalf("skill command group = %q, want skills", groups["skill"])
+	}
+}
+
+func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
+	if _, err := exec.LookPath("git"); err != nil {
+		t.Skip("git not installed")
+	}
+	isolateDesktopUserDirs(t)
+	resetWorkspaceGitBranchMetaCacheForTest(t)
+
+	repo := t.TempDir()
+	configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatal(err)
+	}
+
+	orig, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := os.Chdir(orig); err != nil {
+			t.Fatal(err)
+		}
+	}()
+	if err := os.Chdir(repo); err != nil {
+		t.Fatal(err)
+	}
+	runGit(t, "init")
+	runGit(t, "checkout", "-b", "feature/meta")
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"tab-1": {
+		ID:            "tab-1",
+		Scope:         "project",
+		WorkspaceRoot: repo,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}}
+	app.activeTabID = "tab-1"
+
+	got := app.MetaForTab("tab-1")
+	if got.Cwd != repo || got.WorkspaceRoot != repo || got.WorkspacePath != repo {
+		t.Fatalf("workspace fields = cwd:%q root:%q path:%q, want %q", got.Cwd, got.WorkspaceRoot, got.WorkspacePath, repo)
+	}
+	if got.WorkspaceName != filepath.Base(repo) {
+		t.Fatalf("workspaceName = %q, want %q", got.WorkspaceName, filepath.Base(repo))
+	}
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("marshal meta: %v", err)
+	}
+	if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
+		t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
+	}
+	deadline := time.Now().Add(time.Second)
+	for {
+		if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
+			break
+		}
+		if time.Now().After(deadline) {
+			t.Fatalf("gitBranch = %q, want feature/meta after async refresh", got.GitBranch)
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func TestListTabsDoesNotExposeConfiguredSandboxPath(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	workspace := t.TempDir()
+	configuredSandboxRoot := filepath.Join(t.TempDir(), "sandbox")
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	cfg.Sandbox.WorkspaceRoot = configuredSandboxRoot
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"tab-1": {
+		ID:            "tab-1",
+		Scope:         "project",
+		WorkspaceRoot: workspace,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}}
+	app.activeTabID = "tab-1"
+	app.tabOrder = []string{"tab-1"}
+
+	raw, err := json.Marshal(app.ListTabs())
+	if err != nil {
+		t.Fatalf("marshal tabs: %v", err)
+	}
+	if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
+		t.Fatalf("tab metadata should not expose configured sandbox root as sandboxPath: %s", raw)
+	}
+}
+
+func TestListTabsExposesStructuredRuntimeStatus(t *testing.T) {
+	asks := make(chan event.Ask, 1)
+	done := make(chan event.Event, 1)
+	runner := &desktopAskRuntimeRunner{}
+	ctrl := control.New(control.Options{
+		Runner: runner,
+		Sink: event.FuncSink(func(e event.Event) {
+			switch e.Kind {
+			case event.AskRequest:
+				asks <- e.Ask
+			case event.TurnDone:
+				done <- e
+			}
+		}),
+	})
+	runner.ask = func(ctx context.Context) error {
+		_, err := ctrl.Ask(ctx, []event.AskQuestion{{
+			ID:      "choice",
+			Prompt:  "Pick one",
+			Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
+		}})
+		return err
+	}
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "prov/model")
+	app.tabOrder = []string{"test"}
+	ctrl.Send("ask user")
+	select {
+	case <-asks:
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for ask request")
+	}
+
+	tabs := app.ListTabs()
+	if len(tabs) != 1 {
+		t.Fatalf("tabs = %d, want 1", len(tabs))
+	}
+	if !tabs[0].Running || !tabs[0].PendingPrompt || !tabs[0].Cancellable || tabs[0].CancelRequested {
+		t.Fatalf("tab runtime = running:%v pending:%v cancellable:%v cancel:%v", tabs[0].Running, tabs[0].PendingPrompt, tabs[0].Cancellable, tabs[0].CancelRequested)
+	}
+
+	app.CancelTab("test")
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for turn_done")
+	}
+}
+
+func TestMetaForTabLeavesGitBranchEmptyOutsideGit(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	workspace := t.TempDir()
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"tab-1": {
+		ID:            "tab-1",
+		Scope:         "project",
+		WorkspaceRoot: workspace,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}}
+	app.activeTabID = "tab-1"
+
+	if got := app.MetaForTab("tab-1"); got.GitBranch != "" {
+		t.Fatalf("gitBranch = %q, want empty", got.GitBranch)
+	}
+}
+
+func TestEffortDefaultsBeforeStartup(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	got := NewApp().Effort()
+	if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
+		t.Fatalf("pre-startup Effort() = %+v, want auto with DeepSeek default high", got)
+	}
+}
+
+func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	view := NewApp().Memory()
+	if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {
+		t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
+	}
+	raw, err := json.Marshal(view)
+	if err != nil {
+		t.Fatalf("marshal Memory(): %v", err)
+	}
+	for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} {
+		if strings.Contains(string(raw), bad) {
+			t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
+		}
+	}
+}
+
+func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	userDir := t.TempDir()
+	cwd := t.TempDir()
+	store := memory.Store{Dir: filepath.Join(userDir, "projects", "test", "memory")}
+	if _, err := store.Save(memory.Memory{
+		Name:        "active-fact",
+		Title:       "Active fact",
+		Description: "Still applies",
+		Type:        memory.TypeProject,
+		Body:        "Active body",
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := store.Save(memory.Memory{
+		Name:        "archived-fact",
+		Description: "No longer applies",
+		Type:        memory.TypeFeedback,
+		Body:        "Archived body",
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := store.Archive("archived-fact"); err != nil {
+		t.Fatalf("Archive: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
+		Docs:    []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}},
+		Store:   store,
+		CWD:     cwd,
+		UserDir: userDir,
+	}}), "test-model")
+
+	view := app.Memory()
+	if !view.Available || view.StoreDir != store.Dir {
+		t.Fatalf("Memory() availability/store = %v/%q, want true/%q", view.Available, view.StoreDir, store.Dir)
+	}
+	if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
+		t.Fatalf("Memory() docs = %+v", view.Docs)
+	}
+	if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" {
+		t.Fatalf("Memory() active facts = %+v", view.Facts)
+	}
+	if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" ||
+		view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
+		t.Fatalf("Memory() archived facts = %+v", view.Archives)
+	}
+	if len(view.Scopes) != 3 {
+		t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
+	}
+}
+
+func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	consumeSystemQuitRequested()
+	t.Cleanup(func() { consumeSystemQuitRequested() })
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
+		t.Fatal(err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatal(err)
+	}
+
+	markSystemQuitRequested()
+	if prevent := NewApp().beforeClose(context.Background()); prevent {
+		t.Fatal("system quit should bypass background close-to-tray behavior")
+	}
+	if consumeSystemQuitRequested() {
+		t.Fatal("system quit marker should be consumed by beforeClose")
+	}
+}
+
+func TestBackgroundCloseHideStrategyByPlatform(t *testing.T) {
+	tests := []struct {
+		goos string
+		want bool
+	}{
+		{goos: "darwin", want: true},
+		{goos: "windows", want: false},
+		{goos: "linux", want: false},
+		{goos: "freebsd", want: false},
+	}
+	for _, tt := range tests {
+		if got := backgroundCloseUsesApplicationHide(tt.goos); got != tt.want {
+			t.Fatalf("backgroundCloseUsesApplicationHide(%q) = %v, want %v", tt.goos, got, tt.want)
+		}
+	}
+}
+
+func TestBackgroundCloseRequiresRestorePath(t *testing.T) {
+	tests := []struct {
+		name        string
+		goos        string
+		trayStarted bool
+		trayReady   bool
+		want        bool
+	}{
+		{name: "macOS restores from Dock", goos: "darwin", trayStarted: false, trayReady: false, want: true},
+		{name: "Windows tray ready", goos: "windows", trayStarted: true, trayReady: true, want: true},
+		{name: "Windows tray started but not ready", goos: "windows", trayStarted: true, trayReady: false, want: false},
+		{name: "Linux tray ready", goos: "linux", trayStarted: true, trayReady: true, want: true},
+		{name: "Linux tray started but not ready", goos: "linux", trayStarted: true, trayReady: false, want: false},
+		{name: "Linux no tray", goos: "linux", trayStarted: false, trayReady: false, want: false},
+		{name: "other Unix no tray", goos: "freebsd", trayStarted: false, trayReady: false, want: false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := backgroundCloseHasRestorePathFor(tt.goos, tt.trayStarted, tt.trayReady); got != tt.want {
+				t.Fatalf("backgroundCloseHasRestorePathFor(%q, %v, %v) = %v, want %v", tt.goos, tt.trayStarted, tt.trayReady, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestBackgroundCloseReadySignalRequiresCurrentReadyState(t *testing.T) {
+	app := NewApp()
+	tray := newDesktopTray()
+	app.mu.Lock()
+	app.tray = tray
+	app.mu.Unlock()
+
+	if app.waitForTrayReady(0) {
+		t.Fatal("tray should not be ready before its ready signal")
+	}
+
+	tray.markReady()
+	if app.waitForTrayReady(0) {
+		t.Fatal("closed ready signal should not count without the current ready state")
+	}
+
+	app.mu.Lock()
+	app.trayReady = true
+	app.mu.Unlock()
+	if !app.waitForTrayReady(0) {
+		t.Fatal("ready state should be accepted after the tray is marked ready")
+	}
+
+	app.mu.Lock()
+	app.trayReady = false
+	app.mu.Unlock()
+	if app.waitForTrayReady(0) {
+		t.Fatal("stale ready signal should not count after the tray exits")
+	}
+}
+
+func TestBackgroundCloseWaitsForTrayReadySignal(t *testing.T) {
+	app := NewApp()
+	tray := newDesktopTray()
+	app.mu.Lock()
+	app.tray = tray
+	app.mu.Unlock()
+
+	go func() {
+		time.Sleep(10 * time.Millisecond)
+		app.mu.Lock()
+		app.trayReady = true
+		app.mu.Unlock()
+		tray.markReady()
+	}()
+
+	if !app.waitForTrayReady(200 * time.Millisecond) {
+		t.Fatal("waitForTrayReady should observe the tray becoming ready")
+	}
+}
+
+func TestBackgroundRestoreMaximiseStrategy(t *testing.T) {
+	tests := []struct {
+		goos      string
+		maximised bool
+		want      bool
+	}{
+		{goos: "windows", maximised: true, want: true},
+		{goos: "linux", maximised: true, want: true},
+		{goos: "darwin", maximised: true, want: false},
+		{goos: "windows", maximised: false, want: false},
+	}
+	for _, tt := range tests {
+		if got := backgroundRestoreShouldMaximise(tt.goos, tt.maximised); got != tt.want {
+			t.Fatalf("backgroundRestoreShouldMaximise(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
+		}
+	}
+}
+
+func TestBackgroundRestorePlanAvoidsNormalWindowFlash(t *testing.T) {
+	tests := []struct {
+		name      string
+		goos      string
+		maximised bool
+		want      backgroundRestorePlan
+	}{
+		{
+			name:      "maximised Windows window",
+			goos:      "windows",
+			maximised: true,
+			want:      backgroundRestorePlan{maximiseBeforeShow: true},
+		},
+		{
+			name:      "normal Windows window",
+			goos:      "windows",
+			maximised: false,
+			want:      backgroundRestorePlan{unminimiseAfterShow: true},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := backgroundRestorePlanFor(tt.goos, tt.maximised)
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Fatalf("backgroundRestorePlanFor(%q, %v) = %v, want %v", tt.goos, tt.maximised, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestEmitReadyInvokesReadyHook(t *testing.T) {
+	app := NewApp()
+	var calls int32
+	app.readyHook = func() {
+		atomic.AddInt32(&calls, 1)
+	}
+
+	app.emitReady(context.TODO())
+
+	if got := atomic.LoadInt32(&calls); got != 1 {
+		t.Fatalf("ready hook calls = %d, want 1", got)
+	}
+}
+
+func TestSetEffortPersistsAndAutoClears(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	if err := app.SetEffort("max"); err != nil {
+		t.Fatalf("SetEffort(max): %v", err)
+	}
+	if got := app.Effort().Current; got != "max" {
+		t.Fatalf("Effort current = %q, want max", got)
+	}
+	if err := app.SetEffort("auto"); err != nil {
+		t.Fatalf("SetEffort(auto): %v", err)
+	}
+	if got := app.Effort().Current; got != "auto" {
+		t.Fatalf("Effort current = %q, want auto", got)
+	}
+	body, err := os.ReadFile(config.UserConfigPath())
+	if err != nil {
+		t.Fatalf("read saved config: %v", err)
+	}
+	if strings.Contains(string(body), `effort      = "max"`) {
+		t.Fatalf("auto should clear explicit max effort:\n%s", body)
+	}
+}
+
+func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[desktop]
+language = "zh"
+layout_style = "workbench"
+theme = "light"
+theme_style = "glacier"
+close_behavior = "quit"
+status_bar_style = "icon"
+status_bar_items = ["cost", "balance"]
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("set desktop language: %v", err)
+	}
+	if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
+		t.Fatalf("set desktop layout style: %v", err)
+	}
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
+		t.Fatalf("set desktop close behavior: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarStyle("text"); err != nil {
+		t.Fatalf("set desktop status bar style: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarItems([]string{"model", "balance", "cache"}); err != nil {
+		t.Fatalf("set desktop status bar items: %v", err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	got := NewApp().Settings()
+	if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
+		t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)
+	}
+	if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
+		t.Fatalf("desktop status bar items = %v, want user-level %v", got.StatusBarItems, want)
+	}
+}
+
+func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayload(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("set desktop language: %v", err)
+	}
+	if err := userCfg.SetDesktopLayoutStyle("classic"); err != nil {
+		t.Fatalf("set desktop layout style: %v", err)
+	}
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
+		t.Fatalf("set desktop status bar style: %v", err)
+	}
+	if err := userCfg.SetDesktopStatusBarItems([]string{"workspace", "git_branch", "model"}); err != nil {
+		t.Fatalf("set desktop status bar items: %v", err)
+	}
+	if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
+		t.Fatalf("set desktop check updates: %v", err)
+	}
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	got := NewApp().DesktopStartupSettings()
+	if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates {
+		t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
+	}
+	if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
+		t.Fatalf("DesktopStartupSettings status bar items = %v, want %v", got.StatusBarItems, want)
+	}
+	if !got.Bot.Enabled || !got.Bot.Allowlist.Enabled || !reflect.DeepEqual(got.Bot.Allowlist.QQUsers, []string{"alice"}) {
+		t.Fatalf("DesktopStartupSettings bot settings = %+v, want lightweight bot snapshot", got.Bot)
+	}
+
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("marshal DesktopStartupSettings: %v", err)
+	}
+	if strings.Contains(string(raw), "providers") || strings.Contains(string(raw), "officialProviders") || strings.Contains(string(raw), "providerKinds") {
+		t.Fatalf("DesktopStartupSettings must not include full Settings provider payload: %s", raw)
+	}
+}
+
+func BenchmarkDesktopSettingsPayloads(b *testing.B) {
+	home := b.TempDir()
+	xdg := filepath.Join(home, ".config")
+	appData := filepath.Join(home, "AppData")
+	for _, dir := range []string{xdg, appData} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			b.Fatal(err)
+		}
+	}
+	b.Setenv("HOME", home)
+	b.Setenv("REASONIX_CREDENTIALS_STORE", "file")
+	b.Setenv("USERPROFILE", home)
+	b.Setenv("XDG_CONFIG_HOME", xdg)
+	b.Setenv("REASONIX_STATE_HOME", filepath.Join(home, "state"))
+	b.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
+	b.Setenv("AppData", appData)
+	b.Setenv("SHARED_PROVIDER_KEY", "sk-test")
+
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	for i := 0; i < 40; i++ {
+		cfg.Providers = append(cfg.Providers, config.ProviderEntry{
+			Name:      fmt.Sprintf("custom-%02d", i),
+			Kind:      "openai",
+			BaseURL:   "https://example.invalid/v1",
+			APIKeyEnv: "SHARED_PROVIDER_KEY",
+			Models:    []string{"model-a", "model-b"},
+			Default:   "model-a",
+		})
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		b.Fatalf("save config: %v", err)
+	}
+	app := NewApp()
+
+	b.Run("Settings", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			_ = app.Settings()
+		}
+	})
+	b.Run("DesktopStartupSettings", func(b *testing.B) {
+		for i := 0; i < b.N; i++ {
+			_ = app.DesktopStartupSettings()
+		}
+	})
+}
+
+func TestSettingsIgnoresActiveWorkspaceDotEnvCredentialsWithUserConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	launch := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, ".env"), []byte("WORKSPACE_ONLY_KEY=from-project\n"), 0o600); err != nil {
+		t.Fatalf("write project env: %v", err)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.UpsertProvider(config.ProviderEntry{
+		Name:      "workspace-provider",
+		Kind:      "openai",
+		BaseURL:   "https://workspace.example/v1",
+		Model:     "workspace-model",
+		APIKeyEnv: "WORKSPACE_ONLY_KEY",
+	}); err != nil {
+		t.Fatalf("upsert provider: %v", err)
+	}
+	userCfg.Desktop.ProviderAccess = []string{"workspace-provider"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+	t.Setenv("WORKSPACE_ONLY_KEY", "")
+	os.Unsetenv("WORKSPACE_ONLY_KEY")
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(launch); err != nil {
+		t.Fatalf("chdir launch: %v", err)
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
+	app.activeTabID = "project"
+	got := app.Settings()
+	for _, p := range got.Providers {
+		if p.Name == "workspace-provider" {
+			if p.KeySet {
+				t.Fatalf("workspace provider keySet = true, want false because workspace .env is ignored: %+v", p)
+			}
+			if p.Configured {
+				t.Fatalf("workspace provider configured = true, want false because workspace .env is ignored: %+v", p)
+			}
+			return
+		}
+	}
+	t.Fatalf("workspace provider missing from settings: %+v", got.Providers)
+}
+
+func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	launch := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, ".env"), []byte("SHARED_SETTINGS_KEY=from-project\n"), 0o600); err != nil {
+		t.Fatalf("write project env: %v", err)
+	}
+	if _, err := config.SetCredential("SHARED_SETTINGS_KEY", "from-credentials"); err != nil {
+		t.Fatalf("SetCredential: %v", err)
+	}
+	userCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	if err := userCfg.UpsertProvider(config.ProviderEntry{
+		Name:      "settings-provider",
+		Kind:      "openai",
+		BaseURL:   "https://settings.example/v1",
+		Model:     "settings-model",
+		APIKeyEnv: "SHARED_SETTINGS_KEY",
+	}); err != nil {
+		t.Fatalf("upsert provider: %v", err)
+	}
+	userCfg.Desktop.ProviderAccess = []string{"settings-provider"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+	t.Setenv("SHARED_SETTINGS_KEY", "from-project")
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(launch); err != nil {
+		t.Fatalf("chdir launch: %v", err)
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}}
+	app.activeTabID = "project"
+	got := app.Settings()
+	for _, p := range got.Providers {
+		if p.Name != "settings-provider" {
+			continue
+		}
+		if !p.KeySet || !strings.Contains(p.KeySource, "Reasonix credentials") {
+			t.Fatalf("settings-provider key = set:%v source:%q, want Reasonix credentials: %+v", p.KeySet, p.KeySource, p)
+		}
+		if env := os.Getenv("SHARED_SETTINGS_KEY"); env != "from-project" {
+			t.Fatalf("Settings mutated SHARED_SETTINGS_KEY = %q, want existing project env", env)
+		}
+		return
+	}
+	t.Fatalf("settings provider missing from settings: %+v", got.Providers)
+}
+
+func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+default_model = "legacy-provider/legacy-model"
+
+[desktop]
+language = "zh"
+layout_style = "workbench"
+theme = "light"
+theme_style = "glacier"
+close_behavior = "quit"
+status_bar_style = "text"
+status_bar_items = ["model", "cache", "balance"]
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	app := NewApp()
+	got := app.Settings()
+	if got.ConfigPath != config.UserConfigPath() {
+		t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
+	}
+	if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" {
+		t.Fatalf("Settings did not seed from legacy project config: %+v", got)
+	}
+	if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
+		t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
+	}
+	if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
+		t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
+	}
+	if err := app.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("SetDesktopLanguage: %v", err)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" {
+		t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
+	}
+	if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
+		t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
+	}
+}
+
+func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-flash"
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	app := NewApp()
+	if got := app.Settings().Agent.MaxSubagentDepth; got != agent.DefaultMaxSubagentDepth {
+		t.Fatalf("default max subagent depth = %d, want %d", got, agent.DefaultMaxSubagentDepth)
+	}
+	if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
+		t.Fatalf("SetSubagentModel: %v", err)
+	}
+	if err := app.SetSubagentEffort("max"); err != nil {
+		t.Fatalf("SetSubagentEffort: %v", err)
+	}
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth(1): %v", err)
+	}
+	if err := app.SetMaxSubagentDepth(2); err != nil {
+		t.Fatalf("SetMaxSubagentDepth(2): %v", err)
+	}
+
+	got := app.Settings()
+	if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
+		t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
+	}
+	if got.Agent.MaxSubagentDepth != 2 {
+		t.Fatalf("max subagent depth = %d, want 2", got.Agent.MaxSubagentDepth)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
+		t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
+	}
+	if cfg.Agent.MaxSubagentDepth != 2 {
+		t.Fatalf("saved max_subagent_depth = %d, want 2", cfg.Agent.MaxSubagentDepth)
+	}
+}
+
+func TestSettingsSurfacesOfficialProviderTemplatesSeparately(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	got := NewApp().Settings()
+	providers := providerAccessSet(providerNamesFromView(got.Providers))
+	official := providerAccessSet(providerNamesFromView(got.OfficialProviders))
+	if providers["mimo-api"] {
+		t.Fatalf("mimo-api should not be mixed into configured providers: %+v", got.Providers)
+	}
+	if !official["deepseek"] || official["mimo-api"] || official["mimo-token-plan"] {
+		t.Fatalf("official providers = %+v, want only deepseek", got.OfficialProviders)
+	}
+}
+
+func TestSettingsRepairsLegacyOfficialProviderWithoutModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash"
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Name != "deepseek" {
+			continue
+		}
+		if !p.BuiltIn {
+			t.Fatalf("deepseek provider should be marked built-in for official endpoint: %+v", p)
+		}
+		if !p.Added || !p.KeySet || len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
+			t.Fatalf("deepseek provider = %+v, want added repaired official model list", p)
+		}
+		if got.DefaultModel != "deepseek/deepseek-v4-flash" {
+			t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", got.DefaultModel)
+		}
+		return
+	}
+	t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
+}
+
+func TestSettingsTreatsReservedProviderNameWithExternalEndpointAsCustom(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-Flash"
+
+[desktop]
+provider_access = ["deepseek"]
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://opencode.ai/zen/go/v1"
+models = ["deepseek-v4-Flash", "deepseek-v4-pro", "glm-5"]
+default = "deepseek-v4-Flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	var custom *ProviderView
+	for i := range got.Providers {
+		if got.Providers[i].Name == "deepseek" {
+			custom = &got.Providers[i]
+			break
+		}
+	}
+	if custom == nil {
+		t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
+	}
+	if custom.BuiltIn {
+		t.Fatalf("external deepseek endpoint should be custom, got built-in provider: %+v", *custom)
+	}
+	if !custom.Added || !custom.KeySet || custom.BaseURL != "https://opencode.ai/zen/go/v1" {
+		t.Fatalf("external deepseek provider = %+v, want added key-set custom opencode endpoint", *custom)
+	}
+	for _, p := range got.OfficialProviders {
+		if p.Name == "deepseek" && p.Added {
+			t.Fatalf("official DeepSeek template should not be marked added by external endpoint: %+v", p)
+		}
+	}
+}
+
+func TestSettingsInfersLegacyProviderAccessWhenMissing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash/deepseek-v4-pro"
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+
+[[providers]]
+name = "mimo-pro"
+kind = "openai"
+base_url = "https://token-plan-cn.xiaomimimo.com/v1"
+model = "mimo-v2.5-pro"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	providers := map[string]ProviderView{}
+	for _, p := range got.Providers {
+		providers[p.Name] = p
+	}
+	if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
+		t.Fatalf("deepseek provider = %+v, want inferred added key-set provider", providers["deepseek"])
+	}
+	if !providers["mimo-pro"].Added || !providers["mimo-pro"].KeySet || providers["mimo-pro"].BuiltIn {
+		t.Fatalf("mimo-pro provider = %+v, want inferred custom key-set provider", providers["mimo-pro"])
+	}
+	if got.DefaultModel != "deepseek/deepseek-v4-pro" {
+		t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", got.DefaultModel)
+	}
+}
+
+func TestSettingsDoesNotInferProviderAccessWhenExplicitlyEmpty(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash/deepseek-v4-flash"
+
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Added {
+			t.Fatalf("provider %+v should not be inferred as added when provider_access is explicit empty", p)
+		}
+	}
+}
+
+func TestSettingsInfersConfiguredBuiltInsWithoutConfigFile(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	got := NewApp().Settings()
+	providers := map[string]ProviderView{}
+	for _, p := range got.Providers {
+		providers[p.Name] = p
+	}
+	if !providers["deepseek"].Added || !providers["deepseek"].KeySet {
+		t.Fatalf("deepseek provider = %+v, want inferred added provider from configured key", providers["deepseek"])
+	}
+	if _, ok := providers["mimo-token-plan"]; ok {
+		t.Fatalf("mimo-token-plan should not be inferred from MIMO_API_KEY alone: %+v", providers["mimo-token-plan"])
+	}
+}
+
+func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	t.Setenv("MIMO_API_KEY", "")
+
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Added {
+			t.Fatalf("provider %+v should not be inferred as added without a configured key", p)
+		}
+	}
+}
+
+func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash"
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().AddOfficialProviderAccess("deepseek", "test-key"); err != nil {
+		t.Fatalf("AddOfficialProviderAccess: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	p, ok := cfg.Provider("deepseek")
+	if !ok {
+		t.Fatal("deepseek provider not saved")
+	}
+	if len(p.Models) != 2 || p.Models[0] != "deepseek-v4-flash" || p.Models[1] != "deepseek-v4-pro" || p.Default != "deepseek-v4-flash" {
+		t.Fatalf("deepseek provider after add = %+v, want official model list", p)
+	}
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
+		t.Fatalf("provider_access missing deepseek: %+v", cfg.Desktop.ProviderAccess)
+	}
+	if cfg.DefaultModel != "deepseek/deepseek-v4-flash" {
+		t.Fatalf("default_model = %q, want deepseek/deepseek-v4-flash", cfg.DefaultModel)
+	}
+}
+
+func TestSettingsSurfacesCuratedProviderPresets(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	view := NewApp().Settings()
+	if len(view.ProviderPresets) < 18 {
+		t.Fatalf("Settings().ProviderPresets length = %d, want curated custom presets", len(view.ProviderPresets))
+	}
+	got := map[string]ProviderPresetView{}
+	for _, preset := range view.ProviderPresets {
+		got[preset.ID] = preset
+	}
+	for _, curated := range config.CuratedProviderPresets() {
+		id := curated.ID
+		preset, ok := got[id]
+		if !ok {
+			t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
+		}
+		if preset.KeyEnv == "" || len(preset.ProviderNames) == 0 || len(preset.Models) == 0 {
+			t.Fatalf("preset %q view has missing fields: %+v", id, preset)
+		}
+	}
+}
+
+func providerPresetViewByID(t *testing.T, view SettingsView, id string) ProviderPresetView {
+	t.Helper()
+	for _, preset := range view.ProviderPresets {
+		if preset.ID == id {
+			return preset
+		}
+	}
+	t.Fatalf("Settings().ProviderPresets missing %q: %+v", id, view.ProviderPresets)
+	return ProviderPresetView{}
+}
+
+func TestSettingsMarksPresetAddedWhenSameNameProviderExistsWithoutAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "mimo-api"
+kind = "openai"
+base_url = "https://custom.example/v1"
+models = ["custom-model"]
+default = "custom-model"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusNameConflict || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
+		t.Fatalf("mimo-api preset view = %+v, want name-conflict because a different same-name provider exists", presetView)
+	}
+
+	var providerView *ProviderView
+	for i := range view.Providers {
+		if view.Providers[i].Name == "mimo-api" {
+			providerView = &view.Providers[i]
+			break
+		}
+	}
+	if providerView == nil {
+		t.Fatal("mimo-api provider view missing")
+	}
+	if providerView.Added {
+		t.Fatalf("mimo-api provider Added = true, want false until provider_access explicitly enables it")
+	}
+}
+
+func TestSettingsMarksLegacyEquivalentPresetAsInstalled(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("missing mimo-api preset")
+	}
+	legacy := preset.Entries[0]
+	legacy.PresetID = ""
+	legacy.PresetVersion = 0
+	cfg := config.Default()
+	if err := cfg.UpsertProvider(legacy); err != nil {
+		t.Fatalf("upsert legacy provider: %v", err)
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusInstalled || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
+		t.Fatalf("mimo-api preset view = %+v, want installed for legacy equivalent config", presetView)
+	}
+}
+
+func TestSettingsMarksPresetWithChangedCoreConfigAsModified(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("missing mimo-api preset")
+	}
+	modified := preset.Entries[0]
+	modified.BaseURL = "https://custom.example/v1"
+	cfg := config.Default()
+	if err := cfg.UpsertProvider(modified); err != nil {
+		t.Fatalf("upsert modified provider: %v", err)
+	}
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusInstalledModified || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"mimo-api"}) {
+		t.Fatalf("mimo-api preset view = %+v, want installed-modified for edited preset provider", presetView)
+	}
+}
+
+func TestSettingsMigratesLegacyStepFunPresetBaseURLs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	cfg := config.Default()
+	stepfun, ok := config.CuratedProviderPreset("stepfun")
+	if !ok || len(stepfun.Entries) != 1 {
+		t.Fatal("missing stepfun preset")
+	}
+	stepfunEntry := stepfun.Entries[0]
+	stepfunEntry.BaseURL = "https://api.stepfun.ai/step_plan/v1"
+	stepfunAnthropic, ok := config.CuratedProviderPreset("stepfun-anthropic")
+	if !ok || len(stepfunAnthropic.Entries) != 1 {
+		t.Fatal("missing stepfun-anthropic preset")
+	}
+	stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
+	stepfunAnthropicEntry.BaseURL = "https://api.stepfun.ai/step_plan"
+	cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
+	cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	for _, id := range []string{"stepfun", "stepfun-anthropic"} {
+		presetView := providerPresetViewByID(t, view, id)
+		if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
+			t.Fatalf("%s preset view = %+v, want installed after migration", id, presetView)
+		}
+	}
+
+	migrated := config.LoadForEdit(config.UserConfigPath())
+	stepfunEntryView, ok := migrated.Provider("stepfun")
+	if !ok {
+		t.Fatal("stepfun provider missing after migration")
+	}
+	if got := stepfunEntryView.BaseURL; got != "https://api.stepfun.com/step_plan/v1" {
+		t.Fatalf("stepfun base_url = %q, want official URL", got)
+	}
+	stepfunAnthropicEntryView, ok := migrated.Provider("stepfun-anthropic")
+	if !ok {
+		t.Fatal("stepfun-anthropic provider missing after migration")
+	}
+	if got := stepfunAnthropicEntryView.BaseURL; got != "https://api.stepfun.com/step_plan" {
+		t.Fatalf("stepfun-anthropic base_url = %q, want official URL", got)
+	}
+}
+
+func TestSettingsMarksSimilarProviderPresetWithoutBlockingAdd(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("missing mimo-api preset")
+	}
+	similar := preset.Entries[0]
+	similar.Name = "my-mimo"
+	similar.PresetID = ""
+	similar.PresetVersion = 0
+	cfg := config.Default()
+	if err := cfg.UpsertProvider(similar); err != nil {
+		t.Fatalf("upsert similar provider: %v", err)
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	view := NewApp().Settings()
+	presetView := providerPresetViewByID(t, view, "mimo-api")
+	if presetView.Added || presetView.Status != providerPresetStatusSimilarExisting || !reflect.DeepEqual(presetView.StatusProviderNames, []string{"my-mimo"}) {
+		t.Fatalf("mimo-api preset view = %+v, want non-blocking similar-existing status", presetView)
+	}
+}
+
+func TestAddProviderPresetAccessSavesEditableProviderAndKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+	os.Unsetenv("MIMO_API_KEY")
+
+	if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-mimo"); err != nil {
+		t.Fatalf("AddProviderPresetAccess: %v", err)
+	} else if warning != "" {
+		t.Fatalf("AddProviderPresetAccess warning = %q, want none", warning)
+	}
+
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	p, ok := cfg.Provider("mimo-api")
+	if !ok {
+		t.Fatal("mimo-api provider not saved")
+	}
+	if p.Kind != "openai" || p.BaseURL != "https://api.xiaomimimo.com/v1" || p.Default != "mimo-v2.5-pro" {
+		t.Fatalf("mimo-api provider after preset add = %+v", p)
+	}
+	if p.PresetID != "mimo-api" || p.PresetVersion != config.ProviderPresetVersion {
+		t.Fatalf("mimo-api preset metadata = %q/%d, want mimo-api/%d", p.PresetID, p.PresetVersion, config.ProviderPresetVersion)
+	}
+	if !p.NoProxy {
+		t.Fatal("mimo-api preset should save no_proxy = true")
+	}
+	if !p.HasVisionModel("mimo-v2.5") || p.HasVisionModel("mimo-v2.5-pro") {
+		t.Fatalf("mimo vision_models = %+v, want only vision-capable MiMo models", p.VisionModels)
+	}
+	if price := p.PriceForModel("mimo-v2.5-pro"); price == nil || price.Currency != "¥" {
+		t.Fatalf("mimo-v2.5-pro price = %+v, want RMB pricing", price)
+	}
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
+		t.Fatalf("provider_access missing mimo-api: %+v", cfg.Desktop.ProviderAccess)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read saved credentials: %v", err)
+	}
+	if !strings.Contains(string(data), "MIMO_API_KEY=sk-mimo") {
+		t.Fatalf("saved credentials missing MiMo key:\n%s", data)
+	}
+
+	view := NewApp().Settings()
+	var presetView *ProviderPresetView
+	var providerView *ProviderView
+	for i := range view.ProviderPresets {
+		if view.ProviderPresets[i].ID == "mimo-api" {
+			presetView = &view.ProviderPresets[i]
+		}
+	}
+	for i := range view.Providers {
+		if view.Providers[i].Name == "mimo-api" {
+			providerView = &view.Providers[i]
+		}
+	}
+	if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet {
+		t.Fatalf("mimo-api preset view = %+v, want installed/key-set", presetView)
+	}
+	if providerView == nil || providerView.BuiltIn || !providerView.Added || !providerView.KeySet {
+		t.Fatalf("mimo provider view = %+v, want editable added custom provider with key", providerView)
+	}
+}
+
+func TestAddProviderPresetAccessDoesNotOverwriteExistingProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+	os.Unsetenv("MIMO_API_KEY")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
+
+	cfg := config.Default()
+	custom := config.ProviderEntry{
+		Name:      "mimo-api",
+		Kind:      "openai",
+		BaseURL:   "https://custom.example/v1",
+		Models:    []string{"custom-model"},
+		Default:   "custom-model",
+		APIKeyEnv: "MIMO_API_KEY",
+		Headers:   map[string]string{"X-Custom": "keep-me"},
+	}
+	if err := cfg.UpsertProvider(custom); err != nil {
+		t.Fatalf("upsert custom provider: %v", err)
+	}
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	if warning, err := NewApp().AddProviderPresetAccess("mimo-api", "sk-new"); err == nil {
+		t.Fatal("AddProviderPresetAccess unexpectedly overwrote an existing provider")
+	} else if !strings.Contains(err.Error(), "provider name(s) already exist") {
+		t.Fatalf("AddProviderPresetAccess error = %v, want name-exists guard", err)
+	} else if warning != "" {
+		t.Fatalf("AddProviderPresetAccess warning = %q, want none on rejected add", warning)
+	}
+
+	cfg = config.LoadForEdit(config.UserConfigPath())
+	got, ok := cfg.Provider("mimo-api")
+	if !ok {
+		t.Fatal("mimo-api provider missing after rejected add")
+	}
+	if got.BaseURL != custom.BaseURL || got.DefaultModel() != custom.DefaultModel() || !reflect.DeepEqual(got.ModelList(), custom.ModelList()) || !reflect.DeepEqual(got.Headers, custom.Headers) {
+		t.Fatalf("mimo-api provider was overwritten: %+v, want custom %+v", got, custom)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read saved credentials: %v", err)
+	}
+	if strings.Contains(string(data), "sk-new") || !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
+		t.Fatalf("credentials changed after rejected add:\n%s", data)
+	}
+}
+
+func TestResetProviderPresetAccessOverwritesSameNameProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+	os.Unsetenv("MIMO_API_KEY")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-original")
+
+	cfg := config.Default()
+	custom := config.ProviderEntry{
+		Name:      "mimo-api",
+		Kind:      "openai",
+		BaseURL:   "https://custom.example/v1",
+		Models:    []string{"custom-model"},
+		Default:   "custom-model",
+		APIKeyEnv: "MIMO_API_KEY",
+		Headers:   map[string]string{"X-Custom": "remove-me"},
+	}
+	if err := cfg.UpsertProvider(custom); err != nil {
+		t.Fatalf("upsert custom provider: %v", err)
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	if err := NewApp().ResetProviderPresetAccess("mimo-api"); err != nil {
+		t.Fatalf("ResetProviderPresetAccess: %v", err)
+	}
+
+	cfg = config.LoadForEdit(config.UserConfigPath())
+	got, ok := cfg.Provider("mimo-api")
+	if !ok {
+		t.Fatal("mimo-api provider missing after reset")
+	}
+	if got.BaseURL != "https://api.xiaomimimo.com/v1" || got.DefaultModel() != "mimo-v2.5-pro" || got.PresetID != "mimo-api" || got.PresetVersion != config.ProviderPresetVersion {
+		t.Fatalf("mimo-api provider after reset = %+v, want preset template", got)
+	}
+	if len(got.Headers) != 0 {
+		t.Fatalf("mimo-api headers after reset = %+v, want preset headers", got.Headers)
+	}
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["mimo-api"] {
+		t.Fatalf("provider_access missing mimo-api after reset: %+v", cfg.Desktop.ProviderAccess)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read saved credentials: %v", err)
+	}
+	if !strings.Contains(string(data), "MIMO_API_KEY=sk-original") {
+		t.Fatalf("credentials changed after reset:\n%s", data)
+	}
+
+	presetView := providerPresetViewByID(t, NewApp().Settings(), "mimo-api")
+	if !presetView.Added || presetView.Status != providerPresetStatusInstalled {
+		t.Fatalf("mimo-api preset view = %+v, want installed after reset", presetView)
+	}
+}
+
+func TestResetProviderPresetAccessRejectsMissingSameNameProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	if err := NewApp().ResetProviderPresetAccess("mimo-api"); err == nil {
+		t.Fatal("ResetProviderPresetAccess unexpectedly reset a missing provider")
+	} else if !strings.Contains(err.Error(), "no same-name provider exists") {
+		t.Fatalf("ResetProviderPresetAccess error = %v, want missing same-name provider guard", err)
+	}
+}
+
+func TestAddEveryProviderPresetAccessInstallsTemplate(t *testing.T) {
+	for _, preset := range config.CuratedProviderPresets() {
+		preset := preset
+		t.Run(preset.ID, func(t *testing.T) {
+			isolateDesktopUserDirs(t)
+
+			if warning, err := NewApp().AddProviderPresetAccess(preset.ID, "sk-test"); err != nil {
+				t.Fatalf("AddProviderPresetAccess(%q): %v", preset.ID, err)
+			} else if warning != "" {
+				t.Fatalf("AddProviderPresetAccess(%q) warning = %q, want none", preset.ID, warning)
+			}
+
+			cfg := config.LoadForEdit(config.UserConfigPath())
+			access := providerAccessSet(cfg.Desktop.ProviderAccess)
+			for _, entry := range preset.Entries {
+				got, ok := cfg.Provider(entry.Name)
+				if !ok {
+					t.Fatalf("provider %q from preset %q was not saved", entry.Name, preset.ID)
+				}
+				if !access[entry.Name] {
+					t.Fatalf("provider_access for preset %q missing %q: %+v", preset.ID, entry.Name, cfg.Desktop.ProviderAccess)
+				}
+				if got.Kind != entry.Kind || got.BaseURL != entry.BaseURL || got.DefaultModel() != entry.DefaultModel() || got.APIKeyEnv != entry.APIKeyEnv || got.AuthHeader != entry.AuthHeader || got.NoProxy != entry.NoProxy {
+					t.Fatalf("provider %q core fields = %+v, want template %+v", entry.Name, got, entry)
+				}
+				if got.PresetID != preset.ID || got.PresetVersion != config.ProviderPresetVersion {
+					t.Fatalf("provider %q preset metadata = %q/%d, want %q/%d", entry.Name, got.PresetID, got.PresetVersion, preset.ID, config.ProviderPresetVersion)
+				}
+				if got.ContextWindow != entry.ContextWindow || got.Thinking != entry.Thinking || got.DefaultEffort != entry.DefaultEffort || got.ReasoningProtocol != entry.ReasoningProtocol {
+					t.Fatalf("provider %q capability fields = %+v, want template %+v", entry.Name, got, entry)
+				}
+				if !reflect.DeepEqual(got.ModelList(), entry.ModelList()) || !reflect.DeepEqual(got.VisionModels, entry.VisionModels) || !reflect.DeepEqual(got.SupportedEfforts, entry.SupportedEfforts) {
+					t.Fatalf("provider %q models/capabilities = %+v, want template %+v", entry.Name, got, entry)
+				}
+				if !reflect.DeepEqual(got.Headers, entry.Headers) || !reflect.DeepEqual(got.ExtraBody, entry.ExtraBody) {
+					t.Fatalf("provider %q request extras = %+v, want template %+v", entry.Name, got, entry)
+				}
+			}
+
+			view := NewApp().Settings()
+			var presetView *ProviderPresetView
+			for i := range view.ProviderPresets {
+				if view.ProviderPresets[i].ID == preset.ID {
+					presetView = &view.ProviderPresets[i]
+					break
+				}
+			}
+			if presetView == nil || !presetView.Added || presetView.Status != providerPresetStatusInstalled || !presetView.KeySet || !presetView.Configured {
+				t.Fatalf("preset view for %q = %+v, want installed/key-set/configured", preset.ID, presetView)
+			}
+		})
+	}
+}
+
+func TestAddOfficialProviderAccessRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+
+	app := NewApp()
+	app.readyHook = func() {}
+	app.setTestCtrl(newBackgroundJobController(t, "provider-access-job"), "deepseek-flash/deepseek-v4-flash")
+
+	_, err := app.AddOfficialProviderAccess("deepseek", "sk-test")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("AddOfficialProviderAccess with background job error = %v, want active-work guard", err)
+	}
+	if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
+		t.Fatalf("provider key should not be saved after rejected add access:\n%s", data)
+	}
+}
+
+func TestSetProviderKeyRestoresOfficialProviderAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-flash"
+
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().SetProviderKey("DEEPSEEK_API_KEY", "sk-test"); err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !providerAccessSet(cfg.Desktop.ProviderAccess)["deepseek"] {
+		t.Fatalf("provider_access = %+v, want deepseek restored", cfg.Desktop.ProviderAccess)
+	}
+	got := NewApp().Settings()
+	for _, p := range got.Providers {
+		if p.Name == "deepseek" {
+			if !p.Added || !p.KeySet {
+				t.Fatalf("deepseek settings = %+v, want added and key-set", p)
+			}
+			return
+		}
+	}
+	t.Fatalf("settings providers missing deepseek: %+v", got.Providers)
+}
+
+func TestSetProviderKeyKeepsCustomAliasProviderAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("PROXY_DEEPSEEK_KEY", "")
+	os.Unsetenv("PROXY_DEEPSEEK_KEY")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[desktop]
+provider_access = []
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://proxy.example/v1"
+model = "deepseek-v4-flash"
+api_key_env = "PROXY_DEEPSEEK_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().SetProviderKey("PROXY_DEEPSEEK_KEY", "sk-test"); err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	cfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	if !access["deepseek-flash"] {
+		t.Fatalf("provider_access = %+v, want custom alias deepseek-flash", cfg.Desktop.ProviderAccess)
+	}
+	if access["deepseek"] {
+		t.Fatalf("provider_access = %+v, should not canonicalize custom proxy to deepseek", cfg.Desktop.ProviderAccess)
+	}
+}
+
+func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_provider",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_provider", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	warning, err := app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
+	if err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") {
+		t.Fatalf("SetProviderKey warning = %q, want deferred rebuild warning", warning)
+	}
+	if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
+		t.Fatalf("SetProviderKey surfaced raw lease details: %v", warning)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed provider-key rebuild")
+	}
+	if tab.StartupErr != "" {
+		t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
+	}
+	if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
+		t.Fatalf("history after failed provider-key rebuild = %+v", got)
+	}
+	if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
+		t.Fatalf("provider_access should still persist longcat after key save")
+	}
+}
+
+func TestSetProviderKeyRebuildSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "startup-build-in-flight.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	// Model the async startup build still being in flight: no controller yet,
+	// a live build generation, and a cancellable build context.
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	const startupGeneration = 1
+	tab := &WorkspaceTab{
+		ID:              "tab_key_rebuild",
+		Scope:           "global",
+		SessionPath:     sessionPath,
+		model:           "old/old-model",
+		buildGeneration: startupGeneration,
+		buildCancel:     buildCancel,
+		disabledMCP:     map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(tab.releaseSessionLease)
+
+	if _, err := app.SetProviderKey("OLD_MODEL_KEY", "sk-new"); err != nil {
+		t.Fatalf("SetProviderKey: %v", err)
+	}
+	if tab.Ctrl == nil {
+		t.Fatal("provider-key rebuild did not install a controller")
+	}
+	defer tab.Ctrl.Close()
+
+	assertTabBuildSuperseded(t, app, tab, startupGeneration, buildCtx)
+}
+
+func TestSaveProviderWithKeyLeaseHeldPersistsCustomProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-custom-provider.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_custom_provider",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_custom_provider", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	warning, err := app.SaveProviderWithKey(ProviderView{
+		Name:      "proxy",
+		Kind:      "openai",
+		BaseURL:   "https://proxy.example/v1",
+		Models:    []string{"model-a", "model-b"},
+		Default:   "model-a",
+		APIKeyEnv: "PROXY_API_KEY",
+	}, "sk-proxy")
+	if err != nil {
+		t.Fatalf("SaveProviderWithKey: %v", err)
+	}
+	if !strings.Contains(warning, "current session could not refresh yet") || !strings.Contains(warning, "another Reasonix window") {
+		t.Fatalf("SaveProviderWithKey warning = %q, want deferred rebuild warning", warning)
+	}
+	if strings.Contains(warning, sessionPath) || strings.Contains(warning, "held by") {
+		t.Fatalf("SaveProviderWithKey surfaced raw lease details: %v", warning)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed provider rebuild")
+	}
+	gotCfg := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	got, ok := gotCfg.Provider("proxy")
+	if !ok {
+		t.Fatal("custom provider was not saved")
+	}
+	if want := []string{"model-a", "model-b"}; !reflect.DeepEqual(got.ModelList(), want) {
+		t.Fatalf("custom provider models = %v, want %v", got.ModelList(), want)
+	}
+	if !providerAccessSet(gotCfg.Desktop.ProviderAccess)["proxy"] {
+		t.Fatalf("provider_access = %+v, want proxy", gotCfg.Desktop.ProviderAccess)
+	}
+	data, err := os.ReadFile(config.UserCredentialsPath())
+	if err != nil {
+		t.Fatalf("read credentials: %v", err)
+	}
+	if !strings.Contains(string(data), "PROXY_API_KEY=sk-proxy") {
+		t.Fatalf("provider key was not saved:\n%s", data)
+	}
+}
+
+func TestConfigChangeLeaseHeldPersistsAndDefersRefresh(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-settings.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_settings",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_settings", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth should defer lease-held refresh instead of failing: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after deferred settings rebuild")
+	}
+	got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
+	if got.Agent.MaxSubagentDepth != 1 {
+		t.Fatalf("saved max_subagent_depth = %d, want 1", got.Agent.MaxSubagentDepth)
+	}
+}
+
+func TestDeferredRebuildRetryAppliesAfterLeaseRelease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	prevInterval := deferredRebuildRetryInterval
+	deferredRebuildRetryInterval = 20 * time.Millisecond
+	t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "deferred-rebuild-retry.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	released := false
+	defer func() {
+		if !released {
+			externalLease.Release()
+		}
+	}()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.enableDeferredRebuildRetry()
+	t.Cleanup(app.stopDeferredRebuildRetry)
+	tab := &WorkspaceTab{
+		ID:          "tab_deferred_retry",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_deferred_retry", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	installNoopRuntimeEvents(app, tab.sink)
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
+			c.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth: %v", err)
+	}
+	if !app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild was not scheduled while the lease is held")
+	}
+	if app.controllerForTab(tab) != oldCtrl {
+		t.Fatal("controller changed while the lease is still held")
+	}
+
+	externalLease.Release()
+	released = true
+
+	deadline := time.Now().Add(10 * time.Second)
+	for time.Now().Before(deadline) {
+		if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl {
+			break
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	if app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild is still pending after the lease was released")
+	}
+	if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
+		t.Fatalf("controller was not rebuilt after the lease release: got %p", c)
+	}
+}
+
+func TestDeferredRebuildScheduleAfterStopIsNoop(t *testing.T) {
+	app := NewApp()
+	app.stopDeferredRebuildRetry()
+	app.scheduleDeferredRebuild("tab_x", "settings")
+	if app.deferredRebuildPending("tab_x") {
+		t.Fatal("schedule after stop should not register pending work")
+	}
+}
+
+func TestDeferredRebuildWaitsForTabToBecomeActive(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	prevInterval := deferredRebuildRetryInterval
+	deferredRebuildRetryInterval = 20 * time.Millisecond
+	t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "deferred-rebuild-inactive.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	released := false
+	defer func() {
+		if !released {
+			externalLease.Release()
+		}
+	}()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	otherCtrl := control.New(control.Options{Label: "other"})
+	defer otherCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.enableDeferredRebuildRetry()
+	t.Cleanup(app.stopDeferredRebuildRetry)
+	tab := &WorkspaceTab{
+		ID:          "tab_pending",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_pending", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	installNoopRuntimeEvents(app, tab.sink)
+	other := &WorkspaceTab{
+		ID:          "tab_other",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        otherCtrl,
+		sink:        &tabEventSink{tabID: "tab_other", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab, other.ID: other}
+	app.tabOrder = []string{tab.ID, other.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
+			c.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := app.SetMaxSubagentDepth(1); err != nil {
+		t.Fatalf("SetMaxSubagentDepth: %v", err)
+	}
+	if !app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild was not scheduled while the lease is held")
+	}
+
+	// Focus another tab, then release the lease: the retry must not rebuild
+	// while the pending tab is inactive (rebuildSettingLocked acts on the
+	// active tab), and must not touch the focused tab's runtime either.
+	app.mu.Lock()
+	app.activeTabID = other.ID
+	app.mu.Unlock()
+	externalLease.Release()
+	released = true
+
+	time.Sleep(150 * time.Millisecond)
+	if !app.deferredRebuildPending(tab.ID) {
+		t.Fatal("pending entry was consumed while its tab was inactive")
+	}
+	if app.controllerForTab(tab) != oldCtrl {
+		t.Fatal("inactive pending tab was rebuilt")
+	}
+	if app.controllerForTab(other) != otherCtrl {
+		t.Fatal("focused tab was rebuilt by another tab's deferred retry")
+	}
+
+	// Switch back: the retry should now refresh the pending tab.
+	app.mu.Lock()
+	app.activeTabID = tab.ID
+	app.mu.Unlock()
+
+	deadline := time.Now().Add(10 * time.Second)
+	for time.Now().Before(deadline) {
+		if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != oldCtrl {
+			break
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	if app.deferredRebuildPending(tab.ID) {
+		t.Fatal("deferred rebuild still pending after its tab became active again")
+	}
+	if c := app.controllerForTab(tab); c == nil || c == oldCtrl {
+		t.Fatalf("controller was not rebuilt after tab reactivation: got %p", c)
+	}
+}
+
+func TestSetEffortForTabLeaseHeldKeepsOldControllerAlive(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:             "old",
+		Kind:             "openai",
+		BaseURL:          "https://example.invalid/v1",
+		Model:            "old-model",
+		APIKeyEnv:        "OLD_MODEL_KEY",
+		SupportedEfforts: []string{"low", "max"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-effort-switch.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	released := false
+	defer func() {
+		if !released {
+			externalLease.Release()
+		}
+	}()
+
+	oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_effort",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_effort", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if c := app.controllerForTab(tab); c != nil && c != oldCtrl {
+			c.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	err = app.SetEffortForTab(tab.ID, "max")
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("SetEffortForTab err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
+		t.Fatalf("SetEffortForTab surfaced raw lease details: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatal("tab controller changed after failed effort switch")
+	}
+
+	// The failed switch must leave the old runtime alive: after the other
+	// window releases the lease, retrying from the same tab has to succeed.
+	// (The old code closed the old controller before acquiring the lease, so
+	// this retry died on a snapshot of a closed session.)
+	externalLease.Release()
+	released = true
+	if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
+		t.Fatalf("SetEffortForTab retry after lease release: %v", err)
+	}
+	if tab.Ctrl == oldCtrl {
+		t.Fatal("retry did not rebuild the controller")
+	}
+}
+
+func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:             "old",
+		Kind:             "openai",
+		BaseURL:          "https://example.invalid/v1",
+		Model:            "old-model",
+		APIKeyEnv:        "OLD_MODEL_KEY",
+		SupportedEfforts: []string{"low", "max"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	recoveryPath := filepath.Join(dir, "effort-switch-conflict-recovery-deadbeef.jsonl")
+	disk := agent.NewSession("old system prompt")
+	disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := disk.Save(recoveryPath); err != nil {
+		t.Fatalf("save recovery branch: %v", err)
+	}
+	meta, ok, err := agent.LoadBranchMeta(recoveryPath)
+	if err != nil || !ok {
+		t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
+	}
+	meta.Recovered = true
+	meta.ParentID = "effort-switch-conflict"
+	meta.RecoveryReason = "snapshot conflict"
+	meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
+	if err := agent.SaveBranchMeta(recoveryPath, meta); err != nil {
+		t.Fatalf("SaveBranchMeta: %v", err)
+	}
+
+	stale := agent.NewSession("old system prompt")
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
+	tab := &WorkspaceTab{
+		ID:          "tab_depth_cap_effort",
+		Scope:       "global",
+		SessionPath: recoveryPath,
+		Ready:       true,
+		model:       "old/old-model",
+		disabledMCP: map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	oldCtrl := control.New(control.Options{
+		Executor:            oldExec,
+		SessionDir:          dir,
+		SessionPath:         recoveryPath,
+		Label:               "old",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	tab.Ctrl = oldCtrl
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+	stale.IncrementRewrite()
+
+	if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
+		t.Fatalf("SetEffortForTab: %v", err)
+	}
+	if got := tab.Ctrl.SessionPath(); got != recoveryPath {
+		t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
+	}
+	if got := tab.currentSessionPath(); got != recoveryPath {
+		t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
+		t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
+	}
+	matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
+	}
+
+	lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
+	if len(lines) != 1 {
+		t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
+	}
+	if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
+		t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
+	}
+	if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
+		t.Fatalf("conflict diagnostic leaked local path: %s", lines[0])
+	}
+
+	if err := tab.Ctrl.Snapshot(); err != nil {
+		t.Fatalf("Snapshot after effort switch recovery: %v", err)
+	}
+	afterLines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
+	if len(afterLines) != len(lines) {
+		t.Fatalf("follow-up snapshot appended conflict diagnostics: before=%v after=%v", lines, afterLines)
+	}
+	matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches after snapshot: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
+	}
+}
+
+func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[desktop]
+language = "zh"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if _, err := NewApp().AddOfficialProviderAccess("deepseek", ""); err != nil {
+		t.Fatalf("AddOfficialProviderAccess: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	p, ok := cfg.Provider("deepseek")
+	if !ok {
+		t.Fatal("deepseek provider not saved")
+	}
+	flash := p.Prices["deepseek-v4-flash"]
+	pro := p.Prices["deepseek-v4-pro"]
+	if flash == nil || flash.Output != 2 || flash.Currency != "¥" {
+		t.Fatalf("flash price = %+v, want CNY preset", flash)
+	}
+	if pro == nil || pro.Output != 6 || pro.Currency != "¥" {
+		t.Fatalf("pro price = %+v, want CNY preset", pro)
+	}
+}
+
+func TestRemoveBuiltInProviderAccessRetargetsDefaultToRemainingAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek-flash/deepseek-v4-pro"
+
+[desktop]
+provider_access = ["deepseek-flash", "mimo-pro"]
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+
+[[providers]]
+name = "mimo-pro"
+kind = "openai"
+base_url = "https://token-plan-cn.xiaomimimo.com/v1"
+model = "mimo-v2.5-pro"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	if err := NewApp().RemoveProviderAccess("deepseek"); err != nil {
+		t.Fatalf("RemoveProviderAccess: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	if access["deepseek"] || !access["mimo-pro"] {
+		t.Fatalf("provider_access = %+v, want only mimo-pro", cfg.Desktop.ProviderAccess)
+	}
+	if cfg.DefaultModel != "mimo-pro/mimo-v2.5-pro" {
+		t.Fatalf("default_model = %q, want mimo-pro/mimo-v2.5-pro", cfg.DefaultModel)
+	}
+}
+
+func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
+	deepseek, _ := cfg.Provider("deepseek-flash")
+	deepseek.Model = ""
+	deepseek.Models = []string{"deepseek-v4-flash", "deepseek-v4-pro"}
+	deepseek.Default = "deepseek-v4-flash"
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{
+		"deepseek/deepseek-v4-flash",
+		"deepseek/deepseek-v4-pro",
+		"mimo-pro/mimo-v2.5-pro",
+		"mimo-pro/mimo-v2.5",
+	} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+	for _, hidden := range []string{
+		"deepseek-pro/deepseek-v4-pro",
+		"mimo-flash/mimo-v2.5",
+	} {
+		if refs[hidden] {
+			t.Fatalf("Models() refs = %+v, should not include hidden provider %s", models, hidden)
+		}
+	}
+	if len(models) != 4 {
+		t.Fatalf("Models() len = %d, want 4: %+v", len(models), models)
+	}
+}
+
+func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "local/model-a"
+
+[desktop]
+provider_access = ["local"]
+
+[[providers]]
+name = "local"
+kind = "openai"
+base_url = "http://127.0.0.1:23333/v1"
+models = ["model-a", "model-b"]
+default = "model-a"
+api_key_env = "LOCAL_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{"local/model-a", "local/model-b"} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+	if len(models) != 2 {
+		t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
+	}
+}
+
+func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "local/model-a"
+
+[desktop]
+provider_access = ["local"]
+
+[[providers]]
+name = "local"
+kind = "openai"
+base_url = "http://127.0.0.1:23333/v1"
+models = ["model-a", "model-b"]
+default = "model-a"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{"local/model-a", "local/model-b"} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+}
+
+func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "local/model-a"
+
+[desktop]
+provider_access = ["local"]
+
+[[providers]]
+name = "local"
+kind = "openai"
+base_url = "http://127.0.0.1:23333/v1"
+models = ["model-a", "model-b"]
+default = "model-a"
+api_key_env = "LOCAL_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{"local/model-a", "local/model-b"} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+}
+
+func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	preset, ok := config.CuratedProviderPreset("mimo-api")
+	if !ok || len(preset.Entries) == 0 {
+		t.Fatal("mimo-api preset missing")
+	}
+	if err := cfg.UpsertProvider(preset.Entries[0]); err != nil {
+		t.Fatalf("upsert mimo-api preset: %v", err)
+	}
+	cfg.DefaultModel = "mimo-api/mimo-v2.5-pro"
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	models := NewApp().Models()
+	refs := modelRefsFromView(models)
+	for _, want := range []string{
+		"mimo-api/mimo-v2.5-pro",
+		"mimo-api/mimo-v2.5",
+	} {
+		if !refs[want] {
+			t.Fatalf("Models() refs = %+v, missing %s", models, want)
+		}
+	}
+	if len(models) != 2 {
+		t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
+	}
+}
+
+func TestModelsForTabKeepsUserProvidersWithProjectConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	userCfg := config.Default()
+	userCfg.DefaultModel = "mimo-pro/mimo-v2.5-pro"
+	userCfg.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	projectRoot := t.TempDir()
+	projectConfig := `default_model = "deepseek-flash/deepseek-v4-flash"
+
+[desktop]
+provider_access = ["deepseek-flash"]
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+model = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+`
+	if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.activeTabID = tab.ID
+
+	models := app.ModelsForTab(tab.ID)
+	refs := modelRefsFromView(models)
+	for _, want := range []string{
+		"deepseek/deepseek-v4-flash",
+		"mimo-pro/mimo-v2.5-pro",
+	} {
+		if !refs[want] {
+			t.Fatalf("ModelsForTab refs = %+v, missing %s", models, want)
+		}
+	}
+}
+
+func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+	setDesktopTestCredential(t, "MIMO_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek-flash/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek-flash"}
+	cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "other", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "other-model"})
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err := app.SetModelForTab(tab.ID, "other/other-model")
+	if err == nil || !strings.Contains(err.Error(), "not available") {
+		t.Fatalf("SetModelForTab hidden provider error = %v, want not available", err)
+	}
+}
+
+func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+	if err := os.MkdirAll(config.MemoryUserDir(), 0o755); err != nil {
+		t.Fatalf("mkdir memory dir: %v", err)
+	}
+	const freshRule = "Fresh global AGENTS rule for model switch"
+	if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "AGENTS.md"), []byte(freshRule), 0o644); err != nil {
+		t.Fatalf("write global AGENTS.md: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	oldSession := agent.NewSession("old system prompt without memory")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldPath := filepath.Join(dir, "old.jsonl")
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 {
+		t.Fatalf("history length = %d, want system + user", len(history))
+	}
+	if history[0].Role != provider.RoleSystem {
+		t.Fatalf("first message role = %s, want system", history[0].Role)
+	}
+	if !strings.Contains(history[0].Content, freshRule) {
+		t.Fatalf("refreshed system prompt missing global AGENTS rule:\n%s", history[0].Content)
+	}
+	if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
+		t.Fatalf("carried user message changed: %+v", history[1])
+	}
+}
+
+func TestSetModelForTabContinuesRecoveryPathAfterSnapshotConflict(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	originalPath := filepath.Join(dir, "model-switch-conflict.jsonl")
+	current := agent.NewSession("old system prompt")
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
+	if err := current.Save(originalPath); err != nil {
+		t.Fatalf("save current session: %v", err)
+	}
+
+	stale := agent.NewSession("old system prompt")
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
+	stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
+	stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
+	oldExec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.runtimeEvents.emit = func(context.Context, string, ...interface{}) {}
+	tab := &WorkspaceTab{
+		ID:          "tab_recovery_model",
+		Scope:       "global",
+		SessionPath: originalPath,
+		Ready:       true,
+		model:       "old/old-model",
+		disabledMCP: map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	oldCtrl := control.New(control.Options{
+		Executor:            oldExec,
+		SessionDir:          dir,
+		SessionPath:         originalPath,
+		Label:               "old",
+		Sink:                tab.sink,
+		SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
+		OnSessionRecovered:  app.handleTabSessionRecovered(tab),
+	})
+	tab.Ctrl = oldCtrl
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	recoveryPath := tab.Ctrl.SessionPath()
+	if recoveryPath == "" || recoveryPath == originalPath || !strings.Contains(filepath.Base(recoveryPath), "-recovery-") {
+		t.Fatalf("model switch session path = %q, want recovery path distinct from %q", recoveryPath, originalPath)
+	}
+	if got := tab.currentSessionPath(); got != recoveryPath {
+		t.Fatalf("tab current session path = %q, want recovery path %q", got, recoveryPath)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
+		t.Fatalf("tab lease path = %q, want recovery path %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
+	}
+
+	matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after model switch = %v, want only %q", matches, recoveryPath)
+	}
+	if err := tab.Ctrl.Snapshot(); err != nil {
+		t.Fatalf("Snapshot after model switch recovery: %v", err)
+	}
+	matches, err = filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
+	if err != nil {
+		t.Fatalf("glob recovery branches after snapshot: %v", err)
+	}
+	matches = primarySessionFiles(matches)
+	if len(matches) != 1 || matches[0] != recoveryPath {
+		t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
+	}
+}
+
+func TestSetModelForTabReusesCurrentSessionLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldPath := filepath.Join(dir, "leased-model-switch.jsonl")
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := tab.ensureSessionLease(oldPath); err != nil {
+		t.Fatalf("ensureSessionLease: %v", err)
+	}
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("tab controller was not rebuilt")
+	}
+	if got := tab.model; got != "new/new-model" {
+		t.Fatalf("tab model = %q, want new/new-model", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(oldPath) {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), oldPath)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
+		t.Fatalf("carried history = %+v, want original user message", history)
+	}
+}
+
+func TestSetModelForTabWaitsForConcurrentBlankSessionLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := desktopSessionDir(globalTabWorkspaceRoot())
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	path := filepath.Join(dir, "blank-model-switch-race.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write blank session: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:            "tab_blank_race",
+		Scope:         "global",
+		WorkspaceRoot: globalTabWorkspaceRoot(),
+		SessionPath:   path,
+		Ready:         true,
+		model:         "old/old-model",
+		sink:          &tabEventSink{tabID: "tab_blank_race", app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	acquired := make(chan struct{})
+	releaseHook := make(chan struct{})
+	var once sync.Once
+	sessionLeaseAcquireHookForTest = func() {
+		once.Do(func() {
+			close(acquired)
+			<-releaseHook
+		})
+	}
+	t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
+
+	buildErr := make(chan error, 1)
+	go func() {
+		buildErr <- tab.ensureSessionLease(path)
+	}()
+
+	select {
+	case <-acquired:
+	case err := <-buildErr:
+		t.Fatalf("background lease acquire returned before hook: %v", err)
+	case <-time.After(2 * time.Second):
+		t.Fatal("background lease acquire did not start")
+	}
+
+	switchErr := make(chan error, 1)
+	go func() {
+		switchErr <- app.SetModelForTab(tab.ID, "new/new-model")
+	}()
+
+	select {
+	case err := <-switchErr:
+		t.Fatalf("SetModelForTab returned before concurrent lease was bound: %v", err)
+	case <-time.After(50 * time.Millisecond):
+	}
+
+	close(releaseHook)
+	if err := <-buildErr; err != nil {
+		t.Fatalf("background ensureSessionLease: %v", err)
+	}
+	if err := <-switchErr; err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	if tab.Ctrl == nil {
+		t.Fatal("model switch did not build a controller")
+	}
+	if got := tab.model; got != "new/new-model" {
+		t.Fatalf("tab model = %q, want new/new-model", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
+	}
+}
+
+func TestSetModelForTabLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	oldPath := filepath.Join(dir, "externally-leased-model-switch.jsonl")
+	if err := os.WriteFile(oldPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(oldPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err = app.SetModelForTab(tab.ID, "new/new-model")
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("SetModelForTab err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if strings.Contains(err.Error(), oldPath) || strings.Contains(err.Error(), "held by") {
+		t.Fatalf("SetModelForTab surfaced raw lease details: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed switch")
+	}
+	if got := tab.model; got != "old/old-model" {
+		t.Fatalf("tab model = %q, want old/old-model", got)
+	}
+	info, err := os.Stat(oldPath)
+	if err != nil {
+		t.Fatalf("stat session: %v", err)
+	}
+	if info.Size() != 0 {
+		t.Fatalf("session file size = %d, want unchanged empty file", info.Size())
+	}
+}
+
+func TestSetModelForTabReattachesDetachedRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old", "new"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+		{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "new-model", APIKeyEnv: "NEW_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := desktopSessionDir(globalTabWorkspaceRoot())
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "detached-model-switch.jsonl")
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello from detached"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
+	lease, err := agent.TryAcquireSessionLease(path)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	key := sessionRuntimeKey(path)
+	detached := &WorkspaceTab{
+		ID:             detachedRuntimeTabID(key),
+		Scope:          "global",
+		SessionPath:    path,
+		Ctrl:           oldCtrl,
+		Ready:          true,
+		model:          "old/old-model",
+		sessionLease:   lease,
+		disabledMCP:    map[string]ServerView{},
+		SharedHostKey:  "detached-host",
+		ActivityStatus: "",
+	}
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		SessionPath: path,
+		Ready:       true,
+		model:       "old/old-model",
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.detachedSessions = map[string]*WorkspaceTab{key: detached}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+		if detached.sessionLease != nil {
+			detached.releaseSessionLease()
+		}
+	})
+
+	if err := app.SetModelForTab(tab.ID, "new/new-model"); err != nil {
+		t.Fatalf("SetModelForTab: %v", err)
+	}
+	if _, ok := app.detachedSessions[key]; ok {
+		t.Fatal("detached runtime was not consumed")
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("tab controller was not rebuilt from detached runtime")
+	}
+	if got := tab.model; got != "new/new-model" {
+		t.Fatalf("tab model = %q, want new/new-model", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != key {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 || history[1].Content != "hello from detached" {
+		t.Fatalf("carried history = %+v, want detached user message", history)
+	}
+}
+
+type staleWorkspaceBindingFixture struct {
+	app          *App
+	tab          *WorkspaceTab
+	oldCtrl      control.SessionAPI
+	projectA     string
+	sessionDirA  string
+	sessionPathA string
+}
+
+func newStaleWorkspaceBindingFixture(t *testing.T, suffix string) staleWorkspaceBindingFixture {
+	return newStaleWorkspaceBindingFixtureWithLayout(t, suffix, "")
+}
+
+func newStaleWorkspaceBindingFixtureWithLayout(t *testing.T, suffix, layoutStyle string) staleWorkspaceBindingFixture {
+	t.Helper()
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
+
+	// Steers and idle-steer fallbacks start real provider turns (they are no
+	// longer command-interpreted), so the fixture's provider must complete
+	// instantly instead of pointing at an unreachable host — otherwise every
+	// steer leaves the controller running for the rest of the test.
+	providerStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+		w.WriteHeader(http.StatusOK)
+		_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
+	}))
+	t.Cleanup(providerStub.Close)
+
+	cfg := config.Default()
+	cfg.DefaultModel = "test/test-model"
+	cfg.Desktop.ProviderAccess = []string{"test"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "test", Kind: "openai", BaseURL: providerStub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
+	}
+	if strings.TrimSpace(layoutStyle) != "" {
+		if err := cfg.SetDesktopLayoutStyle(layoutStyle); err != nil {
+			t.Fatalf("set desktop layout style: %v", err)
+		}
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+
+	topicID := "topic_" + suffix
+	topicTitle := "Rebuild workspace " + suffix
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
+	sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "carry me"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{
+		Executor:      oldExec,
+		SessionDir:    sessionDirB,
+		SessionPath:   sessionPathB,
+		Label:         "test/test-model",
+		ModelRef:      "test/test-model",
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+
+	app := NewApp()
+	app.readyHook = func() {}
+	tab := &WorkspaceTab{
+		ID:            "tab_stale_workspace_" + suffix,
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       topicID,
+		TopicTitle:    topicTitle,
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		model:         "test/test-model",
+		Ctrl:          oldCtrl,
+		sink:          &tabEventSink{tabID: "tab_stale_workspace_" + suffix, app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	return staleWorkspaceBindingFixture{
+		app:          app,
+		tab:          tab,
+		oldCtrl:      oldCtrl,
+		projectA:     projectA,
+		sessionDirA:  sessionDirA,
+		sessionPathA: sessionPathA,
+	}
+}
+
+func assertTabRebuiltToPinnedWorkspace(t *testing.T, f staleWorkspaceBindingFixture) {
+	t.Helper()
+	if f.tab.Ctrl == nil {
+		t.Fatal("controller was not rebuilt")
+	}
+	if f.tab.Ctrl == f.oldCtrl {
+		t.Fatal("stale controller was reused")
+	}
+	if got := normalizeProjectRoot(f.tab.WorkspaceRoot); got != normalizeProjectRoot(f.projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA))
+	}
+	if got := normalizeProjectRoot(f.tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(f.projectA) {
+		t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(f.projectA))
+	}
+	if !sameDesktopPath(f.tab.Ctrl.SessionDir(), f.sessionDirA) {
+		t.Fatalf("controller session dir = %q, want %q", f.tab.Ctrl.SessionDir(), f.sessionDirA)
+	}
+	if !sameDesktopPath(f.tab.Ctrl.SessionPath(), f.sessionPathA) {
+		t.Fatalf("controller session path = %q, want %q", f.tab.Ctrl.SessionPath(), f.sessionPathA)
+	}
+}
+
+type blockingSnapshotCtrl struct {
+	control.SessionAPI
+
+	firstSnapshotStarted  chan struct{}
+	secondSnapshotStarted chan struct{}
+	releaseSnapshot       chan struct{}
+	firstOnce             sync.Once
+	secondOnce            sync.Once
+	snapshotCount         atomic.Int32
+	closeCount            atomic.Int32
+}
+
+func newBlockingSnapshotCtrl(ctrl control.SessionAPI) *blockingSnapshotCtrl {
+	return &blockingSnapshotCtrl{
+		SessionAPI:            ctrl,
+		firstSnapshotStarted:  make(chan struct{}),
+		secondSnapshotStarted: make(chan struct{}),
+		releaseSnapshot:       make(chan struct{}),
+	}
+}
+
+func (c *blockingSnapshotCtrl) Snapshot() error {
+	count := c.snapshotCount.Add(1)
+	switch count {
+	case 1:
+		c.firstOnce.Do(func() { close(c.firstSnapshotStarted) })
+	case 2:
+		c.secondOnce.Do(func() { close(c.secondSnapshotStarted) })
+	}
+	<-c.releaseSnapshot
+	if c.SessionAPI == nil {
+		return nil
+	}
+	return c.SessionAPI.Snapshot()
+}
+
+func (c *blockingSnapshotCtrl) Close() {
+	c.closeCount.Add(1)
+	if c.SessionAPI != nil {
+		c.SessionAPI.Close()
+	}
+}
+
+func (f *staleWorkspaceBindingFixture) installBlockingSnapshotController() *blockingSnapshotCtrl {
+	ctrl := newBlockingSnapshotCtrl(f.tab.Ctrl)
+	f.tab.Ctrl = ctrl
+	f.oldCtrl = ctrl
+	return ctrl
+}
+
+func TestEnsureTabControllerWorkspaceRebuildsStaleWorkspace(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "rebuild_workspace")
+
+	if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
+		t.Fatalf("ensureTabControllerWorkspace: %v", err)
+	}
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestEnsureTabControllerWorkspaceWarnsWhenPinnedSessionSwitchesWorkspace(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "warn_workspace_switch")
+	events := make(chan event.Event, 8)
+	f.tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
+		events <- e
+	}))
+
+	if err := f.app.ensureTabControllerWorkspace(f.tab); err != nil {
+		t.Fatalf("ensureTabControllerWorkspace: %v", err)
+	}
+	assertTabRebuiltToPinnedWorkspace(t, f)
+
+	deadline := time.After(2 * time.Second)
+	for {
+		select {
+		case e := <-events:
+			if e.Kind == event.Notice &&
+				e.Level == event.LevelWarn &&
+				strings.Contains(e.Text, f.projectA) &&
+				strings.Contains(e.Text, "switched tab") {
+				return
+			}
+		case <-deadline:
+			t.Fatal("did not receive workspace switch warning notice")
+		}
+	}
+}
+
+func TestSteerForTabReconcilesStaleWorkspaceBeforeIdleFallback(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "steer_idle_fallback")
+
+	// The idle fallback submits the steer text verbatim as a provider turn
+	// (steers are never command-interpreted); the fixture's provider stub
+	// completes it instantly.
+	if err := f.app.SteerForTab(f.tab.ID, "steer guidance"); err != nil {
+		t.Fatalf("SteerForTab: %v", err)
+	}
+	waitNotRunning(t, f.tab.Ctrl)
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestCompactReconcilesStaleWorkspaceBeforeCompaction(t *testing.T) {
+	f := newStaleWorkspaceBindingFixture(t, "compact")
+
+	if err := f.app.Compact(); err != nil {
+		t.Fatalf("Compact: %v", err)
+	}
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestEffortCommandUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OWNER_MODEL_KEY", "sk-test")
+	setDesktopTestCredential(t, "STALE_MODEL_KEY", "sk-test")
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	ownerConfig := `default_model = "owner/owner-model"
+[[providers]]
+name = "owner"
+kind = "openai"
+base_url = "https://owner.example.invalid/v1"
+model = "owner-model"
+api_key_env = "OWNER_MODEL_KEY"
+supported_efforts = ["max"]
+default_effort = "max"
+`
+	if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	staleConfig := `default_model = "stale/stale-model"
+[[providers]]
+name = "stale"
+kind = "openai"
+base_url = "https://stale.example.invalid/v1"
+model = "stale-model"
+api_key_env = "STALE_MODEL_KEY"
+reasoning_protocol = "none"
+`
+	if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	topicID := "topic_effort_owner"
+	topicTitle := "Effort owner"
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", topicID, topicTitle, projectA, "project A prompt", time.Now())
+	oldCtrl := control.New(control.Options{
+		SessionDir:    sessionDirB,
+		SessionPath:   filepath.Join(sessionDirB, "wrong.jsonl"),
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+
+	app := NewApp()
+	app.readyHook = func() {}
+	tab := &WorkspaceTab{
+		ID:            "tab_stale_effort",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       topicID,
+		TopicTitle:    topicTitle,
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		Ctrl:          oldCtrl,
+		sink:          &tabEventSink{tabID: "tab_stale_effort", app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	if err := app.SubmitToTab(tab.ID, "/effort max"); err != nil {
+		t.Fatalf("SubmitToTab(/effort max): %v", err)
+	}
+	waitNotRunning(t, tab.Ctrl)
+	if tab.effort == nil || *tab.effort != "max" {
+		t.Fatalf("tab effort = %#v, want max from pinned project A provider", tab.effort)
+	}
+	if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
+	}
+	if got := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("controller workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
+	}
+}
+
+func TestClassicLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
+	runQuickClickWorkspaceReconcileTest(t, "classic")
+}
+
+func TestWorkbenchLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
+	runQuickClickWorkspaceReconcileTest(t, "workbench")
+}
+
+func TestCreationLayoutQuickClicksSerializeWorkspaceRebuild(t *testing.T) {
+	runQuickClickWorkspaceReconcileTest(t, "creation")
+}
+
+func runQuickClickWorkspaceReconcileTest(t *testing.T, layoutStyle string) {
+	t.Helper()
+	f := newStaleWorkspaceBindingFixtureWithLayout(t, "quick_click_"+layoutStyle, layoutStyle)
+	if got, want := f.app.singleSurfaceLayoutEnabled(), singleSurfaceLayoutStyle(layoutStyle); got != want {
+		t.Fatalf("singleSurfaceLayoutEnabled(%q) = %v, want %v", layoutStyle, got, want)
+	}
+	blockingCtrl := f.installBlockingSnapshotController()
+
+	type quickAction struct {
+		name string
+		run  func() error
+	}
+	actions := []quickAction{
+		{name: "submit", run: func() error { return f.app.SubmitToTab(f.tab.ID, "/unknown-command") }},
+		{name: "steer", run: func() error { return f.app.SteerForTab(f.tab.ID, "steer guidance") }},
+		{name: "compact", run: func() error { return f.app.Compact() }},
+		{name: "submit-display", run: func() error { return f.app.SubmitDisplayToTab(f.tab.ID, "/unknown display", "/unknown-command") }},
+	}
+
+	start := make(chan struct{})
+	ready := make(chan struct{}, len(actions))
+	errs := make(chan error, len(actions))
+	var wg sync.WaitGroup
+	for _, action := range actions {
+		action := action
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			ready <- struct{}{}
+			<-start
+			if err := action.run(); err != nil {
+				errs <- fmt.Errorf("%s: %w", action.name, err)
+			}
+		}()
+	}
+	for range actions {
+		<-ready
+	}
+	close(start)
+
+	select {
+	case <-blockingCtrl.firstSnapshotStarted:
+	case <-time.After(time.Second):
+		t.Fatal("timed out waiting for first stale controller snapshot")
+	}
+	select {
+	case <-blockingCtrl.secondSnapshotStarted:
+		t.Fatal("workspace rebuild was not serialized: second stale snapshot started before the first rebuild finished")
+	case <-time.After(75 * time.Millisecond):
+	}
+	close(blockingCtrl.releaseSnapshot)
+	wg.Wait()
+	close(errs)
+	for err := range errs {
+		// The steer action holds a real provider turn now, so racing quick
+		// clicks may legitimately observe a busy controller. This test
+		// asserts workspace-rebuild serialization, not that every
+		// concurrent action wins the turn.
+		if strings.Contains(err.Error(), "turn already running") ||
+			strings.Contains(err.Error(), "cannot compact while a turn is running") {
+			continue
+		}
+		t.Error(err)
+	}
+	if t.Failed() {
+		return
+	}
+	if got := blockingCtrl.snapshotCount.Load(); got != 1 {
+		t.Fatalf("stale snapshot count = %d, want 1", got)
+	}
+	if got := blockingCtrl.closeCount.Load(); got != 1 {
+		t.Fatalf("stale close count = %d, want 1", got)
+	}
+	waitNotRunning(t, f.tab.Ctrl)
+	assertTabRebuiltToPinnedWorkspace(t, f)
+}
+
+func TestListSessionsUsesPinnedSessionOwnerBeforeStaleRuntimeDir(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_project_a", "Project A topic", projectA, "project A prompt", time.Now())
+	sessionPathB := writeTopicSessionWithPrompt(t, sessionDirB, "project-b.jsonl", "topic_project_b", "Project B topic", projectB, "project B prompt", time.Now().Add(time.Minute))
+
+	app := NewApp()
+	oldCtrl := control.New(control.Options{
+		SessionDir:    sessionDirB,
+		SessionPath:   sessionPathB,
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+	tab := &WorkspaceTab{
+		ID:            "tab_stale_runtime_dir",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       "topic_project_a",
+		TopicTitle:    "Project A topic",
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		Ctrl:          oldCtrl,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(oldCtrl.Close)
+
+	sessions := app.ListSessions()
+	if len(sessions) == 0 {
+		t.Fatal("ListSessions() returned no sessions")
+	}
+	if filepath.Clean(sessions[0].Path) != filepath.Clean(sessionPathA) {
+		t.Fatalf("ListSessions()[0].Path = %q, want pinned project A session %q", sessions[0].Path, sessionPathA)
+	}
+	for _, item := range sessions {
+		if filepath.Clean(item.Path) == filepath.Clean(sessionPathB) {
+			t.Fatalf("ListSessions() included stale project B runtime session: %+v", sessions)
+		}
+	}
+	if got := normalizeProjectRoot(tab.WorkspaceRoot); got != normalizeProjectRoot(projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", got, normalizeProjectRoot(projectA))
+	}
+}
+
+func TestSetDefaultModelRejectsProviderWithoutKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("MIMO_API_KEY", "")
+
+	cfg := config.Default()
+	cfg.Desktop.ProviderAccess = []string{"mimo-api"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ready: true, model: "deepseek-flash/deepseek-v4-flash"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err := app.SetDefaultModel("mimo-api/mimo-v2.5-pro")
+	if err == nil || !strings.Contains(err.Error(), "has no key") {
+		t.Fatalf("SetDefaultModel no-key error = %v, want has no key", err)
+	}
+	if tab.model != "deepseek-flash/deepseek-v4-flash" {
+		t.Fatalf("tab model after failed default change = %q, want previous", tab.model)
+	}
+}
+
+func TestSaveProviderPersistsReasoningProtocol(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	if err := app.SaveProvider(ProviderView{
+		Name:              "deepseek-proxy",
+		Kind:              "openai",
+		BaseURL:           "https://proxy.example.com/v1",
+		Models:            []string{"deepseek-v4-flash"},
+		Default:           "deepseek-v4-flash",
+		APIKeyEnv:         "DEEPSEEK_PROXY_KEY",
+		ReasoningProtocol: "none",
+		SupportedEfforts:  []string{"high", "max"},
+		DefaultEffort:     "max",
+	}); err != nil {
+		t.Fatalf("SaveProvider: %v", err)
+	}
+
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	got, ok := cfg.Provider("deepseek-proxy")
+	if !ok {
+		t.Fatal("saved provider not found")
+	}
+	if got.ReasoningProtocol != "none" || got.DefaultEffort != "max" {
+		t.Fatalf("saved provider = %+v, want reasoning_protocol none and default_effort max", got)
+	}
+
+	view := app.Settings()
+	for _, p := range view.Providers {
+		if p.Name == "deepseek-proxy" {
+			if p.ReasoningProtocol != "none" {
+				t.Fatalf("settings reasoningProtocol = %q, want none", p.ReasoningProtocol)
+			}
+			return
+		}
+	}
+	t.Fatalf("Settings() missing saved provider: %+v", view.Providers)
+}
+
+func TestDeleteProviderMigratesConfigAndOpenTabs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-a/model-a2"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", Models: []string{"model-a1", "model-a2"}, APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	cfg.Agent.PlannerModel = "prov-a"
+	cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	ctrl := control.New(control.Options{Label: "old"})
+	defer ctrl.Close()
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "tab_a", Scope: "global", Ctrl: ctrl, Label: "prov-a/model-a1", Ready: true, model: "prov-a/model-a1"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.DeleteProvider("prov-a"); err != nil {
+		t.Fatalf("DeleteProvider: %v", err)
+	}
+
+	got := config.LoadForEdit(config.UserConfigPath())
+	if _, ok := got.Provider("prov-a"); ok {
+		t.Fatal("prov-a should be removed")
+	}
+	if got.DefaultModel != "prov-b" || got.Agent.PlannerModel != "prov-b" {
+		t.Fatalf("model refs after delete = default:%q planner:%q, want prov-b", got.DefaultModel, got.Agent.PlannerModel)
+	}
+	if providerAccessSet(got.Desktop.ProviderAccess)["prov-a"] {
+		t.Fatalf("provider access still contains prov-a: %+v", got.Desktop.ProviderAccess)
+	}
+	if tab.model != "prov-b/model-b1" || tab.Label != "prov-b/model-b1" {
+		t.Fatalf("tab model after delete = model:%q label:%q, want prov-b/model-b1", tab.model, tab.Label)
+	}
+	if tab.Ctrl != nil {
+		t.Fatal("tab controller should be closed and cleared when retargeted without a running app context")
+	}
+}
+
+// assertTabBuildSuperseded checks that the startup build registered before the
+// mutation (generation) can no longer install its controller and that its
+// build context was cancelled.
+func assertTabBuildSuperseded(t *testing.T, app *App, tab *WorkspaceTab, generation uint64, buildCtx context.Context) {
+	t.Helper()
+	app.mu.Lock()
+	superseded := app.tabBuildSupersededLocked(tab, generation)
+	app.mu.Unlock()
+	if !superseded {
+		t.Fatal("in-flight startup build was not superseded; finishing it would reinstall a stale controller")
+	}
+	select {
+	case <-buildCtx.Done():
+	default:
+		t.Fatal("in-flight startup build context was not cancelled")
+	}
+	if tab.buildCancel != nil {
+		t.Fatal("build cancel was not cleared")
+	}
+}
+
+func TestDeleteProviderSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-b/model-b1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	cfg.Desktop.ProviderAccess = []string{"prov-a", "prov-b"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	// Model the async startup build still being in flight for the affected
+	// tab: no controller yet, a live generation, a cancellable build context.
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	tab := &WorkspaceTab{
+		ID:              "tab_a",
+		Scope:           "global",
+		model:           "prov-a/model-a1",
+		buildGeneration: 1,
+		buildCancel:     buildCancel,
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.DeleteProvider("prov-a"); err != nil {
+		t.Fatalf("DeleteProvider: %v", err)
+	}
+	assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
+	if tab.model != "prov-b/model-b1" {
+		t.Fatalf("tab model after delete = %q, want prov-b/model-b1", tab.model)
+	}
+}
+
+func TestRemoveBuiltInProviderAccessSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-b/model-b1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-chat", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	cfg.Desktop.ProviderAccess = []string{"deepseek", "prov-b"}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	tab := &WorkspaceTab{
+		ID:              "tab_ds",
+		Scope:           "global",
+		model:           "deepseek/deepseek-chat",
+		buildGeneration: 1,
+		buildCancel:     buildCancel,
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	if err := app.RemoveProviderAccess("deepseek"); err != nil {
+		t.Fatalf("RemoveProviderAccess: %v", err)
+	}
+	assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
+	if tab.model != "prov-b/model-b1" {
+		t.Fatalf("tab model after access removal = %q, want prov-b/model-b1", tab.model)
+	}
+}
+
+func TestClearActiveSessionRuntimeSupersedesInFlightStartupBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "clear-runtime-in-flight.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+
+	oldSession := agent.NewSession("old system prompt")
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	// A runtime is attached while an older async build is still in flight
+	// (e.g. attached via topic activation); destroying the session must
+	// invalidate that build so it cannot resurrect the destroyed session.
+	buildCtx, buildCancel := context.WithCancel(context.Background())
+	tab := &WorkspaceTab{
+		ID:              "tab_clear",
+		Scope:           "global",
+		SessionPath:     sessionPath,
+		model:           "old/old-model",
+		Ready:           true,
+		Ctrl:            oldCtrl,
+		buildGeneration: 1,
+		buildCancel:     buildCancel,
+		disabledMCP:     map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(tab.releaseSessionLease)
+
+	if err := app.clearActiveSessionRuntime(tab, oldCtrl); err != nil {
+		t.Fatalf("clearActiveSessionRuntime: %v", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("clear did not install a fresh controller (ctrl=%v)", tab.Ctrl)
+	}
+	defer tab.Ctrl.Close()
+	assertTabBuildSuperseded(t, app, tab, 1, buildCtx)
+}
+
+func TestClearActiveSessionRuntimeReleasesResourcesWhenTabReplaced(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "old",
+		Kind:      "openai",
+		BaseURL:   "https://example.invalid/v1",
+		Model:     "old-model",
+		APIKeyEnv: "OLD_MODEL_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "clear-runtime-replaced-tab.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+
+	oldSession := agent.NewSession("old system prompt")
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	tab := &WorkspaceTab{
+		ID:          "tab_replaced",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		model:       "old/old-model",
+		Ready:       true,
+		Ctrl:        oldCtrl,
+		disabledMCP: map[string]ServerView{},
+	}
+	tab.sink = &tabEventSink{tabID: tab.ID, app: app}
+	// The tab entry now points at a replacement struct (the tab was closed and
+	// reopened while the clear ran off-lock), so the swap must not apply.
+	replacement := &WorkspaceTab{ID: tab.ID, Scope: "global"}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: replacement}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(tab.releaseSessionLease)
+
+	err := app.clearActiveSessionRuntime(tab, oldCtrl)
+	if err == nil || !strings.Contains(err.Error(), "changed while clearing") {
+		t.Fatalf("clearActiveSessionRuntime error = %v, want tab-changed error", err)
+	}
+	if replacement.Ctrl != nil {
+		t.Fatalf("replacement tab controller = %v, want untouched nil", replacement.Ctrl)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("replaced tab controller = %v, want left on the destroyed runtime", tab.Ctrl)
+	}
+	if key := tab.sessionLeaseRuntimeKey(); key != "" {
+		t.Fatalf("replaced tab still holds a session lease for %q; the fresh lease leaked", key)
+	}
+	if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
+		t.Fatalf("old session artifacts were not destroyed (stat err=%v)", err)
+	}
+}
+
+func TestDeleteProviderRejectsRunningAffectedTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-a/model-a1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Runner: runner}), "prov-a/model-a1")
+	ctrl := app.activeCtrl()
+	ctrl.Submit("work")
+	<-runner.started
+
+	err := app.DeleteProvider("prov-a")
+	if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
+		t.Fatalf("DeleteProvider while running error = %v, want finish/cancel guard", err)
+	}
+	if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
+		t.Fatal("provider should remain after rejected deletion")
+	}
+
+	close(runner.release)
+	waitNotRunning(t, ctrl)
+	ctrl.Close()
+}
+
+func TestDeleteProviderRejectsAffectedBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-a/model-a1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "provider-job.jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	defer ctrl.Close()
+	app := NewApp()
+	app.setTestCtrl(ctrl, "prov-a/model-a1")
+	jm.StartForSession(agent.BranchID(path), "bash", "provider job", func(ctx context.Context, _ io.Writer) (string, error) {
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+
+	err := app.DeleteProvider("prov-a")
+	if err == nil || !strings.Contains(err.Error(), "active work") {
+		t.Fatalf("DeleteProvider with background job error = %v, want active-work guard", err)
+	}
+	if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
+		t.Fatal("provider should remain after rejected deletion")
+	}
+}
+
+func TestDeleteProviderRejectsUnaffectedBackgroundJobsBeforeSavingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "REASONIX_TEST_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "prov-b/model-b1"
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "prov-a", Kind: "openai", BaseURL: "https://a.example.com", Model: "model-a1", APIKeyEnv: "REASONIX_TEST_KEY"},
+		{Name: "prov-b", Kind: "openai", BaseURL: "https://b.example.com", Model: "model-b1", APIKeyEnv: "REASONIX_TEST_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(newBackgroundJobController(t, "provider-unaffected-job"), "prov-b/model-b1")
+
+	err := app.DeleteProvider("prov-a")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("DeleteProvider with unaffected background job error = %v, want active-work guard", err)
+	}
+	if _, ok := config.LoadForEdit(config.UserConfigPath()).Provider("prov-a"); !ok {
+		t.Fatal("unaffected provider should remain after rejected deletion")
+	}
+}
+
+func TestRemoveBuiltInProviderAccessRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "mimo-pro/mimo-v2.5-pro"
+
+[desktop]
+provider_access = ["deepseek-flash", "mimo-pro"]
+
+[[providers]]
+name = "deepseek-flash"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+api_key_env = "DEEPSEEK_API_KEY"
+
+[[providers]]
+name = "mimo-pro"
+kind = "openai"
+base_url = "https://token-plan-cn.xiaomimimo.com/v1"
+model = "mimo-v2.5-pro"
+api_key_env = "MIMO_API_KEY"
+`), 0o644); err != nil {
+		t.Fatalf("write config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(newBackgroundJobController(t, "provider-access-unaffected-job"), "mimo-token-plan/mimo-v2.5-pro")
+
+	err := app.RemoveProviderAccess("deepseek")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("RemoveProviderAccess with background job error = %v, want active-work guard", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	access := providerAccessSet(cfg.Desktop.ProviderAccess)
+	if !access["deepseek"] && !access["deepseek-flash"] {
+		t.Fatalf("provider_access should still contain deepseek after rejected removal: %+v", cfg.Desktop.ProviderAccess)
+	}
+}
+
+func TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	os.Unsetenv("DEEPSEEK_API_KEY")
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(newBackgroundJobController(t, "connect-key-job"), "deepseek-flash/deepseek-v4-flash")
+
+	_, err := app.ConnectKey("sk-test")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("ConnectKey with background job error = %v, want active-work guard", err)
+	}
+	if data, readErr := os.ReadFile(config.UserCredentialsPath()); readErr == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
+		t.Fatalf("onboarding key should not be saved after rejected connect:\n%s", data)
+	}
+}
+
+func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv(onboardingKeyEnv, "")
+	os.Unsetenv(onboardingKeyEnv)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	oldFetch := connectKeyBalanceFetch
+	connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
+		return &billing.Balance{Available: true}, nil
+	}
+	t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
+	if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(sessionPath)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	oldSession := agent.NewSession("old system prompt")
+	oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_connect",
+		Scope:       "global",
+		SessionPath: sessionPath,
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_connect", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	warning, err := app.ConnectKey("sk-test")
+	if err != nil {
+		t.Fatalf("ConnectKey: %v", err)
+	}
+	if !strings.Contains(warning, "another Reasonix window") {
+		t.Fatalf("ConnectKey warning = %q, want user-facing lease warning", warning)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed connect-key rebuild")
+	}
+	if tab.StartupErr != "" {
+		t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
+	}
+	if !config.CredentialStored(onboardingKeyEnv) {
+		t.Fatal("onboarding key should be persisted even when hot rebuild is deferred")
+	}
+}
+
+func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	if err := userCfg.SetDesktopLanguage("en"); err != nil {
+		t.Fatalf("set desktop language: %v", err)
+	}
+	if err := userCfg.SetDesktopLayoutStyle("workbench"); err != nil {
+		t.Fatalf("set desktop layout style: %v", err)
+	}
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
+		t.Fatalf("migrate desktop preferences: %v", err)
+	}
+
+	got := config.LoadForEdit(config.UserConfigPath())
+	if got.DesktopLanguage() != "en" || got.DesktopLayoutStyle() != "workbench" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
+		t.Fatalf("desktop prefs after migration = lang:%q layout:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopLayoutStyle(), got.DesktopTheme(), got.DesktopThemeStyle())
+	}
+}
+
+func TestSetEffortRebuildsController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetEffort("max"); err != nil {
+		t.Fatalf("SetEffort(max): %v", err)
+	}
+	if c := app.activeCtrl(); c == nil {
+		t.Fatal("SetEffort should leave a rebuilt controller")
+	}
+	if c := app.activeCtrl(); c == old {
+		t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
+	}
+	if got := app.Effort().Current; got != "max" {
+		t.Fatalf("Effort current = %q, want max", got)
+	}
+}
+
+func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "deepseek",
+		Kind:      "openai",
+		BaseURL:   "https://api.deepseek.com",
+		Model:     "glm-5",
+		APIKeyEnv: "DEEPSEEK_API_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetEffort("max"); err != nil {
+		t.Fatalf("SetEffort(max): %v", err)
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if tab.model != "deepseek/deepseek-v4-flash" {
+		t.Fatalf("tab model = %q, want migrated official ref", tab.model)
+	}
+}
+
+func TestSetTokenModeRebuildsController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetTokenMode("economy"); err != nil {
+		t.Fatalf("SetTokenMode(economy): %v", err)
+	}
+	if c := app.activeCtrl(); c == nil {
+		t.Fatal("SetTokenMode should leave a rebuilt controller")
+	}
+	if c := app.activeCtrl(); c == old {
+		t.Fatal("SetTokenMode should rebuild the active controller so the provider sees the new tool profile")
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if got := currentTabTokenMode(tab); got != "economy" {
+		t.Fatalf("token mode = %q, want economy", got)
+	}
+	if got := app.Meta().TokenMode; got != "economy" {
+		t.Fatalf("Meta token mode = %q, want economy", got)
+	}
+	saved := loadTabsFile()
+	if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" {
+		t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs)
+	}
+}
+
+func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
+		t.Fatalf("SetTokenMode(delivery): %v", err)
+	}
+	if c := app.activeCtrl(); c == nil || c == old {
+		t.Fatal("delivery profile should rebuild the active controller")
+	}
+	tab := app.activeTab()
+	if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
+		t.Fatalf("token mode = %q, want delivery", got)
+	}
+	if got := app.Meta().TokenMode; got != boot.TokenModeDelivery {
+		t.Fatalf("Meta token mode = %q, want delivery", got)
+	}
+	saved := loadTabsFile()
+	if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != boot.TokenModeDelivery {
+		t.Fatalf("saved tabs = %+v, want delivery profile", saved.Tabs)
+	}
+}
+
+func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	session := agent.NewSession("old system prompt")
+	session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
+	path := filepath.Join(dir, "leased-token-mode-switch.jsonl")
+	oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+		tab.releaseSessionLease()
+	})
+
+	if err := tab.ensureSessionLease(path); err != nil {
+		t.Fatalf("ensureSessionLease: %v", err)
+	}
+	if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
+		t.Fatalf("SetTokenModeForTab: %v", err)
+	}
+	if tab.Ctrl == nil || tab.Ctrl == oldCtrl {
+		t.Fatalf("tab controller was not rebuilt")
+	}
+	if got := currentTabTokenMode(tab); got != "economy" {
+		t.Fatalf("token mode = %q, want economy", got)
+	}
+	if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
+		t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
+	}
+	history := tab.Ctrl.History()
+	if len(history) < 2 || history[1].Role != provider.RoleUser || history[1].Content != "hello" {
+		t.Fatalf("carried history = %+v, want original user message", history)
+	}
+}
+
+func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "old/old-model"
+	cfg.Desktop.ProviderAccess = []string{"old"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "externally-leased-token-mode-switch.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write placeholder session: %v", err)
+	}
+	externalLease, err := agent.TryAcquireSessionLease(path)
+	if err != nil {
+		t.Fatalf("TryAcquireSessionLease: %v", err)
+	}
+	defer externalLease.Release()
+
+	session := agent.NewSession("old system prompt")
+	session.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
+	exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
+	oldCtrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
+	defer oldCtrl.Close()
+
+	app := NewApp()
+	app.ctx = context.Background()
+	tab := &WorkspaceTab{
+		ID:          "tab_a",
+		Scope:       "global",
+		Ready:       true,
+		model:       "old/old-model",
+		Ctrl:        oldCtrl,
+		sink:        &tabEventSink{tabID: "tab_a", app: app},
+		disabledMCP: map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+
+	err = app.SetTokenModeForTab(tab.ID, "economy")
+	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
+		t.Fatalf("SetTokenModeForTab err = %v, want ErrSessionLeaseHeld", err)
+	}
+	if strings.Contains(err.Error(), path) || strings.Contains(err.Error(), "held by") {
+		t.Fatalf("SetTokenModeForTab surfaced raw lease details: %v", err)
+	}
+	if tab.Ctrl != oldCtrl {
+		t.Fatalf("tab controller changed after failed switch")
+	}
+	if got := currentTabTokenMode(tab); got != "full" {
+		t.Fatalf("token mode = %q, want full", got)
+	}
+}
+
+func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "deepseek/deepseek-v4-flash"
+	cfg.Desktop.ProviderAccess = []string{"deepseek"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:      "deepseek",
+		Kind:      "openai",
+		BaseURL:   "https://api.deepseek.com",
+		Model:     "glm-5",
+		APIKeyEnv: "DEEPSEEK_API_KEY",
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetTokenMode("economy"); err != nil {
+		t.Fatalf("SetTokenMode(economy): %v", err)
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if tab.model != "deepseek/deepseek-v4-flash" {
+		t.Fatalf("tab model = %q, want migrated official ref", tab.model)
+	}
+	if got := currentTabTokenMode(tab); got != "economy" {
+		t.Fatalf("token mode = %q, want economy", got)
+	}
+}
+
+func TestMetaForTabReportsImageInputCapability(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "custom/text-only"
+	cfg.Desktop.ProviderAccess = []string{"custom"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:         "custom",
+		Kind:         "openai",
+		BaseURL:      "https://example.invalid/v1",
+		APIKeyEnv:    "CUSTOM_KEY",
+		Models:       []string{"text-only", "vision-pro"},
+		VisionModels: []string{"vision-pro"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.setTestCtrl(control.New(control.Options{Label: "custom/text-only"}), "custom/text-only")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if got := app.Meta().ImageInputEnabled; got {
+		t.Fatal("text-only meta should disable image input")
+	}
+	if err := app.SetModel("custom/vision-pro"); err != nil {
+		t.Fatalf("SetModel(custom/vision-pro): %v", err)
+	}
+	if got := app.Meta().ImageInputEnabled; !got {
+		t.Fatal("vision model meta should enable image input")
+	}
+}
+
+func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "CUSTOM_KEY", "sk-test")
+
+	cfg := config.Default()
+	cfg.DefaultModel = "custom/vision-pro"
+	cfg.Desktop.ProviderAccess = []string{"custom"}
+	cfg.Providers = []config.ProviderEntry{{
+		Name:         "custom",
+		Kind:         "openai",
+		BaseURL:      "https://example.invalid/v1",
+		APIKeyEnv:    "CUSTOM_KEY",
+		Models:       []string{"text-only", "vision-pro"},
+		VisionModels: []string{"vision-pro"},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	app.setTestCtrl(control.New(control.Options{Label: "deleted/model"}), "deleted/model")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if got := app.Meta().ImageInputEnabled; got {
+		t.Fatal("unknown model ref should not inherit image input from the default fallback model")
+	}
+}
+
+func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("DEEPSEEK_API_KEY", "")
+	t.Setenv("MIMO_API_KEY", "")
+
+	app := NewApp()
+	app.ctx = context.Background()
+	app.readyHook = func() {}
+	old := control.New(control.Options{Label: "old-controller"})
+	app.setTestCtrl(old, "missing-token-mode-model")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	err := app.SetTokenMode("economy")
+	if err == nil {
+		t.Fatal("SetTokenMode(economy) with an unknown model should fail")
+	}
+	if c := app.activeCtrl(); c != old {
+		t.Fatalf("SetTokenMode failure replaced controller: got %p want %p", c, old)
+	}
+	tab := app.activeTab()
+	if tab == nil {
+		t.Fatal("active tab missing")
+	}
+	if got := currentTabTokenMode(tab); got != "full" {
+		t.Fatalf("token mode after failed rebuild = %q, want full", got)
+	}
+	if got := app.Meta().TokenMode; got != "full" {
+		t.Fatalf("Meta token mode after failed rebuild = %q, want full", got)
+	}
+}
+
+func TestSetEffortRejectsRunningTurn(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
+	app.activeCtrl().Submit("work")
+	<-runner.started
+
+	err := app.SetEffort("max")
+	if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
+		t.Fatalf("SetEffort while running error = %v, want finish/cancel guard", err)
+	}
+
+	close(runner.release)
+	waitNotRunning(t, app.activeCtrl())
+}
+
+func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
+	app.activeCtrl().Submit("work")
+	<-runner.started
+
+	err := app.SetTokenMode("economy")
+	if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
+		t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err)
+	}
+
+	close(runner.release)
+	waitNotRunning(t, app.activeCtrl())
+}
+
+func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "jobs.jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	defer ctrl.Close()
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+
+	release := make(chan struct{})
+	jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
+		select {
+		case <-ctx.Done():
+			return "", ctx.Err()
+		case <-release:
+			return "", nil
+		}
+	})
+	defer close(release)
+
+	err := app.SetTokenMode("economy")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
+	}
+}
+
+func TestSettingsRebuildRejectsBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "settings-job.jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	defer ctrl.Close()
+	app := NewApp()
+	app.ctx = context.Background()
+	app.setTestCtrl(ctrl, "deepseek-flash/deepseek-v4-flash")
+
+	jm.StartForSession(agent.BranchID(path), "bash", "settings job", func(ctx context.Context, _ io.Writer) (string, error) {
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+
+	err := app.SetSandbox("enforce", true, "", nil, "")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetSandbox with background job error = %v, want background-job guard", err)
+	}
+}
+
+func TestClearSessionCancelsRunningRuntimeAndKeepsTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "clear-running.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
+	app := NewApp()
+	app.projectTreeChangedHook = func() {}
+	app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
+	app.tabs["test"].TopicID = "topic_clear"
+	app.tabs["test"].TopicTitle = "Clear topic"
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	oldCtrl.Submit("work")
+	<-runner.started
+	if err := app.ClearSession(); err != nil {
+		t.Fatalf("ClearSession: %v", err)
+	}
+	waitNotRunning(t, oldCtrl)
+	tab := app.activeTab()
+	if tab == nil || tab.Ctrl == nil {
+		t.Fatalf("active tab/controller missing after clear")
+	}
+	if tab.Ctrl == oldCtrl {
+		t.Fatalf("clear should replace the active controller after cancelling old work")
+	}
+	if tab.TopicID != "topic_clear" || tab.TopicTitle != "Clear topic" {
+		t.Fatalf("clear changed topic identity: %+v", tab)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("old cleared session artifacts should be removed, stat err = %v", err)
+	}
+	if got := tab.currentSessionPath(); got == "" || got == path {
+		t.Fatalf("new session path = %q, want fresh path", got)
+	}
+}
+
+func TestClearSessionRemovesRunningJobArtifacts(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "clear-running-job.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	jm := jobs.NewManager(event.Discard)
+	oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	app := NewApp()
+	app.projectTreeChangedHook = func() {}
+	app.setTestCtrl(oldCtrl, "deepseek-flash/deepseek-v4-flash")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	started := make(chan struct{})
+	jm.StartForSession(agent.BranchID(path), "bash", "clear artifact", func(ctx context.Context, _ io.Writer) (string, error) {
+		close(started)
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+	<-started
+	jobsDir := jobs.ArtifactDir(path)
+	if _, err := os.Stat(jobsDir); err != nil {
+		t.Fatalf("job sidecar should exist before clear: %v", err)
+	}
+
+	if err := app.ClearSession(); err != nil {
+		t.Fatalf("ClearSession: %v", err)
+	}
+	if _, err := os.Stat(jobsDir); !os.IsNotExist(err) {
+		t.Fatalf("old job sidecar should be removed after clear, stat err = %v", err)
+	}
+}
+
+func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	dir := robustTempDir(t)
+	if err := os.MkdirAll(filepath.Join(dir, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "frontend", "wailsjs", "runtime", "runtime.js"), []byte("x"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "frontend", "Thumbs.db"), []byte("noise"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "frontend", ".DS_Store"), []byte("noise"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "runtime.js"), []byte("noise"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	for _, noise := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
+		if err := os.MkdirAll(filepath.Join(dir, noise), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		if err := os.WriteFile(filepath.Join(dir, noise, "runtime.js"), []byte("noise"), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "desktop", "frontend", "wailsjs"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "desktop", "frontend", "wailsjs", "runtime.js"), []byte("generated"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, "product", "bin"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "product", "bin", "runtime.js"), []byte("real"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chdir(dir); err != nil {
+		t.Fatal(err)
+	}
+
+	app := &App{}
+	listed := app.ListDir("")
+	for _, hidden := range []string{".codex", ".npm", ".pnpm-store", "bin", "dist", "stage", "tmp"} {
+		if hasDirEntry(listed, hidden) {
+			t.Fatalf("ListDir should hide local noise %q, got %+v", hidden, listed)
+		}
+	}
+	desktopFrontend := app.ListDir("desktop/frontend")
+	if hasDirEntry(desktopFrontend, "wailsjs") {
+		t.Fatalf("ListDir should hide generated Wails bindings, got %+v", desktopFrontend)
+	}
+	frontendEntries := app.ListDir("frontend")
+	for _, hidden := range []string{".DS_Store", "Thumbs.db"} {
+		if hasDirEntry(frontendEntries, hidden) {
+			t.Fatalf("ListDir should hide local noise file %q, got %+v", hidden, frontendEntries)
+		}
+	}
+
+	got := app.SearchFileRefs("runtime.js")
+	if !hasDirEntry(got, "frontend/wailsjs/runtime/runtime.js") {
+		t.Fatalf("SearchFileRefs(runtime.js) should find nested workspace file, got %+v", got)
+	}
+	if !hasDirEntry(got, "product/bin/runtime.js") {
+		t.Fatalf("SearchFileRefs should keep non-root bin directories searchable, got %+v", got)
+	}
+	if hasDirEntry(got, "node_modules/pkg/runtime.js") {
+		t.Fatalf("SearchFileRefs should skip node_modules noise, got %+v", got)
+	}
+	for _, hidden := range []string{
+		".codex/runtime.js",
+		".npm/runtime.js",
+		".pnpm-store/runtime.js",
+		"bin/runtime.js",
+		"desktop/frontend/wailsjs/runtime.js",
+		"dist/runtime.js",
+		"stage/runtime.js",
+		"tmp/runtime.js",
+	} {
+		if hasDirEntry(got, hidden) {
+			t.Fatalf("SearchFileRefs should skip local noise %q, got %+v", hidden, got)
+		}
+	}
+	if noise := app.SearchFileRefs("Thumbs"); hasDirEntry(noise, "frontend/Thumbs.db") {
+		t.Fatalf("SearchFileRefs should skip Thumbs.db noise, got %+v", noise)
+	}
+	if noise := app.SearchFileRefs(".DS"); hasDirEntry(noise, "frontend/.DS_Store") {
+		t.Fatalf("SearchFileRefs should skip .DS_Store noise even for dot-prefixed search, got %+v", noise)
+	}
+}
+
+func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := robustTempDir(t)
+	projectRoot := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
+	if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.activeTabID = tab.ID
+
+	listed := app.ListDir("")
+	if !hasDirEntry(listed, "frontend") {
+		t.Fatalf("ListDir should list active project root, got %+v", listed)
+	}
+	if hasDirEntry(listed, "launch-only.txt") {
+		t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
+	}
+
+	found := app.SearchFileRefs("runtime.js")
+	if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
+		t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
+	}
+	preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
+	if preview.Err != "" || preview.Body != "right workspace" {
+		t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
+	}
+}
+
+func TestFileRefsForTabIgnoreActiveParentWorkspace(t *testing.T) {
+	parentRoot := robustTempDir(t)
+	childRoot := filepath.Join(parentRoot, "child")
+	if err := os.MkdirAll(childRoot, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(parentRoot, "parent-only.txt"), []byte("parent"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(parentRoot, "shared.txt"), []byte("parent shared"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(childRoot, "child-only.txt"), []byte("child"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child shared"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"parent": {ID: "parent", Scope: "project", WorkspaceRoot: parentRoot},
+			"child":  {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
+		},
+		activeTabID: "parent",
+	}
+
+	listed := app.ListDirForTab("child", "")
+	if !hasDirEntry(listed, "child-only.txt") || hasDirEntry(listed, "parent-only.txt") {
+		t.Fatalf("ListDirForTab(child) = %+v, want only child workspace entries", listed)
+	}
+	found := app.SearchFileRefsForTab("child", "child-only")
+	if !hasDirEntry(found, "child-only.txt") {
+		t.Fatalf("SearchFileRefsForTab(child) = %+v, want child-only.txt", found)
+	}
+	preview := app.ReadFileForTab("child", "shared.txt")
+	if preview.Err != "" || preview.Body != "child shared" {
+		t.Fatalf("ReadFileForTab(child) = %+v, want child workspace file", preview)
+	}
+	path, ok, err := app.workspaceOrExternalPathForTab("child", "shared.txt")
+	if err != nil || !ok || path != filepath.Join(childRoot, "shared.txt") {
+		t.Fatalf("workspaceOrExternalPathForTab(child) = (%q, %v, %v)", path, ok, err)
+	}
+
+	legacy := app.ReadFile("shared.txt")
+	if legacy.Err != "" || legacy.Body != "parent shared" {
+		t.Fatalf("ReadFile legacy active-tab behavior = %+v, want parent workspace file", legacy)
+	}
+}
+
+func TestFileRefsIncludeRegisteredExternalFolderChildren(t *testing.T) {
+	workspace := robustTempDir(t)
+	external := filepath.Join(robustTempDir(t), "Folder With Spaces")
+	if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	expectedExternal := external
+	if resolved, err := filepath.EvalSymlinks(external); err == nil {
+		expectedExternal = resolved
+	}
+	expectedDisplayPath := filepath.ToSlash(expectedExternal)
+
+	ctrl := &control.Controller{}
+	token, _, err := ctrl.RegisterExternalFolderRef(external)
+	if err != nil {
+		t.Fatalf("RegisterExternalFolderRef: %v", err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
+			"other":   {ID: "other", WorkspaceRoot: robustTempDir(t)},
+		},
+		activeTabID: "other",
+	}
+
+	listed := app.ListDirForTab("project", token+"/src/")
+	if len(listed) != 1 ||
+		listed[0].Name != "outside.txt" ||
+		listed[0].Path != token+"/src/outside.txt" ||
+		listed[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
+		t.Fatalf("ListDir external src = %+v, want outside token/display path", listed)
+	}
+
+	found := app.SearchFileRefsForTab("project", "outside")
+	var externalHit *DirEntry
+	for i := range found {
+		if found[i].Path == token+"/src/outside.txt" {
+			externalHit = &found[i]
+			break
+		}
+	}
+	if externalHit == nil || externalHit.DisplayName != "Folder With Spaces/src/outside.txt" || externalHit.DisplayPath != expectedDisplayPath+"/src/outside.txt" {
+		t.Fatalf("SearchFileRefs external hit = %+v, all results %+v", externalHit, found)
+	}
+
+	preview := app.ReadFileForTab("project", token+"/src/outside.txt")
+	if preview.Err != "" || preview.Body != "outside" {
+		t.Fatalf("ReadFile external token preview = %+v, want outside file body", preview)
+	}
+}
+
+func TestDeleteSessionCancelsActiveRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "active.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+
+	app := NewApp()
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
+	keepPath := filepath.Join(dir, "keep.jsonl")
+	if err := os.WriteFile(keepPath, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write keep session: %v", err)
+	}
+	keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
+	defer keepCtrl.Close()
+	app.setTestCtrl(activeCtrl, "")
+	app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
+	app.tabOrder = []string{"test", "keep"}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession(active basename): %v", err)
+	}
+	if _, ok := app.tabs["test"]; ok {
+		t.Fatalf("deleted active session runtime should be removed")
+	}
+	if got := app.activeTabID; got != "keep" {
+		t.Fatalf("active tab after delete = %q, want keep", got)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("active session should be moved out of active history, stat err = %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "active.jsonl", "active.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("active session should be moved to trash: %v", err)
+	}
+}
+
+func TestDeleteSessionCancelsPreReadyBlankBuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	globalRoot := globalTabWorkspaceRoot()
+	dir := desktopSessionDir(globalRoot)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "pre-ready-blank.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write blank session: %v", err)
+	}
+	cancelled := false
+	blank := &WorkspaceTab{
+		ID:            "blank",
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		SessionPath:   path,
+		buildCancel:   func() { cancelled = true },
+		disabledMCP:   map[string]ServerView{},
+	}
+	keep := &WorkspaceTab{
+		ID:            "keep",
+		Scope:         "global",
+		WorkspaceRoot: globalRoot,
+		Ready:         true,
+		disabledMCP:   map[string]ServerView{},
+	}
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"blank": blank, "keep": keep},
+		tabOrder:    []string{"blank", "keep"},
+		activeTabID: "blank",
+	}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession(pre-ready blank): %v", err)
+	}
+	if !cancelled {
+		t.Fatal("pre-ready blank build was not cancelled")
+	}
+	if !blank.removed {
+		t.Fatal("pre-ready blank tab was not marked removed")
+	}
+	if _, ok := app.tabs["blank"]; ok {
+		t.Fatal("pre-ready blank tab should be removed")
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("blank session should be moved out of active history, stat err = %v", err)
+	}
+}
+
+func TestDeleteLastTopicSessionFallbackDoesNotReuseDeletedTopic(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_delete_last"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Delete last"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := writeTopicSession(t, dir, "delete-last.jsonl", topicID, "Delete last", projectRoot)
+	ctrl := controllerWithContent(t, path)
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"only": {
+				ID:            "only",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       topicID,
+				TopicTitle:    "Delete last",
+				Ctrl:          ctrl,
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+		},
+		tabOrder:    []string{"only"},
+		activeTabID: "only",
+	}
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession(last topic session): %v", err)
+	}
+
+	if _, ok := app.tabs["only"]; ok {
+		t.Fatalf("deleted topic session tab should be removed")
+	}
+	for id, tab := range app.tabs {
+		if tab.TopicID == topicID {
+			t.Fatalf("fallback tab %q reused deleted topic %q", id, topicID)
+		}
+		if strings.TrimSpace(tab.TopicID) != "" {
+			t.Fatalf("fallback tab %q topic ID = %q, want transient unindexed blank", id, tab.TopicID)
+		}
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "delete-last.jsonl", "delete-last.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("deleted session should be moved to trash: %v", err)
+	}
+}
+
+func TestDeleteSessionFallbackKeepsTopicWithRemainingHistory(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_delete_keep_history"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := writeTopicSession(t, dir, "delete-one.jsonl", topicID, "Keep history", projectRoot)
+	remainingPath := writeTopicSessionWithPrompt(t, dir, "remaining.jsonl", topicID, "Keep history", projectRoot, "remaining turn", time.Now().Add(-time.Minute))
+	ctrl := controllerWithContent(t, path)
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"only": {
+				ID:            "only",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       topicID,
+				TopicTitle:    "Keep history",
+				Ctrl:          ctrl,
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+		},
+		tabOrder:    []string{"only"},
+		activeTabID: "only",
+	}
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession(topic with remaining history): %v", err)
+	}
+
+	found := false
+	for _, tab := range app.tabs {
+		if tab.TopicID == topicID {
+			found = true
+			if got := filepath.Clean(tab.currentSessionPath()); got != filepath.Clean(remainingPath) {
+				t.Fatalf("fallback session path = %q, want remaining history %q", got, remainingPath)
+			}
+		}
+	}
+	if !found {
+		t.Fatalf("fallback should keep topic %q when another session remains", topicID)
+	}
+}
+
+func TestDeleteSessionWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "stuck-delete.jsonl")
+	keepPath := filepath.Join(dir, "keep.jsonl")
+	for _, p := range []string{path, keepPath} {
+		if err := os.WriteFile(p, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write session %s: %v", p, err)
+		}
+	}
+
+	grace := 500 * time.Millisecond
+	slack := 300 * time.Millisecond
+	jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	keepCtrl := control.New(control.Options{SessionDir: dir, SessionPath: keepPath, Label: "keep"})
+	releaseJob := startNonCooperativeSessionJob(t, jm, path)
+	defer func() {
+		releaseJob()
+		ctrl.Close()
+		keepCtrl.Close()
+	}()
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	app.tabs["keep"] = &WorkspaceTab{ID: "keep", Scope: "global", Ctrl: keepCtrl, Ready: true}
+	app.tabOrder = []string{"test", "keep"}
+
+	start := time.Now()
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession(stuck job): %v", err)
+	}
+	elapsed := time.Since(start)
+	if elapsed > grace+slack {
+		t.Fatalf("DeleteSession took %s, want one teardown grace plus scheduling slack", elapsed)
+	}
+	if !agent.IsCleanupPending(path) {
+		t.Fatalf("stuck delete should mark cleanup pending")
+	}
+	if _, err := os.Stat(path); err != nil {
+		t.Fatalf("stuck session file should remain until delayed cleanup: %v", err)
+	}
+}
+
+func TestDeleteSessionTrashConflictKeepsRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "active-conflict.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	if err := os.MkdirAll(filepath.Join(dir, sessionTrashDir, filepath.Base(path)), 0o755); err != nil {
+		t.Fatalf("create trash conflict: %v", err)
+	}
+
+	runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
+	ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "test"})
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	defer ctrl.Close()
+	ctrl.Submit("work")
+	<-runner.started
+
+	err := app.DeleteSession(filepath.Base(path))
+	if err != nil {
+		t.Fatalf("DeleteSession should succeed after cleaning empty trash dir: %v", err)
+	}
+	if _, ok := app.tabs["test"]; ok {
+		t.Fatalf("deleted session runtime should be removed from tabs")
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("session file should be moved out of active history, stat err = %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("session should be moved to trash: %v", err)
+	}
+
+	close(runner.release)
+	waitNotRunning(t, ctrl)
+}
+
+func TestDeleteSessionValidTrashRemovesEmptyLiveStub(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "stale-live.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write live stub: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
+		t.Fatalf("create trash dir: %v", err)
+	}
+	if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write trash session: %v", err)
+	}
+
+	activePath := filepath.Join(dir, "active.jsonl")
+	if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write active session: %v", err)
+	}
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
+	defer activeCtrl.Close()
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
+		activeTabID: "active",
+		tabOrder:    []string{"active"},
+	}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession should remove stale live stub: %v", err)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("live stub should be removed, stat err = %v", err)
+	}
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("existing trash should remain authoritative: %v", err)
+	}
+}
+
+func TestDeleteSessionValidTrashRemovesDuplicateLiveSession(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "duplicate-recovery.jsonl")
+	content := []byte(`{"role":"user","content":"same recovery"}` + "\n")
+	if err := os.WriteFile(path, content, 0o644); err != nil {
+		t.Fatalf("write live session: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
+		t.Fatalf("create trash dir: %v", err)
+	}
+	if err := os.WriteFile(trashPath, content, 0o644); err != nil {
+		t.Fatalf("write trash session: %v", err)
+	}
+
+	activePath := filepath.Join(dir, "active.jsonl")
+	if err := os.WriteFile(activePath, []byte(`{"role":"user","content":"active"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write active session: %v", err)
+	}
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
+	defer activeCtrl.Close()
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true}},
+		activeTabID: "active",
+		tabOrder:    []string{"active"},
+	}
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession should remove duplicate live session: %v", err)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("duplicate live session should be removed, stat err = %v", err)
+	}
+	if got, err := os.ReadFile(trashPath); err != nil || string(got) != string(content) {
+		t.Fatalf("existing trash should remain authoritative, got %q err=%v", string(got), err)
+	}
+}
+
+func TestRestoreSessionRejectsOpenEmptyLiveStub(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "restore-open.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write trash source: %v", err)
+	}
+	if err := deleteSessionFile(dir, path); err != nil {
+		t.Fatalf("trash source: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatalf("write live stub: %v", err)
+	}
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open"})
+	defer ctrl.Close()
+	app := &App{
+		tabs:        map[string]*WorkspaceTab{"open": {ID: "open", Scope: "global", Ctrl: ctrl, Ready: true}},
+		tabOrder:    []string{"open"},
+		activeTabID: "open",
+	}
+
+	err := app.RestoreSession(trashPath)
+	if err == nil || !strings.Contains(err.Error(), "session is open") {
+		t.Fatalf("RestoreSession error = %v, want open-session rejection", err)
+	}
+	if info, statErr := os.Stat(path); statErr != nil || info.Size() != 0 {
+		t.Fatalf("open live stub should remain empty, info=%v err=%v", info, statErr)
+	}
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("trash session should remain after rejected restore: %v", err)
+	}
+}
+
+func TestDeleteSessionValidTrashRenamesDifferentLiveConflict(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "real-live.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"new work"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write live session: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(path), filepath.Base(path))
+	if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
+		t.Fatalf("create trash dir: %v", err)
+	}
+	if err := os.WriteFile(trashPath, []byte(`{"role":"user","content":"trashed"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write trash session: %v", err)
+	}
+
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "active"})
+	defer activeCtrl.Close()
+	app := NewApp()
+	app.setTestCtrl(activeCtrl, "")
+
+	if err := app.DeleteSession(filepath.Base(path)); err != nil {
+		t.Fatalf("DeleteSession should move different live session to a unique trash item: %v", err)
+	}
+	if _, err := os.Stat(path); !os.IsNotExist(err) {
+		t.Fatalf("live session should be moved out of active history, stat err = %v", err)
+	}
+	if got, err := os.ReadFile(trashPath); err != nil || !strings.Contains(string(got), "trashed") {
+		t.Fatalf("original trash session should remain, got %q err=%v", string(got), err)
+	}
+	trashed, err := listTrashedSessionFiles(dir)
+	if err != nil {
+		t.Fatalf("list trash: %v", err)
+	}
+	var renamedPath string
+	for _, candidate := range trashed {
+		if candidate != trashPath && filepath.Base(candidate) == filepath.Base(path) {
+			renamedPath = candidate
+			break
+		}
+	}
+	if renamedPath == "" {
+		t.Fatalf("renamed trash copy not found in %#v", trashed)
+	}
+	if filepath.Base(filepath.Dir(renamedPath)) == filepath.Base(path) {
+		t.Fatalf("renamed trash copy reused fixed trash item dir: %s", renamedPath)
+	}
+	if got, err := os.ReadFile(renamedPath); err != nil || !strings.Contains(string(got), "new work") {
+		t.Fatalf("renamed trash session = %q err=%v, want live content", string(got), err)
+	}
+}
+
+func TestDeleteSessionCancelsInactiveOpenRuntime(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	activePath := filepath.Join(dir, "active.jsonl")
+	inactivePath := filepath.Join(dir, "inactive.jsonl")
+	otherPath := filepath.Join(dir, "other.jsonl")
+	for _, path := range []string{activePath, inactivePath, otherPath} {
+		if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write session %s: %v", path, err)
+		}
+	}
+
+	activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
+	inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
+	defer activeCtrl.Close()
+	defer inactiveCtrl.Close()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"active":   {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
+			"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
+		},
+		tabOrder:    []string{"active", "inactive"},
+		activeTabID: "active",
+	}
+
+	if err := app.DeleteSession(filepath.Base(inactivePath)); err != nil {
+		t.Fatalf("DeleteSession(inactive open basename): %v", err)
+	}
+	if _, ok := app.tabs["inactive"]; ok {
+		t.Fatalf("deleted inactive session runtime should be removed")
+	}
+	if _, err := os.Stat(inactivePath); !os.IsNotExist(err) {
+		t.Fatalf("inactive open session should be moved out of active history, stat err = %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, "inactive.jsonl", "inactive.jsonl")
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("inactive open session should be moved to trash: %v", err)
+	}
+
+	sessions := app.ListSessions()
+	current := map[string]bool{}
+	open := map[string]bool{}
+	for _, s := range sessions {
+		current[filepath.Base(s.Path)] = s.Current
+		open[filepath.Base(s.Path)] = s.Open
+	}
+	if !current[filepath.Base(activePath)] {
+		t.Fatalf("ListSessions should mark active session current, got %#v", current)
+	}
+	if current[filepath.Base(otherPath)] {
+		t.Fatalf("ListSessions marked unopened session current, got %#v", current)
+	}
+	if !open[filepath.Base(activePath)] {
+		t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
+	}
+	if open[filepath.Base(inactivePath)] || open[filepath.Base(otherPath)] {
+		t.Fatalf("ListSessions marked unopened session open, got %#v", open)
+	}
+}
+
+func TestTrashTopicWithStuckJobReturnsAfterSingleGrace(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	projectRoot := t.TempDir()
+	topicID := "topic_stuck_trash"
+	if err := addProject(projectRoot, ""); err != nil {
+		t.Fatalf("add project: %v", err)
+	}
+	if err := setTopicTitle(projectRoot, topicID, "Stuck trash"); err != nil {
+		t.Fatalf("set topic title: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir sessions: %v", err)
+	}
+	sessionPath := writeTopicSession(t, dir, "stuck-topic.jsonl", topicID, "Stuck trash", projectRoot)
+
+	grace := 500 * time.Millisecond
+	slack := 300 * time.Millisecond
+	jm := jobs.NewManager(event.Discard, jobs.WithTeardownGrace(grace))
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", Jobs: jm, WorkspaceRoot: projectRoot})
+	releaseJob := startNonCooperativeSessionJob(t, jm, sessionPath)
+	defer func() {
+		releaseJob()
+		ctrl.Close()
+	}()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"stuck": {
+				ID:            "stuck",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       topicID,
+				TopicTitle:    "Stuck trash",
+				Ctrl:          ctrl,
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+			"keep": {
+				ID:            "keep",
+				Scope:         "project",
+				WorkspaceRoot: projectRoot,
+				TopicID:       "topic_keep",
+				TopicTitle:    "Keep",
+				Ready:         true,
+				disabledMCP:   map[string]ServerView{},
+			},
+		},
+		tabOrder:    []string{"stuck", "keep"},
+		activeTabID: "stuck",
+	}
+
+	start := time.Now()
+	if err := app.TrashTopic(topicID); err != nil {
+		t.Fatalf("TrashTopic(stuck job): %v", err)
+	}
+	elapsed := time.Since(start)
+	if elapsed > grace+slack {
+		t.Fatalf("TrashTopic took %s, want one teardown grace plus scheduling slack", elapsed)
+	}
+	if !agent.IsCleanupPending(sessionPath) {
+		t.Fatalf("stuck topic trash should mark cleanup pending")
+	}
+	if _, err := os.Stat(sessionPath); err != nil {
+		t.Fatalf("stuck topic session should remain until delayed trash: %v", err)
+	}
+}
+
+func TestRestoreSessionRejectsDestroyingSession(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	sessionPath := filepath.Join(dir, "trash-me.jsonl")
+	if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	if err := deleteSessionFile(dir, sessionPath); err != nil {
+		t.Fatalf("deleteSessionFile: %v", err)
+	}
+	trashPath := filepath.Join(dir, sessionTrashDir, filepath.Base(sessionPath), filepath.Base(sessionPath))
+
+	jm := jobs.NewManager(event.Discard)
+	defer jm.Close()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "active", Jobs: jm})
+	defer ctrl.Close()
+	destroy := ctrl.BeginDestroySession(sessionPath)
+	defer destroy.Finish()
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	if err := app.RestoreSession(trashPath); err == nil || !strings.Contains(err.Error(), "cleanup is still in progress") {
+		t.Fatalf("RestoreSession while destroying error = %v, want cleanup-in-progress", err)
+	}
+	if _, err := os.Stat(trashPath); err != nil {
+		t.Fatalf("trashed session should remain after rejected restore: %v", err)
+	}
+
+	destroy.Finish()
+	if err := app.RestoreSession(trashPath); err != nil {
+		t.Fatalf("RestoreSession after finish: %v", err)
+	}
+	if _, err := os.Stat(sessionPath); err != nil {
+		t.Fatalf("session should be restored: %v", err)
+	}
+}
+
+func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dirA := filepath.Join(t.TempDir(), "workspace-a-sessions")
+	dirB := filepath.Join(t.TempDir(), "workspace-b-sessions")
+	if err := os.MkdirAll(dirA, 0o755); err != nil {
+		t.Fatalf("mkdir dirA: %v", err)
+	}
+	if err := os.MkdirAll(dirB, 0o755); err != nil {
+		t.Fatalf("mkdir dirB: %v", err)
+	}
+	pathA := filepath.Join(dirA, "a.jsonl")
+	pathB := filepath.Join(dirB, "b.jsonl")
+	if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write pathA: %v", err)
+	}
+	if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write pathB: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	sessions := app.ListSessions()
+	if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" {
+		t.Fatalf("ListSessions should read the active controller session dir only, got %+v", sessions)
+	}
+	if err := app.RenameSession(pathA, "A title"); err != nil {
+		t.Fatalf("RenameSession in active session dir: %v", err)
+	}
+	meta, ok, err := agent.LoadBranchMeta(pathA)
+	if err != nil || !ok {
+		t.Fatalf("LoadBranchMeta after RenameSession ok=%v err=%v", ok, err)
+	}
+	if meta.CustomTitle != "A title" {
+		t.Fatalf("custom title should be written to branch meta, got %q", meta.CustomTitle)
+	}
+	sessions = app.ListSessions()
+	if len(sessions) != 1 || sessions[0].Title != "A title" {
+		t.Fatalf("ListSessions should return custom title from branch meta, got %+v", sessions)
+	}
+	if titles := loadSessionTitles(dirA); titles["a.jsonl"] != "A title" {
+		t.Fatalf("title should be written beside the active session, got %+v", titles)
+	}
+	if titles := loadSessionTitles(dirB); len(titles) != 0 {
+		t.Fatalf("inactive workspace title sidecar should remain untouched, got %+v", titles)
+	}
+}
+
+func TestListSessionsMarksAutoBotSessionAsChannel(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	cfg := config.Default()
+	cfg.Bot.Connections = []config.BotConnectionConfig{{
+		ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
+		SessionMappings: []config.BotConnectionSessionMapping{{
+			RemoteID: "wx-chat-1", SessionID: "path:" + path, SessionSource: "auto",
+		}},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	sessions := app.ListSessions()
+	if len(sessions) != 1 {
+		t.Fatalf("ListSessions len = %d, want 1: %+v", len(sessions), sessions)
+	}
+	got := sessions[0]
+	if got.Kind != "channel" || got.Channel != "weixin" || got.ChannelLabel != "微信" || got.RemoteID != "wx-chat-1" || got.SessionSource != "auto" {
+		t.Fatalf("channel session meta = %+v", got)
+	}
+}
+
+func TestDeleteSessionClearsAutoBotSessionMapping(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+	other := filepath.Join(dir, "other-channel.jsonl")
+	cfg := config.Default()
+	cfg.Bot.Connections = []config.BotConnectionConfig{{
+		ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected",
+		SessionMappings: []config.BotConnectionSessionMapping{
+			{RemoteID: "remove-auto", SessionID: "path:" + path, SessionSource: "auto"},
+			{RemoteID: "keep-explicit", SessionID: "path:" + path},
+			{RemoteID: "keep-other-auto", SessionID: "path:" + other, SessionSource: "auto"},
+		},
+	}}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer app.activeCtrl().Close()
+
+	if err := app.DeleteSession(path); err != nil {
+		t.Fatalf("DeleteSession: %v", err)
+	}
+
+	got := config.LoadForEdit(config.UserConfigPath())
+	mappings := got.Bot.Connections[0].SessionMappings
+	if len(mappings) != 2 {
+		t.Fatalf("session mappings = %+v, want explicit and other auto mappings preserved", mappings)
+	}
+	for _, mapping := range mappings {
+		if mapping.RemoteID == "remove-auto" {
+			t.Fatalf("deleted session auto mapping was preserved: %+v", mappings)
+		}
+	}
+}
+
+func TestOpenChannelSessionForTabIsReadOnly(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer app.activeCtrl().Close()
+
+	if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
+		t.Fatalf("OpenChannelSessionForTab: %v", err)
+	}
+	if meta := app.tabMeta(app.activeTab(), true); !meta.ReadOnly {
+		t.Fatalf("channel tab should be read-only: %+v", meta)
+	}
+	before, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read before: %v", err)
+	}
+	app.SubmitToTab("test", "must not append")
+	app.RunShellForTab("test", "echo must-not-run")
+	after, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read after: %v", err)
+	}
+	if string(after) != string(before) {
+		t.Fatalf("read-only channel transcript changed:\nbefore=%s\nafter=%s", before, after)
+	}
+
+	f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
+	if err != nil {
+		t.Fatalf("open append: %v", err)
+	}
+	if _, err := f.WriteString(`{"role":"user","content":"external follow-up"}` + "\n"); err != nil {
+		f.Close()
+		t.Fatalf("append external message: %v", err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatalf("close append: %v", err)
+	}
+	app.snapshotAllTabs()
+	afterSnapshot, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read after snapshot: %v", err)
+	}
+	if !strings.Contains(string(afterSnapshot), "external follow-up") {
+		t.Fatalf("read-only channel snapshot overwrote external append:\n%s", afterSnapshot)
+	}
+}
+
+func TestUserTriggeredCommandsReturnErrorsWhenUnavailable(t *testing.T) {
+	tests := []struct {
+		name string
+		app  *App
+		call func(*App) error
+		want string
+	}{
+		{
+			name: "submit read-only",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", ReadOnly: true}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.SubmitToTab("test", "hello") },
+			want: "read-only",
+		},
+		{
+			name: "submit workspace unavailable",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global", StartupErr: "boom"}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.SubmitToTab("test", "hello") },
+			want: "workspace failed to start: boom",
+		},
+		{
+			name: "run shell workspace unavailable",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.RunShellForTab("test", "echo hi") },
+			want: "workspace is still starting",
+		},
+		{
+			name: "steer workspace unavailable",
+			app: &App{
+				tabs:        map[string]*WorkspaceTab{"test": {ID: "test", Scope: "global"}},
+				activeTabID: "test",
+			},
+			call: func(app *App) error { return app.SteerForTab("test", "please continue") },
+			want: "workspace is still starting",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.call(tt.app)
+			if err == nil {
+				t.Fatalf("expected error containing %q", tt.want)
+			}
+			if !strings.Contains(err.Error(), tt.want) {
+				t.Fatalf("error = %q, want to contain %q", err, tt.want)
+			}
+		})
+	}
+}
+
+func TestCloseReadOnlyChannelTabDoesNotSnapshotTranscript(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, "bot-channel.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
+		t.Fatalf("write session: %v", err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: filepath.Join(dir, "active.jsonl"), Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer ctrl.Close()
+
+	if _, err := app.OpenChannelSessionForTab("test", path); err != nil {
+		t.Fatalf("OpenChannelSessionForTab: %v", err)
+	}
+	app.mu.Lock()
+	app.tabs["survivor"] = &WorkspaceTab{ID: "survivor", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
+	app.tabOrder = []string{"test", "survivor"}
+	app.activeTabID = "test"
+	app.mu.Unlock()
+
+	f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
+	if err != nil {
+		t.Fatalf("open append: %v", err)
+	}
+	if _, err := f.WriteString(`{"role":"user","content":"external close follow-up"}` + "\n"); err != nil {
+		f.Close()
+		t.Fatalf("append external message: %v", err)
+	}
+	if err := f.Close(); err != nil {
+		t.Fatalf("close append: %v", err)
+	}
+
+	if err := app.CloseTab("test"); err != nil {
+		t.Fatalf("CloseTab: %v", err)
+	}
+	afterClose, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read after close: %v", err)
+	}
+	if !strings.Contains(string(afterClose), "external close follow-up") {
+		t.Fatalf("closing read-only channel tab overwrote external append:\n%s", afterClose)
+	}
+}
+
+func TestResumeSessionRejectsCleanupPending(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	activePath := filepath.Join(dir, "active.jsonl")
+	pendingPath := filepath.Join(dir, "pending.jsonl")
+	for _, path := range []string{activePath, pendingPath} {
+		if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write %s: %v", path, err)
+		}
+	}
+	if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "test"})
+	app.setTestCtrl(ctrl, "")
+	defer app.activeCtrl().Close()
+
+	if _, err := app.ResumeSession(pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
+		t.Fatalf("ResumeSession cleanup-pending error = %v, want pending cleanup", err)
+	}
+	if got := app.activeCtrl().SessionPath(); filepath.Clean(got) != filepath.Clean(activePath) {
+		t.Fatalf("active session path after rejected resume = %q, want %q", got, activePath)
+	}
+	if _, err := app.OpenChannelSessionForTab("test", pendingPath); err == nil || !strings.Contains(err.Error(), "pending cleanup") {
+		t.Fatalf("OpenChannelSessionForTab cleanup-pending error = %v, want pending cleanup", err)
+	}
+	if meta := app.tabMeta(app.activeTab(), true); meta.ReadOnly {
+		t.Fatalf("rejected channel open should not make tab read-only: %+v", meta)
+	}
+}
+
+func TestResumeSessionRejectsPathOutsideControllerSessionDir(t *testing.T) {
+	dirA := t.TempDir()
+	dirB := t.TempDir()
+	activePath := filepath.Join(dirA, "active.jsonl")
+	outsidePath := filepath.Join(dirB, "outside.jsonl")
+	for _, path := range []string{activePath, outsidePath} {
+		if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
+			t.Fatalf("write %s: %v", path, err)
+		}
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: activePath, Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	if _, err := app.ResumeSession(outsidePath); err == nil {
+		t.Fatal("ResumeSession should reject a transcript outside the active session dir")
+	}
+	if _, err := app.PreviewSession(outsidePath); err == nil {
+		t.Fatal("PreviewSession should reject a transcript outside the active session dir")
+	}
+}
+
+func BenchmarkDesktopListSessionsScoped(b *testing.B) {
+	dirA := filepath.Join(b.TempDir(), "workspace-a-sessions")
+	dirB := filepath.Join(b.TempDir(), "workspace-b-sessions")
+	for _, dir := range []string{dirA, dirB} {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			b.Fatalf("mkdir %s: %v", dir, err)
+		}
+		for i := 0; i < 120; i++ {
+			path := filepath.Join(dir, fmt.Sprintf("session-%03d.jsonl", i))
+			body := fmt.Sprintf(`{"role":"user","content":"session %03d"}`+"\n", i)
+			if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+				b.Fatalf("write session: %v", err)
+			}
+		}
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{SessionDir: dirA, SessionPath: filepath.Join(dirA, "session-000.jsonl"), Label: "test"}), "")
+	defer app.activeCtrl().Close()
+
+	b.ReportAllocs()
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		sessions := app.ListSessions()
+		if len(sessions) != 120 {
+			b.Fatalf("ListSessions len = %d, want 120", len(sessions))
+		}
+	}
+}
+
+type appendingDesktopRunner struct {
+	session *agent.Session
+	started chan string
+}
+
+func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
+	r.started <- input
+	r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
+	r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
+	return nil
+}
+
+func TestSubmitToTabHistoryDisplaysRawInputAfterMemoryCompose(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	path := filepath.Join(dir, "memory-display.jsonl")
+	sess := agent.NewSession("sys")
+	exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+	runner := &appendingDesktopRunner{session: sess, started: make(chan string, 1)}
+	ctrl := control.New(control.Options{
+		Runner:      runner,
+		Executor:    exec,
+		Sink:        event.Discard,
+		SessionDir:  dir,
+		SessionPath: path,
+		Label:       "test",
+	})
+	defer ctrl.Close()
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "deepseek/test")
+	ctrl.QueueMemory(`Saved memory "reasonix-contributions": contribution count updated`)
+
+	const prompt = "不要,删了"
+	app.SubmitToTab("test", prompt)
+	composed := <-runner.started
+	waitNotRunning(t, ctrl)
+
+	if !strings.Contains(composed, "") || !strings.HasSuffix(composed, prompt) {
+		t.Fatalf("model input should include memory update followed by prompt, got %q", composed)
+	}
+	got := app.HistoryForTab("test")
+	if len(got) < 2 {
+		t.Fatalf("history length = %d, want user + assistant", len(got))
+	}
+	if got[0].Role != "system" || got[1].Role != "user" {
+		t.Fatalf("history roles = %+v, want system then user", got[:min(len(got), 2)])
+	}
+	if got[1].Content != prompt {
+		t.Fatalf("displayed user content = %q, want %q", got[1].Content, prompt)
+	}
+	if strings.Contains(got[1].Content, "") {
+		t.Fatalf("displayed user content leaked memory update: %q", got[1].Content)
+	}
+}
+
+func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	workspace := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(""), 0o644); err != nil {
+		t.Fatalf("write workspace config: %v", err)
+	}
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := agent.NewSessionPath(dir, "test")
+	sess := agent.NewSession("sys")
+	exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+	runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}
+	ctrl := control.New(control.Options{
+		Runner:        runner,
+		Executor:      exec,
+		Sink:          event.Discard,
+		SessionDir:    dir,
+		SessionPath:   path,
+		Label:         "test",
+		WorkspaceRoot: workspace,
+	})
+	app := NewApp()
+	app.setTestCtrl(ctrl, "deepseek/test")
+	app.tabs["test"].Scope = "project"
+	app.tabs["test"].WorkspaceRoot = workspace
+	app.tabs["test"].TopicID = "topic_source"
+	app.tabs["test"].TopicTitle = "Source topic"
+	defer ctrl.Close()
+
+	ctrl.Submit("first")
+	<-runner.started
+	waitNotRunning(t, ctrl)
+	ctrl.Submit("second")
+	<-runner.started
+	waitNotRunning(t, ctrl)
+	if got := len(ctrl.History()); got != 5 {
+		t.Fatalf("source history len before fork = %d, want 5", got)
+	}
+
+	meta, err := app.Fork(1)
+	if err != nil {
+		t.Fatalf("Fork: %v", err)
+	}
+	if !meta.Active || meta.ID == "" || meta.ID == "test" {
+		t.Fatalf("fork meta = %+v, want a new active tab", meta)
+	}
+	if got := app.activeTabID; got != meta.ID {
+		t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID)
+	}
+	if got := ctrl.SessionPath(); got != path {
+		t.Fatalf("source controller session path = %q, want %q", got, path)
+	}
+	if got := len(ctrl.History()); got != 5 {
+		t.Fatalf("source history len after fork = %d, want 5", got)
+	}
+	if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want {
+		t.Fatalf("fork topic title = %q, want %q", got, want)
+	}
+
+	var forkPath string
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatalf("read session dir: %v", err)
+	}
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+		candidate := filepath.Join(dir, entry.Name())
+		if candidate == path {
+			continue
+		}
+		m, ok, err := agent.LoadBranchMeta(candidate)
+		if err != nil {
+			t.Fatalf("load fork meta: %v", err)
+		}
+		if ok && m.TopicID == meta.TopicID {
+			forkPath = candidate
+			if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {
+				t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path))
+			}
+			if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" {
+				t.Fatalf("fork topic meta = %+v", m)
+			}
+		}
+	}
+	if forkPath == "" {
+		t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir)
+	}
+}
+
+func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "playwright" {
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
+				t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
+	home := isolateDesktopUserDirs(t)
+	reasonixHome := filepath.Join(home, ".reasonix")
+	root := filepath.Join(reasonixHome, "plugins", "superpowers")
+	if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(root, "skills", "plan"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(root, "skills", "plan", "SKILL.md"), []byte("---\ndescription: Plan work\n---\nbody"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.MkdirAll(filepath.Join(root, ".codex-plugin"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(root, ".codex-plugin", "plugin.json"), []byte(`{
+  "name": "superpowers",
+  "version": "6.1.0",
+  "description": "Planning workflows",
+  "skills": "./skills/"
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
+		Name:         "superpowers",
+		Root:         "plugins/superpowers",
+		Version:      "6.1.0",
+		Description:  "Planning workflows",
+		ManifestKind: "codex",
+		Enabled:      true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	plugins := app.Capabilities().Plugins
+	if len(plugins) != 1 || plugins[0].Name != "superpowers" || plugins[0].Skills != 1 {
+		t.Fatalf("Capabilities().Plugins = %+v", plugins)
+	}
+	if len(plugins[0].SkillDetails) != 1 || plugins[0].SkillDetails[0].Invocation != "/superpowers:plan" {
+		t.Fatalf("Capabilities().Plugins skill details = %+v", plugins[0].SkillDetails)
+	}
+}
+
+func TestDesktopSharedHostBackgroundMCPAutoConnectsOnBoot(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping background MCP boot integration test in short mode")
+	}
+
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
+[[plugins]]
+name = "h"
+type = "http"
+url = %q
+`, srv.URL)), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	sharedHost := plugin.NewHost()
+	defer sharedHost.Close()
+	ctrl, err := boot.Build(ctx, boot.Options{
+		WorkspaceRoot: dir,
+		SessionDir:    filepath.Join(dir, "sessions"),
+		SharedHost:    sharedHost,
+		Stderr:        io.Discard,
+	})
+	if err != nil {
+		t.Fatalf("boot.Build: %v", err)
+	}
+	defer ctrl.Close()
+
+	deadline := time.Now().Add(3 * time.Second)
+	for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
+		time.Sleep(25 * time.Millisecond)
+	}
+	if !sharedHost.HasClient("h") {
+		t.Fatalf("background MCP did not auto-connect; connecting=%v failures=%+v", sharedHost.ConnectingServers(), sharedHost.Failures())
+	}
+
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{
+		"test": {
+			ID:            "test",
+			Scope:         "global",
+			WorkspaceRoot: dir,
+			Ready:         true,
+			Ctrl:          ctrl,
+			SharedHostKey: dir,
+			disabledMCP:   map[string]ServerView{},
+		},
+	}
+	app.activeTabID = "test"
+
+	view := app.MCPServers()
+	if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].StartIntent != "automatic" || view[0].RuntimeState != "ready" || view[0].Tools != 1 {
+		t.Fatalf("MCPServers() = %+v, want h connected automatic ready with one tool", view)
+	}
+}
+
+func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if got, want := app.MCPServers(), app.Capabilities().Servers; !reflect.DeepEqual(got, want) {
+		t.Fatalf("MCPServers() = %+v, want Capabilities().Servers %+v", got, want)
+	}
+}
+
+func TestConfiguredMCPWithFormerBuiltInNameIsUserServer(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "time"
+command = "custom-time"
+args = ["serve"]
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	found := false
+	for _, s := range view.Servers {
+		if s.Name != "time" {
+			continue
+		}
+		found = true
+		if s.BuiltIn || !s.Configured || s.Command != "custom-time" || !reflect.DeepEqual(s.Args, []string{"serve"}) {
+			t.Fatalf("configured time view = %+v, want ordinary user MCP config", s)
+		}
+	}
+	if !found {
+		t.Fatalf("configured time server missing from Capabilities: %+v", view.Servers)
+	}
+
+	if err := app.SetMCPServerEnabled("time", false); err != nil {
+		t.Fatalf("SetMCPServerEnabled(time,false): %v", err)
+	}
+	view = app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "time" {
+			if s.Status != "disabled" || s.BuiltIn || s.Command != "custom-time" {
+				t.Fatalf("disabled configured time view = %+v, want disabled external config", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("time missing after disable: %+v", view.Servers)
+}
+
+func TestSetMCPServerEnabledSharedHostPreservesSiblingTabs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(fmt.Sprintf(`
+[[plugins]]
+name = "h"
+type = "http"
+url = %q
+`, srv.URL)), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	sharedHost := plugin.NewHost()
+	defer sharedHost.Close()
+	tools, err := sharedHost.Add(ctx, plugin.Spec{Name: "h", Type: "http", URL: srv.URL})
+	if err != nil {
+		t.Fatalf("sharedHost.Add: %v", err)
+	}
+
+	activeRegistry := tool.NewRegistry()
+	siblingRegistry := tool.NewRegistry()
+	for _, mt := range tools {
+		activeRegistry.Add(mt)
+		siblingRegistry.Add(mt)
+	}
+	activeCtrl := control.New(control.Options{Host: sharedHost, Registry: activeRegistry, PluginCtx: context.Background()})
+	siblingCtrl := control.New(control.Options{Host: sharedHost, Registry: siblingRegistry, PluginCtx: context.Background()})
+	app := NewApp()
+	app.tabs = map[string]*WorkspaceTab{
+		"active": {
+			ID:            "active",
+			Scope:         "global",
+			WorkspaceRoot: dir,
+			Ready:         true,
+			Ctrl:          activeCtrl,
+			SharedHostKey: dir,
+			disabledMCP:   map[string]ServerView{},
+		},
+		"sibling": {
+			ID:            "sibling",
+			Scope:         "global",
+			WorkspaceRoot: dir,
+			Ready:         true,
+			Ctrl:          siblingCtrl,
+			SharedHostKey: dir,
+			disabledMCP:   map[string]ServerView{},
+		},
+	}
+	app.activeTabID = "active"
+
+	if err := app.SetMCPServerEnabled("h", false); err != nil {
+		t.Fatalf("SetMCPServerEnabled(h,false): %v", err)
+	}
+	if _, found := activeRegistry.Get("mcp__h__greet"); found {
+		t.Fatal("active tab still has h tools after disabling the shared server")
+	}
+	if _, found := siblingRegistry.Get("mcp__h__greet"); !found {
+		t.Fatal("sibling tab lost h tools when active tab disabled the shared server")
+	}
+	if !sharedHost.HasClient("h") {
+		t.Fatal("shared host client was removed by a per-tab disable")
+	}
+	view := app.Capabilities()
+	if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "disabled" {
+		t.Fatalf("Capabilities after disable = %+v, want h disabled for the active tab", view.Servers)
+	}
+
+	if err := app.SetMCPServerEnabled("h", true); err != nil {
+		t.Fatalf("SetMCPServerEnabled(h,true): %v", err)
+	}
+	if _, found := activeRegistry.Get("mcp__h__greet"); !found {
+		t.Fatal("active tab did not re-register h tools from the existing shared client")
+	}
+	view = app.Capabilities()
+	if len(view.Servers) != 1 || view.Servers[0].Name != "h" || view.Servers[0].Status != "connected" {
+		t.Fatalf("Capabilities after re-enable = %+v, want h connected for the active tab", view.Servers)
+	}
+}
+
+func TestSetMCPServerEnabledRejectsBackgroundJobs(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	app.setTestCtrl(newBackgroundJobController(t, "mcp-enabled-job"), "")
+
+	err := app.SetMCPServerEnabled("time", false)
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetMCPServerEnabled with background job error = %v, want active-work guard", err)
+	}
+	if tab := app.activeTab(); tab == nil || len(tab.disabledMCP) != 0 {
+		t.Fatalf("disabled MCP state changed after rejected toggle: %+v", tab)
+	}
+}
+
+func TestEditAndRemoveConfiguredMCPWithBuiltInName(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "time"
+command = "custom-time"
+args = ["serve"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.UpdateMCPServer("time", MCPServerInput{
+		Name:      "time",
+		Transport: "stdio",
+		Command:   "updated-time",
+		Args:      []string{"run"},
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer(time): %v", err)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	updated, ok := findPluginEntry(cfg.Plugins, "time")
+	if !ok || updated.Command != "updated-time" || !reflect.DeepEqual(updated.Args, []string{"run"}) {
+		t.Fatalf("updated time plugin = %+v, found=%v", updated, ok)
+	}
+
+	if err := app.RemoveMCPServer("time"); err != nil {
+		t.Fatalf("RemoveMCPServer(time): %v", err)
+	}
+	cfg, err = config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := findPluginEntry(cfg.Plugins, "time"); ok {
+		t.Fatalf("time plugin still configured after remove: %+v", cfg.Plugins)
+	}
+}
+
+func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+	recordMCPFailure(app.activeCtrl(), config.PluginEntry{
+		Name:    "broken",
+		Command: "reasonix-missing-mcp-binary",
+	}, errors.New("connect: missing binary"))
+
+	view := app.Capabilities()
+	if len(view.Servers) != 1 || view.Servers[0].Name != "broken" || view.Servers[0].Status != "failed" {
+		t.Fatalf("Capabilities before remove = %+v, want broken failed", view.Servers)
+	}
+
+	if err := app.RemoveMCPServer("broken"); err != nil {
+		t.Fatalf("RemoveMCPServer(broken): %v", err)
+	}
+	if mcpFailed(app.activeCtrl(), "broken") {
+		t.Fatalf("Host.Failures() still contains broken after remove: %+v", app.activeCtrl().Host().Failures())
+	}
+	view = app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			t.Fatalf("Capabilities after remove still contains broken: %+v", view.Servers)
+		}
+	}
+}
+
+func TestRemoveMCPServerDeletesProjectMCPJSONEntry(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
+  "mcpServers": {
+    "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] },
+    "keep": { "command": "keep-mcp" }
+  }
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.RemoveMCPServer("codegraph"); err != nil {
+		t.Fatalf("RemoveMCPServer(.mcp.json codegraph): %v", err)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := findPluginEntry(cfg.Plugins, "codegraph"); ok {
+		t.Fatalf("codegraph still merged after remove: %+v", cfg.Plugins)
+	}
+	if _, ok := findPluginEntry(cfg.Plugins, "keep"); !ok {
+		t.Fatalf("unrelated .mcp.json server should be preserved: %+v", cfg.Plugins)
+	}
+}
+
+func TestRemoveMCPServerRejectsPluginManagedServerWithoutDisconnecting(t *testing.T) {
+	home := isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	reasonixHome := filepath.Join(home, ".reasonix")
+	root := filepath.Join(reasonixHome, "plugins", "superpowers")
+	if err := os.MkdirAll(root, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(fmt.Sprintf(`{
+  "name": "superpowers",
+  "version": "1.0.0",
+  "mcpServers": {
+    "helper": { "type": "http", "url": %q }
+  }
+}`, srv.URL)), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
+		Name:         "superpowers",
+		Root:         "plugins/superpowers",
+		Version:      "1.0.0",
+		ManifestKind: "reasonix",
+		Enabled:      true,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entry, ok := findPluginEntry(cfg.Plugins, "helper")
+	if !ok {
+		t.Fatalf("plugin-managed MCP missing from config: %+v", cfg.Plugins)
+	}
+	ctrl := control.New(control.Options{Host: plugin.NewHost()})
+	defer ctrl.Close()
+	if _, err := ctrl.ConnectMCPServer(entry); err != nil {
+		t.Fatalf("connect plugin-managed MCP: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	app.activeTab().WorkspaceRoot = dir
+	err = app.RemoveMCPServer("helper")
+	if err == nil || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
+		t.Fatalf("RemoveMCPServer(plugin-managed) error = %v", err)
+	}
+	if !mcpConnected(ctrl, "helper") {
+		t.Fatal("plugin-managed MCP was disconnected despite rejected removal")
+	}
+	for action, actionErr := range map[string]error{
+		"trust tool": app.TrustMCPServerTool("helper", "echo"),
+		"clear auth": app.ClearMCPServerAuthentication("helper"),
+		"update":     app.UpdateMCPServer("helper", MCPServerInput{Name: "helper", Transport: "http", URL: srv.URL}),
+	} {
+		if actionErr == nil || !strings.Contains(actionErr.Error(), "managed by plugin") {
+			t.Fatalf("%s plugin-managed MCP error = %v", action, actionErr)
+		}
+	}
+	if _, found := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "helper"); found {
+		t.Fatal("plugin-managed MCP mutation created a user-config shadow")
+	}
+	servers := app.MCPServers()
+	if len(servers) != 1 || servers[0].Name != "helper" || servers[0].ManagedByPlugin != "superpowers" {
+		t.Fatalf("MCPServers() = %+v, want helper managed by superpowers", servers)
+	}
+}
+
+func TestRemoveMCPServerRejectsRuntimeOnlyServerWithoutDisconnecting(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+	ctrl := control.New(control.Options{Host: plugin.NewHost()})
+	defer ctrl.Close()
+	if _, err := ctrl.ConnectMCPServer(config.PluginEntry{Name: "runtime-only", Type: "http", URL: srv.URL}); err != nil {
+		t.Fatalf("connect runtime-only MCP: %v", err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(ctrl, "")
+	app.activeTab().WorkspaceRoot = dir
+	err := app.RemoveMCPServer("runtime-only")
+	if err == nil || !strings.Contains(err.Error(), "no removable MCP server") {
+		t.Fatalf("RemoveMCPServer(runtime-only) error = %v", err)
+	}
+	if !mcpConnected(ctrl, "runtime-only") {
+		t.Fatal("runtime-only MCP was disconnected despite failed persistence removal")
+	}
+}
+
+func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
+  "mcpServers": {
+    "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
+  }
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.UpdateMCPServer("codegraph", MCPServerInput{
+		Name:      "codegraph",
+		Transport: "stdio",
+		Command:   "reasonix-missing-mcp-binary",
+		Args:      []string{"serve", "--mcp"},
+		Env:       map[string]string{"CODEGRAPH_LOG": "debug"},
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer(.mcp.json codegraph): %v", err)
+	}
+
+	raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var doc struct {
+		MCPServers map[string]struct {
+			Command string            `json:"command"`
+			Args    []string          `json:"args"`
+			Env     map[string]string `json:"env"`
+		} `json:"mcpServers"`
+	}
+	if err := json.Unmarshal(raw, &doc); err != nil {
+		t.Fatal(err)
+	}
+	got := doc.MCPServers["codegraph"]
+	if got.Command != "reasonix-missing-mcp-binary" || !reflect.DeepEqual(got.Args, []string{"serve", "--mcp"}) || got.Env["CODEGRAPH_LOG"] != "debug" {
+		t.Fatalf(".mcp.json codegraph = %+v, want updated command/args/env", got)
+	}
+	if _, ok := findPluginEntry(config.LoadForEdit(config.UserConfigPath()).Plugins, "codegraph"); ok {
+		t.Fatalf(".mcp.json update should not create a user config shadow entry")
+	}
+}
+
+func TestTrustMCPServerToolPersistsTrustedReadOnlyTools(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "github"
+command = "npx"
+args = ["-y", "@modelcontextprotocol/server-github"]
+trusted_read_only_tools = ["pull_request_read"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.TrustMCPServerTool("github", " issue_read "); err != nil {
+		t.Fatalf("TrustMCPServerTool(github, issue_read): %v", err)
+	}
+	if err := app.TrustMCPServerTool("github", "issue_read"); err != nil {
+		t.Fatalf("TrustMCPServerTool duplicate: %v", err)
+	}
+	if err := app.TrustMCPServerTools("github", []string{" search_issues ", "issue_read", ""}); err != nil {
+		t.Fatalf("TrustMCPServerTools(github): %v", err)
+	}
+	if err := app.UntrustMCPServerTool("github", " pull_request_read "); err != nil {
+		t.Fatalf("UntrustMCPServerTool(github, pull_request_read): %v", err)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	updated, ok := findPluginEntry(cfg.Plugins, "github")
+	if !ok {
+		t.Fatalf("github plugin missing: %+v", cfg.Plugins)
+	}
+	if !reflect.DeepEqual(updated.TrustedReadOnlyTools, []string{"issue_read", "search_issues"}) {
+		t.Fatalf("trusted read-only tools = %+v", updated.TrustedReadOnlyTools)
+	}
+	for _, s := range app.MCPServers() {
+		if s.Name == "github" {
+			if !reflect.DeepEqual(s.TrustedReadOnlyTools, []string{"issue_read", "search_issues"}) {
+				t.Fatalf("view trusted read-only tools = %+v", s.TrustedReadOnlyTools)
+			}
+			return
+		}
+	}
+	t.Fatalf("github MCP missing from view")
+}
+
+func TestTrustMCPServerToolPersistsProjectMCPJSONEntry(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, ".mcp.json"), []byte(`{
+  "mcpServers": {
+    "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"] }
+  }
+}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.TrustMCPServerTool("codegraph", "codegraph_context"); err != nil {
+		t.Fatalf("TrustMCPServerTool(.mcp.json codegraph): %v", err)
+	}
+
+	raw, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	var doc struct {
+		MCPServers map[string]struct {
+			TrustedReadOnlyTools []string `json:"trusted_read_only_tools"`
+		} `json:"mcpServers"`
+	}
+	if err := json.Unmarshal(raw, &doc); err != nil {
+		t.Fatal(err)
+	}
+	if !reflect.DeepEqual(doc.MCPServers["codegraph"].TrustedReadOnlyTools, []string{"codegraph_context"}) {
+		t.Fatalf(".mcp.json trusted_read_only_tools = %+v", doc.MCPServers["codegraph"].TrustedReadOnlyTools)
+	}
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	updated, ok := findPluginEntry(cfg.Plugins, "codegraph")
+	if !ok || !reflect.DeepEqual(updated.TrustedReadOnlyTools, []string{"codegraph_context"}) {
+		t.Fatalf("merged codegraph trusted_read_only_tools = %+v, found=%v", updated.TrustedReadOnlyTools, ok)
+	}
+}
+
+func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	t.Setenv("STRIPE_TOKEN", "stripe-test-token")
+	srv := desktopMCPHTTPServer(t)
+	defer srv.Close()
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	tools, err := app.AddMCPServer(MCPServerInput{
+		Name:      "stripe",
+		Transport: "http",
+		URL:       srv.URL,
+		Headers: map[string]string{
+			"Authorization": "Bearer ${STRIPE_TOKEN}",
+			"X-Org":         "team",
+		},
+	})
+	if err != nil {
+		t.Fatalf("AddMCPServer(stripe): %v", err)
+	}
+	if tools != 1 {
+		t.Fatalf("tools = %d, want 1", tools)
+	}
+
+	cfg, err := config.LoadForRoot(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	p, ok := findPluginEntry(cfg.Plugins, "stripe")
+	if !ok {
+		t.Fatalf("stripe plugin missing from config: %+v", cfg.Plugins)
+	}
+	if p.Type != "http" || p.URL != srv.URL {
+		t.Fatalf("stripe plugin transport = %q url = %q", p.Type, p.URL)
+	}
+	if p.Headers["Authorization"] != "Bearer ${STRIPE_TOKEN}" || p.Headers["X-Org"] != "team" {
+		t.Fatalf("stripe headers = %+v", p.Headers)
+	}
+
+	view := app.MCPServers()
+	for _, s := range view {
+		if s.Name == "stripe" {
+			if !reflect.DeepEqual(s.HeaderKeys, []string{"Authorization", "X-Org"}) {
+				t.Fatalf("stripe header keys = %+v", s.HeaderKeys)
+			}
+			return
+		}
+	}
+	t.Fatalf("stripe MCP missing from view: %+v", view)
+}
+
+func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "dida"
+type = "http"
+url = "https://mcp.dida365.com"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "dida" {
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
+				t.Fatalf("dida auth diagnosis = %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("dida MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "stripe"
+type = "http"
+url = "https://mcp.stripe.com"
+headers = { Authorization = "Bearer ${STRIPE_TOKEN}" }
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "stripe" {
+			if s.AuthStatus != "none" {
+				t.Fatalf("stripe auth status = %q, want none; server = %+v", s.AuthStatus, s)
+			}
+			return
+		}
+	}
+	t.Fatalf("stripe MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "figma"
+type = "http"
+url = "https://mcp.figma.com/mcp"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	host := plugin.NewHost()
+	host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: host}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "figma" {
+			if s.Status != "failed" || s.AuthStatus != "required" || s.AuthURL != "https://mcp.figma.com/mcp" {
+				t.Fatalf("figma auth diagnosis = %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "figma"
+type = "http"
+url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main"
+headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" }
+env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" }
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	host := plugin.NewHost()
+	host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: host}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.ClearMCPServerAuthentication("figma"); err != nil {
+		t.Fatalf("ClearMCPServerAuthentication: %v", err)
+	}
+	if failures := host.Failures(); len(failures) != 0 {
+		t.Fatalf("failure should be cleared: %+v", failures)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	p := cfg.Plugins[0]
+	if p.URL != "https://mcp.figma.com/mcp?workspace=main" {
+		t.Fatalf("url = %q", p.URL)
+	}
+	if _, ok := p.Headers["Authorization"]; ok {
+		t.Fatalf("auth header should be removed: %v", p.Headers)
+	}
+	if p.Headers["X-Org"] != "team" {
+		t.Fatalf("ordinary header should be preserved: %v", p.Headers)
+	}
+	if _, ok := p.Env["FIGMA_TOKEN"]; ok {
+		t.Fatalf("auth env should be removed: %v", p.Env)
+	}
+	if p.Env["DEBUG"] != "1" {
+		t.Fatalf("ordinary env should be preserved: %v", p.Env)
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "figma" {
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" || s.AuthStatus != "possible" {
+				t.Fatalf("figma should return to background possible auth: %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.UpdateMCPServer("playwright", MCPServerInput{
+		Name:      "playwright",
+		Transport: "stdio",
+		Command:   "node",
+		Args:      []string{"server.js"},
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Plugins[0].Command; got != "node" {
+		t.Fatalf("updated command = %q, want node", got)
+	}
+	if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
+		t.Fatalf("env TOKEN = %q, want preserved env", got)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
+	if !ok {
+		t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
+	}
+	if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
+		t.Fatalf("user plugin after migration = %+v", userPlugin)
+	}
+	if userPlugin.Tier != "" {
+		t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
+	}
+	projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
+	if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
+		t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "playwright" {
+			if s.Status != "failed" {
+				t.Fatalf("updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v", s.Status, s)
+			}
+			if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
+				t.Fatalf("server command not refreshed: %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := t.TempDir()
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "playwright"
+command = "npx"
+args = ["-y", "@playwright/mcp"]
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+	app.activeTab().disabledMCP["playwright"] = ServerView{}
+
+	if err := app.UpdateMCPServer("playwright", MCPServerInput{
+		Name:      "playwright",
+		Transport: "stdio",
+		Command:   "npx -y @modelcontextprotocol/server-filesystem .",
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	p := cfg.Plugins[0]
+	if p.Command != "npx" {
+		t.Fatalf("command = %q, want npx", p.Command)
+	}
+	if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") {
+		t.Fatalf("args = %v", p.Args)
+	}
+}
+
+func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "npx"
+tier = "background"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+
+	if err := app.UpdateMCPServer("broken", MCPServerInput{
+		Name:      "broken",
+		Transport: "stdio",
+		Command:   "reasonix-missing-mcp-binary",
+	}); err != nil {
+		t.Fatalf("UpdateMCPServer should persist config even when reconnect fails: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Plugins[0].Command; got != "reasonix-missing-mcp-binary" {
+		t.Fatalf("updated command = %q, want missing binary", got)
+	}
+	if got := cfg.Plugins[0].Tier; got != "" {
+		t.Fatalf("updated tier = %q, want migrated empty", got)
+	}
+	if !mcpFailed(app.activeCtrl(), "broken") {
+		t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			if s.Status != "failed" {
+				t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
+			}
+			if s.Command != "reasonix-missing-mcp-binary" || s.Tier != "background" {
+				t.Fatalf("server config not refreshed after failed reconnect: %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestReconnectMCPServerClearsInitializingPlaceholderAndRecordsFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "codegraph"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	reg := tool.NewRegistry()
+	reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), Registry: reg}), "")
+	defer app.activeCtrl().Close()
+
+	view := app.Capabilities()
+	foundIdle := false
+	for _, s := range view.Servers {
+		if s.Name == "codegraph" {
+			foundIdle = true
+			if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
+				t.Fatalf("initial codegraph server = %+v, want automatic idle background state", s)
+			}
+		}
+	}
+	if !foundIdle {
+		t.Fatalf("codegraph missing before reconnect: %+v", view.Servers)
+	}
+	if _, ok := reg.Get("mcp__codegraph__connect"); !ok {
+		t.Fatal("test setup expected stale codegraph connect placeholder")
+	}
+
+	if err := app.ReconnectMCPServer("codegraph"); err == nil || !strings.Contains(err.Error(), "command is required") {
+		t.Fatalf("ReconnectMCPServer error = %v, want missing command", err)
+	}
+	if _, ok := reg.Get("mcp__codegraph__connect"); ok {
+		t.Fatalf("stale codegraph placeholder still registered after reconnect failure; names=%v", reg.Names())
+	}
+	if !mcpFailed(app.activeCtrl(), "codegraph") {
+		t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded", app.activeCtrl().Host().Failures())
+	}
+
+	view = app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "codegraph" {
+			if s.Status != "failed" || s.Error == "" {
+				t.Fatalf("codegraph after failed reconnect = %+v, want failed with error", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
+}
+
+func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer func() {
+		if c := app.activeCtrl(); c != nil {
+			c.Close()
+		}
+	}()
+
+	if err := app.SetMCPServerTier("broken", "background"); err != nil {
+		t.Fatalf("SetMCPServerTier legacy binding: %v", err)
+	}
+	cfg, err := config.Load()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got := cfg.Plugins[0].Tier; got != "" {
+		t.Fatalf("saved tier = %q, want migrated empty", got)
+	}
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
+	if !ok {
+		t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
+	}
+	if userPlugin.Tier != "" {
+		t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
+	}
+	projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
+	if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
+		t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
+	}
+	if !mcpFailed(app.activeCtrl(), "broken") {
+		t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
+	}
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			if s.Status != "failed" {
+				t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
+			}
+			if s.Tier != "background" {
+				t.Fatalf("server tier = %q, want background so radio selection does not jump back", s.Tier)
+			}
+			return
+		}
+	}
+	t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestSetMCPServerTierRejectsBackgroundJobsBeforeSavingConfig(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+		t.Fatalf("mkdir config dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserConfigPath(), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+tier = "lazy"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(newBackgroundJobController(t, "mcp-tier-job"), "")
+
+	err := app.SetMCPServerTier("broken", "background")
+	if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
+		t.Fatalf("SetMCPServerTier with background job error = %v, want active-work guard", err)
+	}
+	data, readErr := os.ReadFile(config.UserConfigPath())
+	if readErr != nil {
+		t.Fatalf("read config: %v", readErr)
+	}
+	if !strings.Contains(string(data), `tier = "lazy"`) {
+		t.Fatalf("plugin config changed after rejected tier update:\n%s", data)
+	}
+}
+
+func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	dir := robustTempDir(t)
+	t.Chdir(dir)
+	if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+[[plugins]]
+name = "broken"
+command = "reasonix-missing-mcp-binary"
+tier = "eager"
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+	defer app.activeCtrl().Close()
+	recordMCPFailure(app.activeCtrl(), config.PluginEntry{
+		Name:    "broken",
+		Command: "reasonix-missing-mcp-binary",
+		Tier:    "eager",
+	}, errors.New("connect: missing binary"))
+
+	view := app.Capabilities()
+	for _, s := range view.Servers {
+		if s.Name == "broken" {
+			if s.Status != "failed" {
+				t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
+			}
+			if s.Tier != "background" {
+				t.Fatalf("server tier = %q, want migrated background default", s.Tier)
+			}
+			if !s.Configured {
+				t.Fatalf("server configured = false, want true; server = %+v", s)
+			}
+			return
+		}
+	}
+	t.Fatalf("broken MCP missing from Capabilities: %+v", view.Servers)
+}
+
+func TestRunShellForTabRoutesToRequestedTab(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	activeEvents := make(chan event.Event, 16)
+	inactiveEvents := make(chan event.Event, 16)
+	activeCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { activeEvents <- e })})
+	inactiveCtrl := control.New(control.Options{Sink: event.FuncSink(func(e event.Event) { inactiveEvents <- e })})
+	defer activeCtrl.Close()
+	defer inactiveCtrl.Close()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"active":   {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
+			"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
+		},
+		tabOrder:    []string{"active", "inactive"},
+		activeTabID: "active",
+	}
+
+	app.RunShellForTab("inactive", "echo route-test")
+
+	sawDispatch := false
+	deadline := time.After(3 * time.Second)
+	for {
+		select {
+		case e := <-inactiveEvents:
+			if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, "route-test") {
+				sawDispatch = true
+			}
+			if e.Kind == event.TurnDone {
+				if !sawDispatch {
+					t.Fatal("inactive tab finished without receiving shell dispatch")
+				}
+				select {
+				case active := <-activeEvents:
+					t.Fatalf("active tab received event for inactive shell: %+v", active)
+				default:
+				}
+				return
+			}
+		case <-deadline:
+			t.Fatal("timed out waiting for inactive shell turn")
+		}
+	}
+}
+
+func TestRunShellForTabStaysBoundDuringRapidProjectTabSwitching(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping shell cancellation integration test in short mode")
+	}
+
+	isolateDesktopUserDirs(t)
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	globalRoot := t.TempDir()
+	shellEvents := make(chan event.Event, 64)
+	projectEvents := make(chan event.Event, 64)
+	globalEvents := make(chan event.Event, 64)
+	shellCtrl := control.New(control.Options{
+		Sink:          event.FuncSink(func(e event.Event) { shellEvents <- e }),
+		WorkspaceRoot: projectA,
+	})
+	projectCtrl := control.New(control.Options{
+		Sink:          event.FuncSink(func(e event.Event) { projectEvents <- e }),
+		WorkspaceRoot: projectB,
+	})
+	globalCtrl := control.New(control.Options{
+		Sink:          event.FuncSink(func(e event.Event) { globalEvents <- e }),
+		WorkspaceRoot: globalRoot,
+	})
+	defer shellCtrl.Close()
+	defer projectCtrl.Close()
+	defer globalCtrl.Close()
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"shell":     {ID: "shell", Scope: "project", WorkspaceRoot: projectA, Ctrl: shellCtrl, Ready: true},
+			"project-b": {ID: "project-b", Scope: "project", WorkspaceRoot: projectB, Ctrl: projectCtrl, Ready: true},
+			"global":    {ID: "global", Scope: "global", WorkspaceRoot: globalRoot, Ctrl: globalCtrl, Ready: true},
+		},
+		tabOrder:    []string{"shell", "project-b", "global"},
+		activeTabID: "shell",
+	}
+
+	marker := "shell-route-marker.txt"
+	if err := app.RunShellForTab("shell", longRunningMarkerCommand(marker)); err != nil {
+		t.Fatalf("RunShellForTab: %v", err)
+	}
+	waitForShellDispatch(t, shellEvents, marker)
+	waitForFile(t, filepath.Join(projectA, marker), "shell")
+
+	for i := 0; i < 8; i++ {
+		if err := app.SetActiveTab("project-b"); err != nil {
+			t.Fatalf("SetActiveTab(project-b): %v", err)
+		}
+		if err := app.SetActiveTab("global"); err != nil {
+			t.Fatalf("SetActiveTab(global): %v", err)
+		}
+		if err := app.SetActiveTab("shell"); err != nil {
+			t.Fatalf("SetActiveTab(shell): %v", err)
+		}
+	}
+	if err := app.SetActiveTab("project-b"); err != nil {
+		t.Fatalf("SetActiveTab(project-b final): %v", err)
+	}
+	app.CancelTab("shell")
+
+	cancelled := false
+	deadline := time.After(15 * time.Second)
+	for {
+		select {
+		case e := <-shellEvents:
+			if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
+				cancelled = e.Tool.Err != ""
+			}
+			if e.Kind == event.TurnDone {
+				if !cancelled {
+					t.Fatal("shell tab finished without a cancelled shell result")
+				}
+				if _, err := os.Stat(filepath.Join(projectB, marker)); !errors.Is(err, os.ErrNotExist) {
+					t.Fatalf("shell marker appeared in project-b workspace: %v", err)
+				}
+				if got := activeTabIDForTest(app); got != "project-b" {
+					t.Fatalf("active tab = %q, want project-b after background shell cancel", got)
+				}
+				assertNoEvents(t, projectEvents, "project-b")
+				assertNoEvents(t, globalEvents, "global")
+				return
+			}
+		case <-deadline:
+			t.Fatal("timed out waiting for shell tab cancellation")
+		}
+	}
+}
+
+func longRunningMarkerCommand(marker string) string {
+	if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
+		return fmt.Sprintf("Set-Content -LiteralPath %s -Value shell; Start-Sleep -Seconds 30", marker)
+	}
+	return fmt.Sprintf("printf shell > %s; sleep 30", marker)
+}
+
+func waitForShellDispatch(t *testing.T, ch <-chan event.Event, marker string) {
+	t.Helper()
+	deadline := time.After(5 * time.Second)
+	for {
+		select {
+		case e := <-ch:
+			if e.Kind == event.ToolDispatch && strings.Contains(e.Tool.Args, marker) {
+				return
+			}
+		case <-deadline:
+			t.Fatal("timed out waiting for shell dispatch")
+		}
+	}
+}
+
+func activeTabIDForTest(app *App) string {
+	app.mu.RLock()
+	defer app.mu.RUnlock()
+	return app.activeTabID
+}
+
+func assertNoEvents(t *testing.T, ch <-chan event.Event, name string) {
+	t.Helper()
+	select {
+	case e := <-ch:
+		t.Fatalf("%s received event while shell ran in another tab: %+v", name, e)
+	default:
+	}
+}
+
+type blockingRunner struct {
+	started chan struct{}
+	release chan struct{}
+}
+
+func (r *blockingRunner) Run(ctx context.Context, _ string) error {
+	close(r.started)
+	select {
+	case <-ctx.Done():
+		return ctx.Err()
+	case <-r.release:
+		return nil
+	}
+}
+
+func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath string) func() {
+	t.Helper()
+	started := make(chan struct{})
+	release := make(chan struct{})
+	jm.StartForSession(agent.BranchID(sessionPath), "bash", "stuck job", func(ctx context.Context, _ io.Writer) (string, error) {
+		close(started)
+		<-ctx.Done()
+		<-release
+		return "", ctx.Err()
+	})
+	select {
+	case <-started:
+	case <-time.After(2 * time.Second):
+		t.Fatal("background job never started")
+	}
+	released := false
+	return func() {
+		if released {
+			return
+		}
+		released = true
+		close(release)
+	}
+}
+
+func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
+	t.Helper()
+	deadline := time.Now().Add(time.Second)
+	for ctrl.Running() {
+		if time.Now().After(deadline) {
+			t.Fatal("controller still running")
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func newBackgroundJobController(t *testing.T, label string) *control.Controller {
+	t.Helper()
+	dir := config.SessionDir()
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		t.Fatalf("mkdir session dir: %v", err)
+	}
+	path := filepath.Join(dir, label+".jsonl")
+	jm := jobs.NewManager(event.Discard)
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
+	t.Cleanup(ctrl.Close)
+	jm.StartForSession(agent.BranchID(path), "bash", label, func(ctx context.Context, _ io.Writer) (string, error) {
+		<-ctx.Done()
+		return "", ctx.Err()
+	})
+	return ctrl
+}
+
+func hasLevel(levels []string, want string) bool {
+	for _, level := range levels {
+		if level == want {
+			return true
+		}
+	}
+	return false
+}
+
+func hasCommand(cmds []CommandInfo, name string) bool {
+	for _, cmd := range cmds {
+		if cmd.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func hasDirEntry(entries []DirEntry, name string) bool {
+	for _, entry := range entries {
+		if entry.Name == name {
+			return true
+		}
+	}
+	return false
+}
+
+func TestSessionActionsWithoutControllerReturnError(t *testing.T) {
+	app := &App{tabs: map[string]*WorkspaceTab{}}
+	if err := app.NewSession(); err == nil {
+		t.Error("NewSession with no controller must surface an error, not silently no-op")
+	}
+	if err := app.ClearSession(); err == nil {
+		t.Error("ClearSession with no controller must surface an error")
+	}
+
+	app = &App{
+		tabs:        map[string]*WorkspaceTab{"t1": {ID: "t1", StartupErr: "boot exploded"}},
+		activeTabID: "t1",
+	}
+	err := app.NewSession()
+	if err == nil || !strings.Contains(err.Error(), "boot exploded") {
+		t.Errorf("error should carry the tab's startup failure, got %v", err)
+	}
+}
+
+// --- Prompt history scanning tests ------------------------------------------
+
+func identityPromptDisplay(text string) string { return text }
+
+// TestCollectPromptHistoryEntriesLegacyEvent verifies that the legacy event format
+// {"kind":"user.message","text":"..."} is correctly extracted.
+func TestCollectPromptHistoryEntriesLegacyEvent(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"hello world"}
+{"kind":"user.message","text":"second prompt"}
+{"kind":"model.final","content":"response"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "hello world" {
+		t.Errorf("expected 'hello world', got %q", entries[0].Text)
+	}
+	if entries[1].Text != "second prompt" {
+		t.Errorf("expected 'second prompt', got %q", entries[1].Text)
+	}
+	if entries[0].Turn != 0 || entries[1].Turn != 1 {
+		t.Errorf("expected turns 0,1; got %d,%d", entries[0].Turn, entries[1].Turn)
+	}
+	if entries[0].SessionPath != path {
+		t.Errorf("expected session path %q, got %q", path, entries[0].SessionPath)
+	}
+}
+
+// TestCollectPromptHistoryEntriesEarlyEvent verifies that the migrated legacy event
+// format {"type":"user.message","text":"..."} is correctly extracted.
+func TestCollectPromptHistoryEntriesEarlyEvent(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"type":"user.message","text":"v0 prompt"}
+{"type":"model.final","content":"response"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].Text != "v0 prompt" {
+		t.Errorf("expected 'v0 prompt', got %q", entries[0].Text)
+	}
+}
+
+// TestCollectPromptHistoryEntriesProviderMessage verifies that the current
+// provider.Message format {"role":"user","content":"..."} is correctly extracted.
+func TestCollectPromptHistoryEntriesProviderMessage(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello from provider"}
+{"role":"assistant","content":"response"}
+{"role":"user","content":"another prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "hello from provider" {
+		t.Errorf("expected 'hello from provider', got %q", entries[0].Text)
+	}
+	if entries[1].Text != "another prompt" {
+		t.Errorf("expected 'another prompt', got %q", entries[1].Text)
+	}
+}
+
+// TestCollectPromptHistoryEntriesMixedFormats verifies that both formats in the
+// same file are extracted.
+func TestCollectPromptHistoryEntriesMixedFormats(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy prompt"}
+{"role":"user","content":"modern prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "legacy prompt" {
+		t.Errorf("expected 'legacy prompt', got %q", entries[0].Text)
+	}
+	if entries[1].Text != "modern prompt" {
+		t.Errorf("expected 'modern prompt', got %q", entries[1].Text)
+	}
+}
+
+func TestCollectPromptHistoryEntriesReadsEventTime(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	rfcTime := time.Date(2026, 6, 14, 10, 30, 5, 6_000_000, time.UTC)
+	if err := os.WriteFile(path, []byte(`{"kind":"user.message","text":"legacy timed","time":1800000000123}
+{"role":"user","content":"modern timed","createdAt":`+strconv.Quote(rfcTime.Format(time.RFC3339Nano))+`}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	if entries[0].At != 1800000000123 {
+		t.Errorf("numeric event time = %d, want 1800000000123", entries[0].At)
+	}
+	if entries[1].At != rfcTime.UnixMilli() {
+		t.Errorf("RFC3339 event time = %d, want %d", entries[1].At, rfcTime.UnixMilli())
+	}
+}
+
+// TestCollectPromptHistoryEntriesUsesDisplayResolver verifies history recall uses
+// the user-visible prompt text, not the controller-expanded model input.
+func TestCollectPromptHistoryEntriesUsesDisplayResolver(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	expanded := "\nSaved memory\n\n\nvisible prompt"
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(expanded)+`}`+"\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := recordSessionDisplay(dir, path, expanded, "visible prompt"); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, sessionDisplayResolver(dir, path))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].Text != "visible prompt" {
+		t.Errorf("expected visible prompt, got %q", entries[0].Text)
+	}
+}
+
+func TestCollectPromptHistoryEntriesSkipsSyntheticMessages(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"role":"user","content":"Plan approved — plan mode is off"}
+{"role":"user","content":"real prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 {
+		t.Fatalf("expected 1 entry, got %d", len(entries))
+	}
+	if entries[0].Text != "real prompt" {
+		t.Errorf("expected real prompt, got %q", entries[0].Text)
+	}
+}
+
+// TestCollectPromptHistoryEntriesNoUserMessages verifies that a file with only
+// assistant/tool messages returns no entries.
+func TestCollectPromptHistoryEntriesNoUserMessages(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "session.jsonl")
+	if err := os.WriteFile(path, []byte(`{"kind":"model.final","content":"response"}
+{"kind":"tool.result","output":"done"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 0 {
+		t.Errorf("expected 0 entries, got %d", len(entries))
+	}
+}
+
+// TestCollectPromptHistoryEntriesEmptyFile verifies that an empty JSONL file
+// returns no entries without error.
+func TestCollectPromptHistoryEntriesEmptyFile(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "empty.jsonl")
+	if err := os.WriteFile(path, nil, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	info, err := os.Stat(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	entries, err := collectPromptHistoryEntries(path, info, identityPromptDisplay)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 0 {
+		t.Errorf("expected 0 entries, got %d", len(entries))
+	}
+}
+
+// TestScanPromptHistoryFromDir verifies that scanPromptHistoryFromDir scans
+// multiple JSONL files and returns prompts newest-first.
+func TestScanPromptHistoryFromDir(t *testing.T) {
+	app := &App{tabs: map[string]*WorkspaceTab{"t1": {ID: "t1", Ctrl: nil, WorkspaceRoot: ""}}}
+	_ = app
+
+	dir := t.TempDir()
+	// Write two session files with different mtimes (sleep to ensure ordering).
+	if err := os.WriteFile(filepath.Join(dir, "a.jsonl"), []byte(`{"role":"user","content":"older prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(10 * time.Millisecond)
+	if err := os.WriteFile(filepath.Join(dir, "b.jsonl"), []byte(`{"role":"user","content":"newer prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("expected 2 entries, got %d", len(entries))
+	}
+	// Newest-first: "newer prompt" should be first.
+	if entries[0].Text != "newer prompt" {
+		t.Errorf("expected 'newer prompt' first, got %q", entries[0].Text)
+	}
+	if entries[1].Text != "older prompt" {
+		t.Errorf("expected 'older prompt' second, got %q", entries[1].Text)
+	}
+}
+
+func TestScanPromptHistoryFromDirUsesSessionActivityBeforeEventInterleaving(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	early := filepath.Join(dir, "early.jsonl")
+	late := filepath.Join(dir, "late.jsonl")
+
+	if err := os.WriteFile(early, []byte(fmt.Sprintf(`{"role":"user","content":"early first","time":%d}
+{"role":"assistant","content":"ok"}
+{"role":"user","content":"early second","time":%d}
+`, base.UnixMilli(), base.Add(time.Minute).UnixMilli())), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(late, []byte(fmt.Sprintf(`{"role":"user","content":"late newest","time":%d}
+`, base.Add(2*time.Minute).UnixMilli())), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	// Invert file mtimes: session activity should keep each session grouped
+	// before event timestamps are considered within that session.
+	if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 3 {
+		t.Fatalf("expected 3 entries, got %d", len(entries))
+	}
+	want := []string{"early second", "early first", "late newest"}
+	for i, w := range want {
+		if entries[i].Text != w {
+			t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryFromDirUsesBranchMetaActivityFallback(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	early := filepath.Join(dir, "early.jsonl")
+	late := filepath.Join(dir, "late.jsonl")
+
+	if err := os.WriteFile(early, []byte(`{"role":"user","content":"early first"}
+{"role":"assistant","content":"ok"}
+{"role":"user","content":"early second"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(late, []byte(`{"role":"user","content":"late newest"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(early, agent.BranchMeta{
+		CreatedAt: base,
+		UpdatedAt: base.Add(time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(late, agent.BranchMeta{
+		CreatedAt: base.Add(time.Minute),
+		UpdatedAt: base.Add(2 * time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+	// Invert file mtimes: branch UpdatedAt should be the activity clock.
+	if err := os.Chtimes(early, base.Add(3*time.Hour), base.Add(3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Chtimes(late, base.Add(-3*time.Hour), base.Add(-3*time.Hour)); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 3 {
+		t.Fatalf("expected 3 entries, got %d", len(entries))
+	}
+	want := []string{"late newest", "early second", "early first"}
+	for i, w := range want {
+		if entries[i].Text != w {
+			t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, entries[i].Text, w, entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryFromDirSkipsEmptyOrderedSessions(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	base := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	empty := filepath.Join(dir, "empty.jsonl")
+	real := filepath.Join(dir, "real.jsonl")
+
+	if err := os.WriteFile(empty, nil, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(real, []byte(`{"role":"user","content":"real prompt"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(empty, agent.BranchMeta{
+		CreatedAt: base,
+		UpdatedAt: base.Add(time.Hour),
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(real, agent.BranchMeta{
+		CreatedAt: base,
+		UpdatedAt: base,
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 1 || entries[0].Text != "real prompt" {
+		t.Fatalf("entries = %+v, want only real prompt after skipping empty session", entries)
+	}
+}
+
+func TestScanPromptHistoryUsesCurrentSessionBeforeCrossSession(t *testing.T) {
+	dir := t.TempDir()
+	current := filepath.Join(dir, "current.jsonl")
+	other := filepath.Join(dir, "other.jsonl")
+	if err := os.WriteFile(current, []byte(`{"role":"user","content":"current first"}
+{"role":"assistant","content":"ok"}
+{"role":"user","content":"current second"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
+		CreatedAt: now,
+		UpdatedAt: now,
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
+		CreatedAt: now.Add(time.Minute),
+		UpdatedAt: now.Add(time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
+	defer ctrl.Close()
+	app.setTestCtrl(ctrl, "")
+
+	result, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(result.Entries) != 3 {
+		t.Fatalf("expected current-session entries followed by cross-session fallback, got %d: %+v", len(result.Entries), result.Entries)
+	}
+	want := []string{"current second", "current first", "other newest"}
+	for i, w := range want {
+		if result.Entries[i].Text != w {
+			t.Fatalf("entries[%d] = %q, want %q; all=%+v", i, result.Entries[i].Text, w, result.Entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryPaginatesCurrentSessionBeforeCrossSession(t *testing.T) {
+	dir := t.TempDir()
+	current := filepath.Join(dir, "current.jsonl")
+	other := filepath.Join(dir, "other.jsonl")
+	var lines []byte
+	for i := range 55 {
+		lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"current %d"}
+`, i))...)
+	}
+	if err := os.WriteFile(current, lines, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(other, []byte(`{"role":"user","content":"other newest"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	now := time.Date(2026, 6, 14, 8, 0, 0, 0, time.UTC)
+	if err := agent.SaveBranchMetaPreserveUpdated(current, agent.BranchMeta{
+		CreatedAt: now,
+		UpdatedAt: now,
+	}); err != nil {
+		t.Fatal(err)
+	}
+	if err := agent.SaveBranchMetaPreserveUpdated(other, agent.BranchMeta{
+		CreatedAt: now.Add(time.Minute),
+		UpdatedAt: now.Add(time.Minute),
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrl := control.New(control.Options{SessionDir: dir, SessionPath: current, Label: "test"})
+	defer ctrl.Close()
+	app.setTestCtrl(ctrl, "")
+
+	result, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(result.Entries) != promptHistoryPageLimit {
+		t.Fatalf("expected %d entries, got %d", promptHistoryPageLimit, len(result.Entries))
+	}
+	if result.Entries[0].Text != "current 54" {
+		t.Fatalf("first entry = %q, want current 54", result.Entries[0].Text)
+	}
+	if result.Entries[len(result.Entries)-1].Text != "current 5" {
+		t.Fatalf("last first-page entry = %q, want current 5", result.Entries[len(result.Entries)-1].Text)
+	}
+	if !result.HasOlder || result.OlderCursor == "" {
+		t.Fatalf("first page should expose an older cursor: %+v", result)
+	}
+	for _, entry := range result.Entries {
+		if entry.Text == "other newest" {
+			t.Fatalf("cross-session entry appeared before current-session page was exhausted: %+v", result.Entries)
+		}
+	}
+
+	nextRequest, err := json.Marshal(promptHistoryRequest{Cursor: result.OlderCursor})
+	if err != nil {
+		t.Fatal(err)
+	}
+	next, err := app.ScanPromptHistory(string(nextRequest))
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []string{"current 4", "current 3", "current 2", "current 1", "current 0", "other newest"}
+	if len(next.Entries) != len(want) {
+		t.Fatalf("second page entries = %+v, want %d entries", next.Entries, len(want))
+	}
+	for i, w := range want {
+		if next.Entries[i].Text != w {
+			t.Fatalf("second page entries[%d] = %q, want %q; all=%+v", i, next.Entries[i].Text, w, next.Entries)
+		}
+	}
+}
+
+func TestScanPromptHistoryFromDirReadsAllEntriesForInternalHelper(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	var lines []byte
+	for i := range 250 {
+		lines = append(lines, []byte(fmt.Sprintf(`{"role":"user","content":"prompt %d"}
+`, i))...)
+	}
+	if err := os.WriteFile(filepath.Join(dir, "many.jsonl"), lines, 0o644); err != nil {
+		t.Fatal(err)
+	}
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 250 {
+		t.Fatalf("expected 250 entries, got %d", len(entries))
+	}
+	if entries[0].Text != "prompt 249" {
+		t.Errorf("expected newest 'prompt 249' first, got %q", entries[0].Text)
+	}
+}
+
+// TestScanPromptHistoryFromDirEmpty verifies an empty directory returns nil.
+func TestScanPromptHistoryFromDirEmpty(t *testing.T) {
+	app := &App{}
+	dir := t.TempDir()
+	entries, err := app.scanPromptHistoryFromDir(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(entries) != 0 {
+		t.Errorf("expected 0 entries, got %d", len(entries))
+	}
+}
+
+// TestScanPromptHistoryCacheHit verifies that ScanPromptHistory returns nil
+// on cache hit (nonce matches).
+func TestScanPromptHistoryCacheHit(t *testing.T) {
+	app := &App{tabs: map[string]*WorkspaceTab{}}
+	result, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	nonce := result.Nonce
+	if nonce == "" {
+		t.Error("expected a non-empty nonce on first call")
+	}
+
+	// Second call with the same nonce should be a cache hit (nil entries).
+	result2, err := app.ScanPromptHistory(nonce)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result2.Entries != nil {
+		t.Error("expected nil entries on cache hit")
+	}
+	if result2.Nonce != nonce {
+		t.Errorf("expected nonce %q unchanged, got %q", nonce, result2.Nonce)
+	}
+}
+
+func TestScanPromptHistoryCacheIsScopedBySessionDir(t *testing.T) {
+	dirA := t.TempDir()
+	dirB := t.TempDir()
+	pathA := filepath.Join(dirA, "a.jsonl")
+	pathB := filepath.Join(dirB, "b.jsonl")
+	if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"workspace A"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"workspace B"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrlA := control.New(control.Options{SessionDir: dirA, SessionPath: pathA, Label: "test"})
+	ctrlB := control.New(control.Options{SessionDir: dirB, SessionPath: pathB, Label: "test"})
+	defer ctrlA.Close()
+	defer ctrlB.Close()
+
+	app.setTestCtrl(ctrlA, "")
+	first, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(first.Entries) != 1 || first.Entries[0].Text != "workspace A" {
+		t.Fatalf("first entries = %+v, want workspace A", first.Entries)
+	}
+
+	app.setTestCtrl(ctrlB, "")
+	second, err := app.ScanPromptHistory(first.Nonce)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.Entries == nil {
+		t.Fatal("expected rescan after session dir changes, got cache hit")
+	}
+	if len(second.Entries) != 1 || second.Entries[0].Text != "workspace B" {
+		t.Fatalf("second entries = %+v, want workspace B", second.Entries)
+	}
+}
+
+func TestScanPromptHistoryCacheIsScopedBySessionPath(t *testing.T) {
+	dir := t.TempDir()
+	pathA := filepath.Join(dir, "a.jsonl")
+	pathB := filepath.Join(dir, "b.jsonl")
+	if err := os.WriteFile(pathA, []byte(`{"role":"user","content":"session A"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(pathB, []byte(`{"role":"user","content":"session B"}
+`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	app := NewApp()
+	ctrlA := control.New(control.Options{SessionDir: dir, SessionPath: pathA, Label: "test"})
+	ctrlB := control.New(control.Options{SessionDir: dir, SessionPath: pathB, Label: "test"})
+	defer ctrlA.Close()
+	defer ctrlB.Close()
+
+	app.setTestCtrl(ctrlA, "")
+	first, err := app.ScanPromptHistory("")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(first.Entries) != 2 || first.Entries[0].Text != "session A" || first.Entries[1].Text != "session B" {
+		t.Fatalf("first entries = %+v, want session A followed by session B", first.Entries)
+	}
+
+	app.setTestCtrl(ctrlB, "")
+	second, err := app.ScanPromptHistory(first.Nonce)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if second.Entries == nil {
+		t.Fatal("expected rescan after session path changes, got cache hit")
+	}
+	if len(second.Entries) != 2 || second.Entries[0].Text != "session B" || second.Entries[1].Text != "session A" {
+		t.Fatalf("second entries = %+v, want session B followed by session A", second.Entries)
+	}
+}
diff --git a/desktop/appicon_asset_test.go b/desktop/appicon_asset_test.go
new file mode 100644
index 0000000..0f2027e
--- /dev/null
+++ b/desktop/appicon_asset_test.go
@@ -0,0 +1,183 @@
+package main
+
+import (
+	"bytes"
+	"encoding/binary"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"os"
+	"testing"
+)
+
+func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
+	f, err := os.Open("build/appicon.png")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+
+	img, err := png.Decode(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assertFullCanvasRoundedIcon(t, img, 1024)
+}
+
+func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
+	for _, size := range []int{16, 24, 32, 48, 64, 256} {
+		t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
+			img := decodeICOImage(t, "build/windows/icon.ico", size)
+			assertFullCanvasRoundedIcon(t, img, size)
+		})
+	}
+}
+
+func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
+	t.Helper()
+
+	bounds := img.Bounds()
+	if bounds.Dx() != size || bounds.Dy() != size {
+		t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
+	}
+
+	corners := []struct {
+		name string
+		x    int
+		y    int
+	}{
+		{"top-left", bounds.Min.X, bounds.Min.Y},
+		{"top-right", bounds.Max.X - 1, bounds.Min.Y},
+		{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
+		{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
+	}
+	for _, corner := range corners {
+		_, _, _, a := img.At(corner.x, corner.y).RGBA()
+		if a > 0xff {
+			t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
+		}
+	}
+
+	_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
+	if centerAlpha == 0 {
+		t.Fatal("app icon center must contain visible artwork")
+	}
+
+	edgePoints := []struct {
+		name string
+		x    int
+		y    int
+	}{
+		{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
+		{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
+		{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
+		{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
+	}
+	for _, point := range edgePoints {
+		_, _, _, a := img.At(point.x, point.y).RGBA()
+		if a == 0 {
+			t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
+		}
+		assertReasonixBlue(t, point.name, img.At(point.x, point.y))
+	}
+}
+
+func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
+	t.Helper()
+
+	r16, g16, b16, _ := colorValue.RGBA()
+	r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
+	if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
+		t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
+	}
+}
+
+func near(got, want uint8, tolerance uint8) bool {
+	if got > want {
+		return got-want <= tolerance
+	}
+	return want-got <= tolerance
+}
+
+func decodeICOImage(t *testing.T, path string, size int) image.Image {
+	t.Helper()
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	r := bytes.NewReader(data)
+
+	var header struct {
+		Reserved uint16
+		Type     uint16
+		Count    uint16
+	}
+	if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
+		t.Fatal(err)
+	}
+	if header.Reserved != 0 || header.Type != 1 {
+		t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
+	}
+
+	type iconEntry struct {
+		Width       uint8
+		Height      uint8
+		ColorCount  uint8
+		Reserved    uint8
+		Planes      uint16
+		BitCount    uint16
+		BytesInRes  uint32
+		ImageOffset uint32
+	}
+
+	entries := make([]iconEntry, header.Count)
+	for i := range entries {
+		if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
+	targetIndex := -1
+	for i, entry := range entries {
+		width := int(entry.Width)
+		height := int(entry.Height)
+		if width == 0 {
+			width = 256
+		}
+		if height == 0 {
+			height = 256
+		}
+		if width != height {
+			t.Fatalf("ICO image must be square, got %dx%d", width, height)
+		}
+		if _, ok := expectedSizes[width]; ok {
+			expectedSizes[width] = true
+		}
+		if width == size {
+			targetIndex = i
+		}
+	}
+	for expectedSize, found := range expectedSizes {
+		if !found {
+			t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
+		}
+	}
+	if targetIndex < 0 {
+		t.Fatalf("ICO is missing %dx%d image", size, size)
+	}
+
+	entry := entries[targetIndex]
+	end := int(entry.ImageOffset + entry.BytesInRes)
+	if end > len(data) {
+		t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
+	}
+	img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
+	if err != nil {
+		t.Fatal(err)
+	}
+	return img
+}
diff --git a/desktop/attach_dropped_test.go b/desktop/attach_dropped_test.go
new file mode 100644
index 0000000..9c3cbc2
--- /dev/null
+++ b/desktop/attach_dropped_test.go
@@ -0,0 +1,427 @@
+package main
+
+import (
+	"context"
+	"encoding/base64"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+)
+
+const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+
+func TestWorkspaceRelativeIn(t *testing.T) {
+	root := t.TempDir()
+
+	if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
+		t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
+	}
+	if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
+		t.Fatal("a path above the workspace must not resolve as in-tree")
+	}
+}
+
+func TestIsImageExt(t *testing.T) {
+	for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
+		if !isImageExt(p) {
+			t.Errorf("%q should be an image extension", p)
+		}
+	}
+	for _, p := range []string{"notes.pdf", "main.go", "noext"} {
+		if isImageExt(p) {
+			t.Errorf("%q should not be an image extension", p)
+		}
+	}
+}
+
+func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectRoot := t.TempDir()
+	if err := os.Chdir(projectRoot); err != nil {
+		t.Fatal(err)
+	}
+	projectRoot, _ = os.Getwd()
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: projectRoot},
+		},
+		activeTabID: "project",
+	}
+
+	got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
+	if err != nil {
+		t.Fatalf("SavePastedImage: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
+		t.Fatalf("pasted image should be saved under active workspace: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
+		t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
+	}
+	preview, err := app.AttachmentDataURL(got)
+	if err != nil {
+		t.Fatalf("AttachmentDataURL: %v", err)
+	}
+	if !strings.HasPrefix(preview, "data:image/png;base64,") {
+		t.Fatalf("preview = %q, want png data URL", preview)
+	}
+}
+
+func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	sessionDirA := desktopSessionDir(projectA)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
+		},
+		activeTabID: "project",
+	}
+
+	got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
+	if err != nil {
+		t.Fatalf("SavePastedImage: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
+		t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
+		t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
+	}
+	if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
+		t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
+	}
+}
+
+func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectRoot := t.TempDir()
+	if err := os.Chdir(projectRoot); err != nil {
+		t.Fatal(err)
+	}
+	projectRoot, _ = os.Getwd()
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: projectRoot},
+		},
+		activeTabID: "project",
+	}
+	if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	target := filepath.Join(projectRoot, "sub", "notes.txt")
+	if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := app.AttachDropped(target)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
+		t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
+	}
+}
+
+func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	launchRoot := t.TempDir()
+	projectRoot := t.TempDir()
+	if err := os.Chdir(launchRoot); err != nil {
+		t.Fatal(err)
+	}
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: projectRoot},
+		},
+		activeTabID: "project",
+	}
+	raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
+	if err != nil {
+		t.Fatal(err)
+	}
+	outside := filepath.Join(t.TempDir(), "shot.png")
+	if err := os.WriteFile(outside, raw, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := app.AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
+		t.Fatalf("got %+v, want png attachment", got)
+	}
+	if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
+		t.Fatalf("dropped image should be saved under active workspace: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
+		t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
+	}
+	if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
+		t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
+	}
+}
+
+func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	root := t.TempDir()
+	if err := os.Chdir(root); err != nil {
+		t.Fatal(err)
+	}
+	cwd, _ := os.Getwd()
+	if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	target := filepath.Join(cwd, "sub", "notes.txt")
+	if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := (&App{}).AttachDropped(target)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
+		t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
+	}
+}
+
+func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	outside := filepath.Join(t.TempDir(), "report.pdf")
+	if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	root := t.TempDir()
+	if err := os.Chdir(root); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := (&App{}).AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
+		t.Fatalf("got %+v, want copied pdf attachment", got)
+	}
+}
+
+func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	root := t.TempDir()
+	if err := os.Chdir(root); err != nil {
+		t.Fatal(err)
+	}
+	cwd, _ := os.Getwd()
+	png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
+	if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
+		t.Fatalf("got %+v, want png attachment", got)
+	}
+	if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
+		t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
+	}
+}
+
+func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	workspace := t.TempDir()
+	outside := filepath.Join(t.TempDir(), "Folder With Spaces")
+	if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	expectedOutside := outside
+	if resolved, err := filepath.EvalSymlinks(outside); err == nil {
+		expectedOutside = resolved
+	}
+	expectedDisplayPath := filepath.ToSlash(expectedOutside)
+	if err := os.Chdir(workspace); err != nil {
+		t.Fatal(err)
+	}
+
+	ctrl := control.New(control.Options{WorkspaceRoot: workspace})
+	app := &App{
+		tabs: map[string]*WorkspaceTab{
+			"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
+		},
+		activeTabID: "project",
+	}
+
+	got, err := app.AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if got.Kind != "workspace" || !got.IsDir {
+		t.Fatalf("got %+v, want workspace directory ref", got)
+	}
+	if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
+		t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
+	}
+	if got.DisplayPath != expectedDisplayPath {
+		t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
+	}
+
+	block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
+	if len(errs) != 0 {
+		t.Fatalf("ResolveScopedRefs errors = %v", errs)
+	}
+	if !strings.Contains(block, ``) ||
+		!strings.Contains(block, "sub/") ||
+		!strings.Contains(block, "sub/notes.txt") {
+		t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
+	}
+}
+
+func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
+	cfg := config.Default()
+	cfg.DefaultModel = "test/test-model"
+	cfg.Desktop.ProviderAccess = []string{"test"}
+	cfg.Providers = []config.ProviderEntry{
+		{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
+	}
+	if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save config: %v", err)
+	}
+	orig, _ := os.Getwd()
+	defer os.Chdir(orig)
+
+	projectA := t.TempDir()
+	projectB := t.TempDir()
+	outside := filepath.Join(t.TempDir(), "External")
+	if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	if err := addProject(projectA, "Project A"); err != nil {
+		t.Fatalf("add project A: %v", err)
+	}
+	if err := addProject(projectB, "Project B"); err != nil {
+		t.Fatalf("add project B: %v", err)
+	}
+	sessionDirA := desktopSessionDir(projectA)
+	sessionDirB := desktopSessionDir(projectB)
+	if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
+		t.Fatalf("mkdir project A sessions: %v", err)
+	}
+	if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
+		t.Fatalf("mkdir project B sessions: %v", err)
+	}
+	sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
+	sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
+	oldCtrl := control.New(control.Options{
+		SessionDir:    sessionDirB,
+		SessionPath:   sessionPathB,
+		WorkspaceRoot: projectB,
+		Sink:          event.Discard,
+	})
+	app := NewApp()
+	app.readyHook = func() {}
+	tab := &WorkspaceTab{
+		ID:            "project",
+		Scope:         "project",
+		WorkspaceRoot: projectB,
+		TopicID:       "topic_external_ref",
+		TopicTitle:    "External ref",
+		SessionPath:   sessionPathA,
+		Ready:         true,
+		model:         "test/test-model",
+		Ctrl:          oldCtrl,
+		sink:          &tabEventSink{tabID: "project", app: app},
+		disabledMCP:   map[string]ServerView{},
+	}
+	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
+	app.tabOrder = []string{tab.ID}
+	app.activeTabID = tab.ID
+	t.Cleanup(func() {
+		if tab.Ctrl != nil {
+			tab.Ctrl.Close()
+		}
+	})
+
+	got, err := app.AttachDropped(outside)
+	if err != nil {
+		t.Fatalf("AttachDropped: %v", err)
+	}
+	if tab.Ctrl == oldCtrl {
+		t.Fatal("stale controller was reused for external folder ref")
+	}
+	if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
+		t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
+	}
+	resolver, ok := tab.Ctrl.(interface {
+		ResolveScopedRefs(context.Context, string) (string, []string)
+	})
+	if !ok {
+		t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
+	}
+	block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
+	if len(errs) != 0 {
+		t.Fatalf("ResolveScopedRefs errors = %v", errs)
+	}
+	if !strings.Contains(block, "sub/notes.txt") {
+		t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
+	}
+}
diff --git a/desktop/autosave_warn_test.go b/desktop/autosave_warn_test.go
new file mode 100644
index 0000000..03a20b1
--- /dev/null
+++ b/desktop/autosave_warn_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+	"errors"
+	"testing"
+	"time"
+)
+
+func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
+	app := NewApp()
+	tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
+	failure := errors.New("disk unhappy")
+
+	app.reportTabSnapshotError(tab, "autosave", failure)
+	tab.saveMu.Lock()
+	first := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if first.IsZero() {
+		t.Fatal("first autosave warning did not record its timestamp")
+	}
+
+	// Within the window: suppressed, timestamp untouched.
+	app.reportTabSnapshotError(tab, "autosave", failure)
+	tab.saveMu.Lock()
+	second := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if !second.Equal(first) {
+		t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
+	}
+
+	// Window expired: warns again.
+	tab.saveMu.Lock()
+	tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
+	tab.saveMu.Unlock()
+	app.reportTabSnapshotError(tab, "autosave", failure)
+	tab.saveMu.Lock()
+	third := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if !third.After(first) {
+		t.Fatal("expired window did not re-arm the autosave warning")
+	}
+
+	// Explicit user actions are never debounced (state untouched).
+	app.reportTabSnapshotError(tab, "changing model", failure)
+	tab.saveMu.Lock()
+	fourth := tab.lastAutosaveWarnAt
+	tab.saveMu.Unlock()
+	if !fourth.Equal(third) {
+		t.Fatal("action-save warning must not consume the autosave debounce window")
+	}
+}
diff --git a/desktop/bot_bridge.go b/desktop/bot_bridge.go
new file mode 100644
index 0000000..b1ed5fc
--- /dev/null
+++ b/desktop/bot_bridge.go
@@ -0,0 +1,707 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+// errDriveBusy signals that a takeover drive could not start because the target
+// controller was already running a turn. The hub translates it into a
+// user-facing "session busy" message rather than a generic drive failure.
+var errDriveBusy = errors.New("desktop session busy")
+
+// botBridgeHub 是 bot 网关对桌面端的"上帝视角"桥(bot.DesktopBridge 的实现)。
+//
+// 职责边界(刻意保持窄):
+//   - 观察:tabEventSink.Emit 把每个桌面会话的事件旁路到 observe;hub 记录
+//     待审批/待回答项,并把审批请求、任务完成/出错推送给订阅聊天。
+//   - 遥控审批:/desktop approve|deny|answer 经 App.ApproveTab /
+//     AnswerQuestionForTab 按 tab 寻址回写。controller 侧幂等(先到者赢),
+//     桌面 UI 与远端并发应答互不干扰。
+//   - 不做:向桌面会话注入输入、抢占写 lease。将来的显式接管在
+//     bot.DesktopBridge 上扩展,底层走 internal/control 的接管端口。
+//
+// observe 跑在 controller 的事件 goroutine 上,绝不能做网络调用——通知统一
+// 进有界队列由 worker 异步发送,队列满时丢弃并告警。
+type botBridgeHub struct {
+	sessions   func() []bot.DesktopSessionInfo
+	approveTab func(tabID, id string, allow, session, persist bool)
+	answerTab  func(tabID, id string, answers []QuestionAnswer)
+	notify     func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
+	// drive 把一条远程文本提交为 tab 的新 turn,并把该 turn 的输出转发回 route。
+	drive func(tabID, text string, route bot.DesktopWatchRoute) error
+	// announce 往会话 transcript 里发一条 Notice,让桌面用户看到接管状态变化。
+	announce func(tabID, text string)
+	// persistWatchers 把订阅全集回写用户配置(bot.desktop_watchers)。
+	persistWatchers func(routes []bot.DesktopWatchRoute) error
+	// takeoverChanged 通知桌面前端刷新(TabMeta.RemoteControlled 变化)。
+	takeoverChanged func()
+	logger          *slog.Logger
+
+	mu       sync.Mutex
+	watchers map[string]bot.DesktopWatchRoute
+	pending  map[string]desktopPendingPrompt
+	// takeovers: tabID -> 驾驶该会话的聊天路由;takeoverTabs: routeKey -> tabID。
+	takeovers    map[string]bot.DesktopWatchRoute
+	takeoverTabs map[string]string
+	// watchSeq 单调递增,标记订阅快照的新旧;persist 时用它丢弃过期写入。
+	watchSeq uint64
+	// watchPersistDirty keeps a failed local mutation authoritative in memory;
+	// a later runtime refresh must not silently restore the older disk snapshot.
+	watchPersistDirty bool
+
+	// persistMu 串行化订阅落盘,并保证只写最新快照(见 SetWatch)。
+	persistMu      sync.Mutex
+	lastPersistSeq uint64
+
+	queue chan desktopBridgeNotification
+}
+
+type desktopPendingPrompt struct {
+	tabID     string
+	kind      string // "approval" | "ask"
+	tool      string
+	subject   string
+	questions []event.AskQuestion
+}
+
+// desktopBridgeNotification 是一条待推送的桌面事件。text 与 card 都按订阅路由
+// 现做,因此群聊(多用户)与私聊可给出不同详略——命令行/错误详情只发私聊,群里
+// 只给摘要。route 非 nil 时定向发给该聊天(不看 watch 订阅),用于接管收回等必达通知。
+type desktopBridgeNotification struct {
+	text  func(route bot.DesktopWatchRoute) string
+	card  func(route bot.DesktopWatchRoute) *bot.InteractiveCard
+	route *bot.DesktopWatchRoute
+}
+
+// isSharedChat 判断一个聊天是否为多用户场景(群/话题群/服务器频道)。私聊/单聊
+// 只有操作者本人,可安全展示命令行等敏感详情。
+func isSharedChat(ct bot.ChatType) bool {
+	return ct != bot.ChatDM && ct != bot.ChatDirect
+}
+
+func constText(s string) func(bot.DesktopWatchRoute) string {
+	return func(bot.DesktopWatchRoute) string { return s }
+}
+
+const (
+	botBridgeQueueSize     = 64
+	botBridgeSendTimeout   = 15 * time.Second
+	botBridgeSubjectLimit  = 200
+	botBridgePendingLimit  = 200
+	botBridgeErrTextLimit  = 300
+	botBridgePromptPreview = 500
+)
+
+// botBridgeDeps 打包 hub 对宿主(App)的全部依赖,便于测试注入。
+type botBridgeDeps struct {
+	sessions        func() []bot.DesktopSessionInfo
+	approveTab      func(tabID, id string, allow, session, persist bool)
+	answerTab       func(tabID, id string, answers []QuestionAnswer)
+	notify          func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
+	drive           func(tabID, text string, route bot.DesktopWatchRoute) error
+	announce        func(tabID, text string)
+	persistWatchers func(routes []bot.DesktopWatchRoute) error
+	takeoverChanged func()
+	logger          *slog.Logger
+}
+
+func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
+	logger := deps.logger
+	if logger == nil {
+		logger = slog.Default()
+	}
+	h := &botBridgeHub{
+		sessions:        deps.sessions,
+		approveTab:      deps.approveTab,
+		answerTab:       deps.answerTab,
+		notify:          deps.notify,
+		drive:           deps.drive,
+		announce:        deps.announce,
+		persistWatchers: deps.persistWatchers,
+		takeoverChanged: deps.takeoverChanged,
+		logger:          logger.With("component", "bot_bridge"),
+		watchers:        make(map[string]bot.DesktopWatchRoute),
+		pending:         make(map[string]desktopPendingPrompt),
+		takeovers:       make(map[string]bot.DesktopWatchRoute),
+		takeoverTabs:    make(map[string]string),
+		queue:           make(chan desktopBridgeNotification, botBridgeQueueSize),
+	}
+	go h.run()
+	return h
+}
+
+// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
+// 只做内存记账和入队,不做任何阻塞调用。
+func (h *botBridgeHub) observe(tabID string, e event.Event) {
+	switch e.Kind {
+	case event.ApprovalRequest:
+		h.mu.Lock()
+		h.rememberPendingLocked(e.Approval.ID, desktopPendingPrompt{
+			tabID:   tabID,
+			kind:    "approval",
+			tool:    e.Approval.Tool,
+			subject: truncateForBridge(e.Approval.Subject, botBridgeSubjectLimit),
+		})
+		watching := len(h.watchers) > 0
+		h.mu.Unlock()
+		if watching {
+			h.enqueue(h.approvalNotification(tabID, e.Approval))
+		}
+	case event.AskRequest:
+		h.mu.Lock()
+		h.rememberPendingLocked(e.Ask.ID, desktopPendingPrompt{
+			tabID:     tabID,
+			kind:      "ask",
+			questions: e.Ask.Questions,
+		})
+		watching := len(h.watchers) > 0
+		h.mu.Unlock()
+		if watching {
+			h.enqueue(h.askNotification(tabID, e.Ask))
+		}
+	case event.TurnDone:
+		h.mu.Lock()
+		for id, p := range h.pending {
+			if p.tabID == tabID {
+				delete(h.pending, id)
+			}
+		}
+		watching := len(h.watchers) > 0
+		h.mu.Unlock()
+		if !watching {
+			return
+		}
+		if e.Err != nil && strings.Contains(e.Err.Error(), "context canceled") {
+			// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
+			return
+		}
+		h.enqueue(h.turnDoneNotification(tabID, e.Err))
+	}
+}
+
+// rememberPendingLocked 记录待处理项;容量兜底防泄漏(正常路径 TurnDone 会清理)。
+func (h *botBridgeHub) rememberPendingLocked(id string, p desktopPendingPrompt) {
+	if strings.TrimSpace(id) == "" {
+		return
+	}
+	if len(h.pending) >= botBridgePendingLimit {
+		h.pending = make(map[string]desktopPendingPrompt)
+	}
+	h.pending[id] = p
+}
+
+func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
+	select {
+	case h.queue <- n:
+	default:
+		h.logger.Warn("desktop bridge notification queue full; dropping")
+	}
+}
+
+func (h *botBridgeHub) run() {
+	for n := range h.queue {
+		h.deliver(n)
+	}
+}
+
+func (h *botBridgeHub) deliver(n desktopBridgeNotification) {
+	h.mu.Lock()
+	var routes []bot.DesktopWatchRoute
+	if n.route != nil {
+		routes = []bot.DesktopWatchRoute{*n.route}
+	} else {
+		routes = h.watcherRoutesLocked()
+	}
+	notify := h.notify
+	h.mu.Unlock()
+	if notify == nil || len(routes) == 0 {
+		return
+	}
+	// Fan out per route: a single slow/hung connection must not hold the queue
+	// worker for its full timeout and back-pressure everyone else's approvals.
+	var wg sync.WaitGroup
+	for _, route := range routes {
+		wg.Add(1)
+		go func(route bot.DesktopWatchRoute) {
+			defer wg.Done()
+			msg := bot.OutboundMessage{
+				ChatID:   route.ChatID,
+				ChatType: route.ChatType,
+			}
+			if n.text != nil {
+				msg.Text = n.text(route)
+			}
+			if n.card != nil {
+				msg.Card = n.card(route)
+			}
+			ctx, cancel := context.WithTimeout(context.Background(), botBridgeSendTimeout)
+			defer cancel()
+			if _, err := notify(ctx, route.ConnectionID, route.Domain, msg); err != nil {
+				h.logger.Warn("desktop bridge notification send failed", "platform", route.Platform, "err", err)
+			}
+		}(route)
+	}
+	wg.Wait()
+}
+
+// tabLabel 把 tabID 解析成人类可读的会话名。
+func (h *botBridgeHub) tabLabel(tabID string) string {
+	if s, ok := h.sessionByTabID(tabID); ok {
+		if label := strings.TrimSpace(s.Label); label != "" {
+			return label
+		}
+		if title := strings.TrimSpace(s.Topic); title != "" {
+			return title
+		}
+	}
+	return "(未命名会话)"
+}
+
+func (h *botBridgeHub) sessionByTabID(tabID string) (bot.DesktopSessionInfo, bool) {
+	if h.sessions == nil {
+		return bot.DesktopSessionInfo{}, false
+	}
+	for _, s := range h.sessions() {
+		if s.TabID == tabID {
+			return s, true
+		}
+	}
+	return bot.DesktopSessionInfo{}, false
+}
+
+func (h *botBridgeHub) approvalNotification(tabID string, approval event.Approval) desktopBridgeNotification {
+	label := h.tabLabel(tabID)
+	// The approval subject is the pending command line; only reveal it in a
+	// private chat. In a shared chat show the tool name and point the operator
+	// to the desktop / a DM instead of leaking the command to the whole group.
+	subjectFor := func(route bot.DesktopWatchRoute) string {
+		if isSharedChat(route.ChatType) {
+			return "(命令详情仅在桌面端或私聊显示)"
+		}
+		return truncateForBridge(approval.Subject, botBridgeSubjectLimit)
+	}
+	return desktopBridgeNotification{
+		text: func(route bot.DesktopWatchRoute) string {
+			return fmt.Sprintf("⚠️ 桌面会话「%s」需要批准操作\n工具: %s\n操作: %s\n\nID: `%s`\n用 /desktop approve %s 批准,/desktop deny %s 拒绝。桌面端先处理则以先到者为准。",
+				label, approval.Tool, subjectFor(route), approval.ID, approval.ID, approval.ID)
+		},
+		card: func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
+			return &bot.InteractiveCard{
+				Header: "桌面会话需要批准",
+				Elements: []bot.InteractiveCardElement{
+					{Tag: "markdown", Content: fmt.Sprintf("**会话**: %s\n\n**工具**: %s\n\n**操作**: %s\n\nID: `%s`", label, approval.Tool, subjectFor(route), approval.ID)},
+					{Tag: "action", Extra: map[string]any{
+						"actions": []map[string]any{
+							desktopCardButton("允许一次", "primary", "/desktop approve "+approval.ID, route),
+							desktopCardButton("拒绝", "danger", "/desktop deny "+approval.ID, route),
+						},
+					}},
+				},
+			}
+		},
+	}
+}
+
+func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridgeNotification {
+	label := h.tabLabel(tabID)
+	var b strings.Builder
+	fmt.Fprintf(&b, "❓ 桌面会话「%s」在等待回答:\n", label)
+	for i, q := range ask.Questions {
+		fmt.Fprintf(&b, "\n**%d. %s**\n", i+1, truncateForBridge(q.Prompt, botBridgePromptPreview))
+		for j, opt := range q.Options {
+			fmt.Fprintf(&b, "  %d. %s\n", j+1, opt.Label)
+		}
+	}
+	fmt.Fprintf(&b, "\nID: `%s`\n用 /desktop answer %s <选项编号或文本> 回答;桌面端先处理则以先到者为准。", ask.ID, ask.ID)
+	privateText := b.String()
+	sharedText := fmt.Sprintf("❓ 桌面会话「%s」正在等待回答(问题详情仅在桌面端或私聊显示)。\n\nID: `%s`", label, ask.ID)
+	textFor := func(route bot.DesktopWatchRoute) string {
+		if isSharedChat(route.ChatType) {
+			return sharedText
+		}
+		return privateText
+	}
+
+	var card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
+	if len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0 {
+		options := ask.Questions[0].Options
+		card = func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
+			if isSharedChat(route.ChatType) {
+				return nil
+			}
+			actions := make([]map[string]any, 0, len(options))
+			for i, opt := range options {
+				optLabel := strings.TrimSpace(opt.Label)
+				if optLabel == "" {
+					optLabel = fmt.Sprintf("选项 %d", i+1)
+				}
+				actions = append(actions, desktopCardButton(optLabel, "primary", fmt.Sprintf("/desktop answer %s %d", ask.ID, i+1), route))
+			}
+			return &bot.InteractiveCard{
+				Header: "桌面会话在等待回答",
+				Elements: []bot.InteractiveCardElement{
+					{Tag: "markdown", Content: privateText},
+					{Tag: "action", Extra: map[string]any{"actions": actions}},
+				},
+			}
+		}
+	}
+	return desktopBridgeNotification{text: textFor, card: card}
+}
+
+func (h *botBridgeHub) turnDoneNotification(tabID string, err error) desktopBridgeNotification {
+	label := h.tabLabel(tabID)
+	if err != nil {
+		// Error text can contain paths/tokens; only detail it in a private chat.
+		return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
+			if isSharedChat(route.ChatType) {
+				return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
+			}
+			return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(err.Error(), botBridgeErrTextLimit))
+		}}
+	}
+	return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
+}
+
+func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
+	return map[string]any{
+		"tag":  "button",
+		"text": map[string]string{"tag": "plain_text", "content": label},
+		"type": style,
+		"value": map[string]string{
+			"command":   command,
+			"chat_type": string(route.ChatType),
+		},
+	}
+}
+
+func truncateForBridge(s string, limit int) string {
+	s = strings.TrimSpace(s)
+	runes := []rune(s)
+	if len(runes) <= limit {
+		return s
+	}
+	return string(runes[:limit]) + "…"
+}
+
+// ---- bot.DesktopBridge 实现 ----
+
+func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
+	if h.sessions == nil {
+		return nil
+	}
+	sessions := h.sessions()
+	h.mu.Lock()
+	byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
+	for id, p := range h.pending {
+		byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
+	}
+	h.mu.Unlock()
+	for i := range sessions {
+		if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
+			sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
+			sessions[i].Pending = pend
+		}
+	}
+	return sessions
+}
+
+func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
+	h.mu.Lock()
+	if enable {
+		h.watchers[route.Key()] = route
+	} else {
+		delete(h.watchers, route.Key())
+	}
+	h.watchSeq++
+	h.watchPersistDirty = true
+	seq := h.watchSeq
+	routes := h.watcherRoutesLocked()
+	persist := h.persistWatchers
+	h.mu.Unlock()
+	if persist == nil {
+		return nil
+	}
+	// Serialize persists and drop stale ones: two concurrent SetWatch calls
+	// (different connections) compute snapshots under h.mu but write config
+	// outside it, so their writes could otherwise reorder and let an older
+	// snapshot clobber a newer one, silently losing a subscription.
+	h.persistMu.Lock()
+	defer h.persistMu.Unlock()
+	if seq <= h.lastPersistSeq {
+		return nil
+	}
+	if err := persist(routes); err != nil {
+		return err
+	}
+	h.lastPersistSeq = seq
+	h.mu.Lock()
+	if h.watchSeq == seq {
+		h.watchPersistDirty = false
+	}
+	h.mu.Unlock()
+	return nil
+}
+
+func (h *botBridgeHub) watcherVersion() uint64 {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	return h.watchSeq
+}
+
+// seedWatchers applies a config snapshot only if no watch command changed the
+// runtime after the config read began. Fresh external config edits still apply;
+// stale refreshes and failed local persists do not erase newer runtime state.
+func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
+	h.persistMu.Lock()
+	defer h.persistMu.Unlock()
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	if h.watchSeq != expectedSeq || h.watchPersistDirty {
+		return
+	}
+	h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
+	for _, r := range routes {
+		if strings.TrimSpace(r.ChatID) == "" {
+			continue
+		}
+		h.watchers[r.Key()] = r
+	}
+}
+
+func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
+	routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
+	for _, r := range h.watchers {
+		routes = append(routes, r)
+	}
+	sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
+	return routes
+}
+
+func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	_, ok := h.watchers[route.Key()]
+	return ok
+}
+
+func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
+	approvalID = strings.TrimSpace(approvalID)
+	h.mu.Lock()
+	p, ok := h.pending[approvalID]
+	if ok && p.kind == "approval" {
+		delete(h.pending, approvalID)
+	}
+	h.mu.Unlock()
+	if !ok || p.kind != "approval" {
+		return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
+	}
+	if h.approveTab == nil {
+		return "", fmt.Errorf("桌面端审批通道不可用。")
+	}
+	h.approveTab(p.tabID, approvalID, allow, false, false)
+	action := "批准"
+	if !allow {
+		action = "拒绝"
+	}
+	return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
+}
+
+func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	p, ok := h.pending[strings.TrimSpace(askID)]
+	if !ok || p.kind != "ask" {
+		return nil, false
+	}
+	return p.questions, true
+}
+
+func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
+	askID = strings.TrimSpace(askID)
+	h.mu.Lock()
+	p, ok := h.pending[askID]
+	if ok && p.kind == "ask" {
+		delete(h.pending, askID)
+	}
+	h.mu.Unlock()
+	if !ok || p.kind != "ask" {
+		return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
+	}
+	if h.answerTab == nil {
+		return "", fmt.Errorf("桌面端问答通道不可用。")
+	}
+	out := make([]QuestionAnswer, 0, len(answers))
+	for _, an := range answers {
+		out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
+	}
+	h.answerTab(p.tabID, askID, out)
+	return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
+}
+
+// ---- 显式接管 ----
+
+func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
+	tabID = strings.TrimSpace(tabID)
+	// DM only. In a group the binding is keyed on the group chat, so after an
+	// admin takes over, ANY allowlisted member's plain message would be diverted
+	// to drive the session — a privilege escalation past the admin gate that
+	// establishes the takeover. Restricting to DM keeps the driver identical to
+	// the operator who established it.
+	if route.ChatType != bot.ChatDM {
+		return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
+	}
+	session, ok := h.sessionByTabID(tabID)
+	if !ok {
+		return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
+	}
+	if session.Detached {
+		return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
+	}
+	h.mu.Lock()
+	if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
+		h.mu.Unlock()
+		return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
+	}
+	// 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
+	released := ""
+	if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
+		delete(h.takeovers, prev)
+		released = prev
+	}
+	h.takeovers[tabID] = route
+	h.takeoverTabs[route.Key()] = tabID
+	announce := h.announce
+	changed := h.takeoverChanged
+	h.mu.Unlock()
+	if announce != nil {
+		if released != "" {
+			announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
+		}
+		announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
+	}
+	if changed != nil {
+		changed()
+	}
+	label := h.tabLabel(tabID)
+	return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
+}
+
+func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
+	h.mu.Lock()
+	tabID, ok := h.takeoverTabs[route.Key()]
+	if ok {
+		delete(h.takeoverTabs, route.Key())
+		delete(h.takeovers, tabID)
+	}
+	announce := h.announce
+	changed := h.takeoverChanged
+	h.mu.Unlock()
+	if !ok {
+		return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
+	}
+	if announce != nil {
+		announce(tabID, "IM 远程接管已解除。")
+	}
+	if changed != nil {
+		changed()
+	}
+	return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
+}
+
+func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	return h.takeoverTabs[route.Key()]
+}
+
+func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
+	h.mu.Lock()
+	tabID := h.takeoverTabs[route.Key()]
+	h.mu.Unlock()
+	if tabID == "" {
+		return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
+	}
+	session, ok := h.sessionByTabID(tabID)
+	if !ok || session.Detached {
+		// 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
+		h.mu.Lock()
+		delete(h.takeoverTabs, route.Key())
+		delete(h.takeovers, tabID)
+		h.mu.Unlock()
+		if changed := h.takeoverChanged; changed != nil {
+			changed()
+		}
+		return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
+	}
+	if session.Running {
+		return "", h.busyError(tabID)
+	}
+	if h.drive == nil {
+		return "", fmt.Errorf("桌面端驱动通道不可用。")
+	}
+	if err := h.drive(tabID, text, route); err != nil {
+		if errors.Is(err, errDriveBusy) {
+			return "", h.busyError(tabID)
+		}
+		return "", fmt.Errorf("驱动失败: %v", err)
+	}
+	return "", nil
+}
+
+func (h *botBridgeHub) busyError(tabID string) error {
+	return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
+}
+
+// reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
+// 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
+func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
+	h.mu.Lock()
+	route, ok := h.takeovers[tabID]
+	if ok {
+		delete(h.takeovers, tabID)
+		delete(h.takeoverTabs, route.Key())
+	}
+	notify := h.notify
+	changed := h.takeoverChanged
+	h.mu.Unlock()
+	if !ok {
+		return
+	}
+	if changed != nil {
+		changed()
+	}
+	if notify == nil {
+		return
+	}
+	label := h.tabLabel(tabID)
+	// 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
+	h.enqueue(desktopBridgeNotification{
+		text:  constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
+		route: &route,
+	})
+}
+
+// remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
+func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+	if len(h.takeovers) == 0 {
+		return nil
+	}
+	out := make(map[string]bool, len(h.takeovers))
+	for tabID := range h.takeovers {
+		out[tabID] = true
+	}
+	return out
+}
diff --git a/desktop/bot_bridge_app.go b/desktop/bot_bridge_app.go
new file mode 100644
index 0000000..7475c1e
--- /dev/null
+++ b/desktop/bot_bridge_app.go
@@ -0,0 +1,184 @@
+package main
+
+import (
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/config"
+	"reasonix/internal/control"
+	"reasonix/internal/event"
+)
+
+// 本文件是 botBridgeHub 对 App 的全部胶水:会话枚举(含后台 detached)、
+// 按 tab 寻址的审批/问答/驱动、transcript 公告、订阅持久化。
+
+func (a *App) newBotBridge() *botBridgeHub {
+	return newBotBridgeHub(botBridgeDeps{
+		sessions:        a.bridgeSessions,
+		approveTab:      a.bridgeApprove,
+		answerTab:       a.bridgeAnswer,
+		notify:          a.botRuntime.SendToAdapter,
+		drive:           a.bridgeDrive,
+		announce:        a.bridgeAnnounce,
+		persistWatchers: a.bridgePersistWatchers,
+		takeoverChanged: a.emitProjectTreeChanged,
+		logger:          slog.Default(),
+	})
+}
+
+// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
+// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
+func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
+	tabs := a.ListTabs()
+	out := make([]bot.DesktopSessionInfo, 0, len(tabs)+4)
+	seen := make(map[string]bool, len(tabs))
+	for _, t := range tabs {
+		seen[t.ID] = true
+		out = append(out, bot.DesktopSessionInfo{
+			TabID:         t.ID,
+			Label:         t.Label,
+			Workspace:     t.WorkspaceName,
+			Topic:         t.TopicTitle,
+			Ready:         t.Ready,
+			Running:       t.Running,
+			PendingPrompt: t.PendingPrompt,
+		})
+	}
+	a.mu.RLock()
+	for _, tab := range a.detachedSessions {
+		if tab == nil || seen[tab.ID] {
+			continue
+		}
+		seen[tab.ID] = true
+		out = append(out, bot.DesktopSessionInfo{
+			TabID:    tab.ID,
+			Label:    tab.TopicTitle,
+			Topic:    tab.TopicTitle,
+			Ready:    tab.Ctrl != nil,
+			Running:  strings.TrimSpace(tab.ActivityStatus) != "",
+			Detached: true,
+		})
+	}
+	a.mu.RUnlock()
+	return out
+}
+
+// bridgeCtrlByTabID 解析可见与后台 detached 两张表(区别于 ctrlByTabID:
+// 那是前端语义,空 tabID 落到活跃 tab,且不看 detached)。
+func (a *App) bridgeCtrlByTabID(tabID string) control.SessionAPI {
+	a.mu.RLock()
+	defer a.mu.RUnlock()
+	if tab := a.tabByEventSinkIDLocked(tabID); tab != nil {
+		return tab.Ctrl
+	}
+	return nil
+}
+
+func (a *App) bridgeApprove(tabID, id string, allow, session, persist bool) {
+	if ctrl := a.bridgeCtrlByTabID(tabID); ctrl != nil {
+		ctrl.Approve(id, allow, session, persist)
+	}
+}
+
+func (a *App) bridgeAnswer(tabID, id string, answers []QuestionAnswer) {
+	ctrl := a.bridgeCtrlByTabID(tabID)
+	if ctrl == nil {
+		return
+	}
+	out := make([]event.AskAnswer, len(answers))
+	for i, an := range answers {
+		out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
+	}
+	ctrl.AnswerQuestion(id, out)
+}
+
+// bridgeAnnounce 往会话 transcript 发一条 Notice,桌面用户在聊天流里可见。
+func (a *App) bridgeAnnounce(tabID, text string) {
+	a.mu.RLock()
+	tab := a.tabByEventSinkIDLocked(tabID)
+	var sink *tabEventSink
+	if tab != nil {
+		sink = tab.sink
+	}
+	a.mu.RUnlock()
+	if sink == nil {
+		return
+	}
+	sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
+}
+
+// bridgeDrive 把远程文本提交为可见 tab 的新 turn,并为这一轮挂上事件转发器,
+// 让输出流回接管聊天(转发器在 TurnDone 自动卸载)。
+func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
+	tab, ctrl, err := a.beginTabTurn(tabID, false)
+	if err != nil {
+		if err == control.ErrTurnRunning {
+			return errDriveBusy
+		}
+		return err
+	}
+	if tab.sink == nil {
+		a.finishTabTurnStart(tab, nil)
+		return fmt.Errorf("会话事件通道不可用,无法驱动")
+	}
+	// A local submission may have reclaimed the tab while this drive was waiting
+	// for the per-tab admission gate. Revalidate ownership only after the gate is
+	// held, immediately before attaching the route-specific forwarder.
+	if a.botBridge == nil || a.botBridge.TakeoverTab(route) != tabID {
+		tab.sink.cancelTurnStart()
+		tab.turnStartMu.Unlock()
+		return fmt.Errorf("接管已解除,请重新接管会话")
+	}
+	target := botForwardTarget{
+		ConnID:   route.ConnectionID,
+		Domain:   route.Domain,
+		ChatID:   route.ChatID,
+		ChatType: route.ChatType,
+	}
+	generation := tab.sink.SetBotSink(newBotEventForwarder(a.botRuntime, []botForwardTarget{target}))
+	a.ensureTabTopicIndexedForUserTurn(tab)
+	ctrl.SubmitDisplay(text, text)
+	// Confirm the submit actually started a turn. If nothing is running now, the
+	// controller was rotating and the submit no-oped — detach this exact
+	// generation so a later turn's output does not leak.
+	if !a.finishTabTurnStart(tab, ctrl) {
+		tab.sink.clearBotSink(generation)
+		return errDriveBusy
+	}
+	return nil
+}
+
+// bridgePersistWatchers 把订阅全集回写用户配置(bot.desktop_watchers),
+// 桌面重启后由 refreshBotRuntime 重新种子。
+func (a *App) bridgePersistWatchers(routes []bot.DesktopWatchRoute) error {
+	return a.applyConfigOnly(func(c *config.Config) error {
+		watchers := make([]config.BotDesktopWatcherConfig, 0, len(routes))
+		for _, r := range routes {
+			watchers = append(watchers, config.BotDesktopWatcherConfig{
+				Platform:     string(r.Platform),
+				ConnectionID: r.ConnectionID,
+				Domain:       r.Domain,
+				ChatType:     string(r.ChatType),
+				ChatID:       r.ChatID,
+			})
+		}
+		c.Bot.DesktopWatchers = watchers
+		return nil
+	})
+}
+
+func bridgeRoutesFromConfig(watchers []config.BotDesktopWatcherConfig) []bot.DesktopWatchRoute {
+	routes := make([]bot.DesktopWatchRoute, 0, len(watchers))
+	for _, w := range watchers {
+		routes = append(routes, bot.DesktopWatchRoute{
+			Platform:     bot.Platform(strings.TrimSpace(w.Platform)),
+			ConnectionID: strings.TrimSpace(w.ConnectionID),
+			Domain:       strings.TrimSpace(w.Domain),
+			ChatType:     bot.ChatType(strings.TrimSpace(w.ChatType)),
+			ChatID:       strings.TrimSpace(w.ChatID),
+		})
+	}
+	return routes
+}
diff --git a/desktop/bot_bridge_test.go b/desktop/bot_bridge_test.go
new file mode 100644
index 0000000..c90c7b8
--- /dev/null
+++ b/desktop/bot_bridge_test.go
@@ -0,0 +1,605 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"strings"
+	"testing"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+type bridgeNotifyCall struct {
+	connectionID string
+	domain       string
+	msg          bot.OutboundMessage
+}
+
+type bridgeTestEnv struct {
+	hub        *botBridgeHub
+	notified   chan bridgeNotifyCall
+	approves   chan [2]string // [tabID, id+":"+allow]
+	answers    chan [2]string // [tabID, id]
+	driven     chan [2]string // [tabID, text]
+	announced  chan [2]string // [tabID, text]
+	persisted  chan []bot.DesktopWatchRoute
+	driveErr   error
+	persistErr error
+}
+
+func tabsToSessions(tabs []TabMeta) []bot.DesktopSessionInfo {
+	out := make([]bot.DesktopSessionInfo, 0, len(tabs))
+	for _, t := range tabs {
+		out = append(out, bot.DesktopSessionInfo{
+			TabID:         t.ID,
+			Label:         t.Label,
+			Workspace:     t.WorkspaceName,
+			Topic:         t.TopicTitle,
+			Ready:         t.Ready,
+			Running:       t.Running,
+			PendingPrompt: t.PendingPrompt,
+		})
+	}
+	return out
+}
+
+func newBridgeTestEnv(tabs []TabMeta) *bridgeTestEnv {
+	return newBridgeTestEnvSessions(tabsToSessions(tabs))
+}
+
+func newBridgeTestEnvSessions(sessions []bot.DesktopSessionInfo) *bridgeTestEnv {
+	env := &bridgeTestEnv{
+		notified:  make(chan bridgeNotifyCall, 16),
+		approves:  make(chan [2]string, 16),
+		answers:   make(chan [2]string, 16),
+		driven:    make(chan [2]string, 16),
+		announced: make(chan [2]string, 16),
+		persisted: make(chan []bot.DesktopWatchRoute, 16),
+	}
+	env.hub = newBotBridgeHub(botBridgeDeps{
+		sessions: func() []bot.DesktopSessionInfo { return sessions },
+		approveTab: func(tabID, id string, allow, session, persist bool) {
+			env.approves <- [2]string{tabID, fmt.Sprintf("%s:%t", id, allow)}
+		},
+		answerTab: func(tabID, id string, answers []QuestionAnswer) {
+			env.answers <- [2]string{tabID, id}
+		},
+		notify: func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
+			env.notified <- bridgeNotifyCall{connectionID: connectionID, domain: domain, msg: msg}
+			return bot.SendResult{MessageID: "sent-1"}, nil
+		},
+		drive: func(tabID, text string, route bot.DesktopWatchRoute) error {
+			if env.driveErr != nil {
+				return env.driveErr
+			}
+			env.driven <- [2]string{tabID, text}
+			return nil
+		},
+		announce: func(tabID, text string) {
+			env.announced <- [2]string{tabID, text}
+		},
+		persistWatchers: func(routes []bot.DesktopWatchRoute) error {
+			env.persisted <- routes
+			return env.persistErr
+		},
+		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+	})
+	return env
+}
+
+func (env *bridgeTestEnv) waitNotification(t *testing.T) bridgeNotifyCall {
+	t.Helper()
+	select {
+	case call := <-env.notified:
+		return call
+	case <-time.After(2 * time.Second):
+		t.Fatal("timed out waiting for bridge notification")
+		return bridgeNotifyCall{}
+	}
+}
+
+func (env *bridgeTestEnv) expectNoNotification(t *testing.T) {
+	t.Helper()
+	select {
+	case call := <-env.notified:
+		t.Fatalf("unexpected notification: %+v", call)
+	case <-time.After(100 * time.Millisecond):
+	}
+}
+
+func testWatchRoute() bot.DesktopWatchRoute {
+	return bot.DesktopWatchRoute{
+		ConnectionID: "feishu-main",
+		Domain:       "feishu",
+		Platform:     bot.PlatformFeishu,
+		ChatType:     bot.ChatDM,
+		ChatID:       "chat-god",
+	}
+}
+
+func testGroupRoute() bot.DesktopWatchRoute {
+	r := testWatchRoute()
+	r.ChatType = bot.ChatGroup
+	r.ChatID = "group-god"
+	return r
+}
+
+func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
+	if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
+		t.Fatal("takeover from a group chat must be rejected (non-admin members could otherwise drive it)")
+	}
+	if _, err := env.hub.Takeover(testWatchRoute(), "tab-1"); err != nil {
+		t.Fatalf("DM takeover should work: %v", err)
+	}
+}
+
+func TestBridgeTakeoverSwitchAnnouncesReleaseToOldTab(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-a", Label: "A", Ready: true},
+		{TabID: "tab-b", Label: "B", Ready: true},
+	})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-a"); err != nil {
+		t.Fatalf("takeover A: %v", err)
+	}
+	if got := <-env.announced; got[0] != "tab-a" {
+		t.Fatalf("first announce = %v, want tab-a takeover", got)
+	}
+	if _, err := env.hub.Takeover(route, "tab-b"); err != nil {
+		t.Fatalf("switch to B: %v", err)
+	}
+	seen := map[string]bool{}
+	for i := 0; i < 2; i++ {
+		select {
+		case got := <-env.announced:
+			seen[got[0]] = true
+		case <-time.After(time.Second):
+			t.Fatalf("missing announce after switch; seen=%v", seen)
+		}
+	}
+	if !seen["tab-a"] || !seen["tab-b"] {
+		t.Fatalf("switch should announce release to tab-a and takeover to tab-b; seen=%v", seen)
+	}
+}
+
+func TestBridgeDriveInputBusyReturnsBusyMessage(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	<-env.announced
+	env.driveErr = errDriveBusy
+	_, err := env.hub.DriveInput(route, "hi")
+	if err == nil || !strings.Contains(err.Error(), "正在执行中") {
+		t.Fatalf("busy drive should surface a clean busy message, got %v", err)
+	}
+}
+
+func TestBridgeApprovalRedactsSubjectInGroup(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testGroupRoute(), true)
+	<-env.persisted
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf /secret"}})
+	call := env.waitNotification(t)
+	if strings.Contains(call.msg.Text, "rm -rf /secret") {
+		t.Fatalf("group notification leaked the command line: %q", call.msg.Text)
+	}
+	if call.msg.Card != nil {
+		for _, el := range call.msg.Card.Elements {
+			if strings.Contains(el.Content, "rm -rf /secret") {
+				t.Fatal("group card leaked the command line")
+			}
+		}
+	}
+}
+
+func TestBridgeApprovalShowsSubjectInDM(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+	<-env.persisted
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf build"}})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "rm -rf build") {
+		t.Fatalf("DM notification should show the command line: %q", call.msg.Text)
+	}
+}
+
+func TestBridgeAskRedactsPromptAndOptionsInGroup(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	if err := env.hub.SetWatch(testGroupRoute(), true); err != nil {
+		t.Fatalf("SetWatch: %v", err)
+	}
+	<-env.persisted
+	env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{
+		ID: "redacted-ask",
+		Questions: []event.AskQuestion{{
+			ID: "q1", Prompt: "INTERNAL_ONLY_PROMPT", Options: []event.AskOption{{Label: "CHOICE_INTERNAL"}},
+		}},
+	}})
+	call := env.waitNotification(t)
+	if strings.Contains(call.msg.Text, "INTERNAL_ONLY_PROMPT") || strings.Contains(call.msg.Text, "CHOICE_INTERNAL") {
+		t.Fatalf("group notification leaked ask details: %q", call.msg.Text)
+	}
+	if call.msg.Card != nil {
+		t.Fatal("group ask notification must not include option buttons")
+	}
+}
+
+func TestBridgeSessionsIncludePendingIDs(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash"}})
+	env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: "q1", Questions: []event.AskQuestion{{ID: "x", Prompt: "?"}}}})
+	found := map[string]bool{}
+	for _, s := range env.hub.Sessions() {
+		if s.TabID != "tab-1" {
+			continue
+		}
+		for _, p := range s.Pending {
+			found[p.ID] = true
+		}
+	}
+	if !found["a1"] || !found["q1"] {
+		t.Fatalf("Sessions() should surface pending approval and ask ids; got %v", found)
+	}
+}
+
+func TestBridgePersistDropsStaleSnapshot(t *testing.T) {
+	env := newBridgeTestEnvSessions(nil)
+	// Two subscribes: the second (newer seq) must be the persisted result even
+	// though we invoke the seed-restore afterward.
+	env.hub.SetWatch(testWatchRoute(), true)
+	<-env.persisted
+	env.hub.SetWatch(testGroupRoute(), true)
+	routes := <-env.persisted
+	if len(routes) != 2 {
+		t.Fatalf("persisted routes = %d, want both subscriptions", len(routes))
+	}
+}
+
+func TestBridgeApprovalNotifiesWatchersAndRoutesApproval(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "修复登录"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
+		ID: "appr-1", Tool: "bash", Subject: "rm -rf build",
+	}})
+
+	call := env.waitNotification(t)
+	if call.connectionID != "feishu-main" || call.msg.ChatID != "chat-god" {
+		t.Fatalf("notification routed to %s/%s, want feishu-main/chat-god", call.connectionID, call.msg.ChatID)
+	}
+	for _, want := range []string{"修复登录", "bash", "rm -rf build", "/desktop approve appr-1"} {
+		if !strings.Contains(call.msg.Text, want) {
+			t.Fatalf("notification text = %q, want it to contain %q", call.msg.Text, want)
+		}
+	}
+	if call.msg.Card == nil {
+		t.Fatal("approval notification should carry an interactive card")
+	}
+
+	feedback, err := env.hub.Approve("appr-1", true)
+	if err != nil {
+		t.Fatalf("Approve: %v", err)
+	}
+	if !strings.Contains(feedback, "先到者为准") {
+		t.Fatalf("feedback = %q, want first-wins note", feedback)
+	}
+	select {
+	case got := <-env.approves:
+		if got[0] != "tab-1" || got[1] != "appr-1:true" {
+			t.Fatalf("approve routed as %v, want tab-1/appr-1:true", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("approve was not routed to the tab")
+	}
+
+	// 同一 ID 第二次应答:pending 已清,返回未找到。
+	if _, err := env.hub.Approve("appr-1", false); err == nil {
+		t.Fatal("second Approve on the same id should fail")
+	}
+}
+
+func TestBridgePendingRecordedWithoutWatchers(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
+
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-2", Tool: "bash"}})
+	env.expectNoNotification(t)
+
+	if _, err := env.hub.Approve("appr-2", false); err != nil {
+		t.Fatalf("Approve without watchers should still work: %v", err)
+	}
+	select {
+	case got := <-env.approves:
+		if got[1] != "appr-2:false" {
+			t.Fatalf("deny routed as %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("deny was not routed")
+	}
+}
+
+func TestBridgeTurnDoneClearsPendingAndNotifies(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-3", Tool: "bash"}})
+	env.waitNotification(t)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "✅") || !strings.Contains(call.msg.Text, "会话一") {
+		t.Fatalf("turn-done text = %q", call.msg.Text)
+	}
+
+	if _, err := env.hub.Approve("appr-3", true); err == nil {
+		t.Fatal("pending approval should be cleared by TurnDone")
+	}
+}
+
+func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("context canceled")})
+	env.expectNoNotification(t)
+
+	env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("boom")})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "boom") {
+		t.Fatalf("error text = %q", call.msg.Text)
+	}
+}
+
+func TestBridgeAskAnswerRoundTrip(t *testing.T) {
+	env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
+	env.hub.SetWatch(testWatchRoute(), true)
+
+	env.hub.observe("tab-2", event.Event{Kind: event.AskRequest, Ask: event.Ask{
+		ID: "ask-1",
+		Questions: []event.AskQuestion{{
+			ID:      "q1",
+			Prompt:  "选一个方案",
+			Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
+		}},
+	}})
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "/desktop answer ask-1") {
+		t.Fatalf("ask notification = %q, want answer hint", call.msg.Text)
+	}
+	if call.msg.Card == nil {
+		t.Fatal("single-choice ask should carry option buttons")
+	}
+
+	questions, ok := env.hub.AskQuestions("ask-1")
+	if !ok || len(questions) != 1 {
+		t.Fatalf("AskQuestions = %v/%v", questions, ok)
+	}
+	if _, err := env.hub.Answer("ask-1", []event.AskAnswer{{QuestionID: "q1", Selected: []string{"B"}}}); err != nil {
+		t.Fatalf("Answer: %v", err)
+	}
+	select {
+	case got := <-env.answers:
+		if got[0] != "tab-2" || got[1] != "ask-1" {
+			t.Fatalf("answer routed as %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("answer was not routed")
+	}
+}
+
+func TestBridgeWatchLifecycleStopsNotifications(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+
+	env.hub.SetWatch(route, true)
+	if !env.hub.Watching(route) {
+		t.Fatal("route should be watching after SetWatch(true)")
+	}
+	env.hub.SetWatch(route, false)
+	if env.hub.Watching(route) {
+		t.Fatal("route should not be watching after SetWatch(false)")
+	}
+
+	env.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
+	env.expectNoNotification(t)
+}
+
+func TestBridgeSetWatchPersistsAndSeedRestores(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+
+	env.hub.SetWatch(route, true)
+	select {
+	case routes := <-env.persisted:
+		if len(routes) != 1 || routes[0].Key() != route.Key() {
+			t.Fatalf("persisted = %+v, want the subscribed route", routes)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("SetWatch did not persist watchers")
+	}
+
+	// 模拟重启:全新 hub 从配置种子恢复。
+	env2 := newBridgeTestEnv(nil)
+	env2.hub.seedWatchers([]bot.DesktopWatchRoute{route}, env2.hub.watcherVersion())
+	if !env2.hub.Watching(route) {
+		t.Fatal("seeded hub should be watching the persisted route")
+	}
+	env2.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
+	if call := env2.waitNotification(t); !strings.Contains(call.msg.Text, "✅") {
+		t.Fatalf("seeded watcher did not receive notifications: %q", call.msg.Text)
+	}
+}
+
+func TestBridgeSeedDoesNotOverwriteNewerRuntimeWatch(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+	staleVersion := env.hub.watcherVersion()
+	if err := env.hub.SetWatch(route, true); err != nil {
+		t.Fatalf("SetWatch: %v", err)
+	}
+	<-env.persisted
+
+	// Simulate a runtime refresh carrying a config snapshot loaded before the
+	// watch command persisted. It must not erase the newer in-process route.
+	env.hub.seedWatchers(nil, staleVersion)
+	if !env.hub.Watching(route) {
+		t.Fatal("stale config seed overwrote the newer runtime subscription")
+	}
+}
+
+func TestBridgeSeedPreservesWatchAfterPersistFailure(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	env.persistErr = errors.New("disk unavailable")
+	route := testWatchRoute()
+	if err := env.hub.SetWatch(route, true); err == nil {
+		t.Fatal("SetWatch should report the persistence failure")
+	}
+	<-env.persisted
+
+	env.hub.seedWatchers(nil, env.hub.watcherVersion())
+	if !env.hub.Watching(route) {
+		t.Fatal("disk snapshot erased a runtime watch whose persistence failed")
+	}
+}
+
+func TestBridgeSeedAppliesFreshExternalConfig(t *testing.T) {
+	env := newBridgeTestEnv(nil)
+	route := testWatchRoute()
+	version := env.hub.watcherVersion()
+	env.hub.seedWatchers([]bot.DesktopWatchRoute{route}, version)
+	if !env.hub.Watching(route) {
+		t.Fatal("initial config seed did not apply")
+	}
+
+	env.hub.seedWatchers(nil, version)
+	if env.hub.Watching(route) {
+		t.Fatal("fresh external config update did not replace the watcher set")
+	}
+}
+
+func TestBridgeApprovalRoutesToDetachedSession(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-bg", Label: "后台任务", Detached: true, Ready: true},
+	})
+
+	env.hub.observe("tab-bg", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-bg", Tool: "bash"}})
+	if _, err := env.hub.Approve("appr-bg", true); err != nil {
+		t.Fatalf("Approve on detached session: %v", err)
+	}
+	select {
+	case got := <-env.approves:
+		if got[0] != "tab-bg" {
+			t.Fatalf("approve routed to %v, want tab-bg", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("detached approval was not routed")
+	}
+}
+
+func TestBridgeTakeoverLifecycle(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-1", Label: "会话一", Ready: true},
+		{TabID: "tab-bg", Label: "后台", Detached: true},
+	})
+	route := testWatchRoute()
+
+	// 后台会话拒绝接管。
+	if _, err := env.hub.Takeover(route, "tab-bg"); err == nil {
+		t.Fatal("takeover of a detached session should fail")
+	}
+
+	feedback, err := env.hub.Takeover(route, "tab-1")
+	if err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	if !strings.Contains(feedback, "已接管") {
+		t.Fatalf("feedback = %q", feedback)
+	}
+	if env.hub.TakeoverTab(route) != "tab-1" {
+		t.Fatalf("TakeoverTab = %q, want tab-1", env.hub.TakeoverTab(route))
+	}
+	select {
+	case got := <-env.announced:
+		if got[0] != "tab-1" || !strings.Contains(got[1], "接管") {
+			t.Fatalf("announce = %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("takeover was not announced to the desktop transcript")
+	}
+
+	// 驱动输入路由到 tab。
+	if _, err := env.hub.DriveInput(route, "跑一下测试"); err != nil {
+		t.Fatalf("DriveInput: %v", err)
+	}
+	select {
+	case got := <-env.driven:
+		if got[0] != "tab-1" || got[1] != "跑一下测试" {
+			t.Fatalf("driven = %v", got)
+		}
+	case <-time.After(time.Second):
+		t.Fatal("drive input was not routed")
+	}
+
+	// 另一个聊天抢同一会话被拒。
+	other := route
+	other.ChatID = "chat-other"
+	if _, err := env.hub.Takeover(other, "tab-1"); err == nil {
+		t.Fatal("takeover by another chat should be rejected while held")
+	}
+
+	// 释放。
+	if _, err := env.hub.Release(route); err != nil {
+		t.Fatalf("Release: %v", err)
+	}
+	if env.hub.TakeoverTab(route) != "" {
+		t.Fatal("binding should be cleared after release")
+	}
+	if _, err := env.hub.Release(route); err == nil {
+		t.Fatal("second release should report no binding")
+	}
+}
+
+func TestBridgeDriveInputRejectsRunningSession(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-1", Label: "会话一", Ready: true, Running: true},
+	})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	<-env.announced
+	if _, err := env.hub.DriveInput(route, "hello"); err == nil || !strings.Contains(err.Error(), "正在执行中") {
+		t.Fatalf("DriveInput on running session = %v, want busy rejection", err)
+	}
+}
+
+func TestBridgeReclaimFromDesktopNotifiesController(t *testing.T) {
+	env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
+		{TabID: "tab-1", Label: "会话一", Ready: true},
+	})
+	route := testWatchRoute()
+	if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
+		t.Fatalf("Takeover: %v", err)
+	}
+	<-env.announced
+
+	env.hub.reclaimFromDesktop("tab-1")
+	if env.hub.TakeoverTab(route) != "" {
+		t.Fatal("reclaim should clear the binding")
+	}
+	call := env.waitNotification(t)
+	if !strings.Contains(call.msg.Text, "收回") || call.msg.ChatID != route.ChatID {
+		t.Fatalf("reclaim notification = %+v", call)
+	}
+
+	// 未接管 tab 的 reclaim 是 no-op。
+	env.hub.reclaimFromDesktop("tab-1")
+	env.expectNoNotification(t)
+}
diff --git a/desktop/bot_connection_app.go b/desktop/bot_connection_app.go
new file mode 100644
index 0000000..c664b6c
--- /dev/null
+++ b/desktop/bot_connection_app.go
@@ -0,0 +1,982 @@
+package main
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/bot/feishu"
+	"reasonix/internal/bot/weixin"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+)
+
+type BotConnectionCredentialView struct {
+	AppID        string `json:"appId"`
+	AppSecretEnv string `json:"appSecretEnv"`
+	AccountID    string `json:"accountId"`
+	TokenEnv     string `json:"tokenEnv"`
+	SecretSet    bool   `json:"secretSet"`
+}
+
+type BotConnectionSessionMappingView struct {
+	RemoteID      string `json:"remoteId"`
+	SessionID     string `json:"sessionId"`
+	SessionSource string `json:"sessionSource"`
+	ChatType      string `json:"chatType"`
+	UserID        string `json:"userId"`
+	ThreadID      string `json:"threadId"`
+	Scope         string `json:"scope"`
+	WorkspaceRoot string `json:"workspaceRoot"`
+	UpdatedAt     string `json:"updatedAt"`
+}
+
+type BotConnectionView struct {
+	ID               string                            `json:"id"`
+	Provider         string                            `json:"provider"`
+	Domain           string                            `json:"domain"`
+	Label            string                            `json:"label"`
+	Enabled          bool                              `json:"enabled"`
+	Status           string                            `json:"status"`
+	Model            string                            `json:"model"`
+	ToolApprovalMode string                            `json:"toolApprovalMode"`
+	WorkspaceRoot    string                            `json:"workspaceRoot"`
+	Access           BotAccessView                     `json:"access"`
+	Credential       BotConnectionCredentialView       `json:"credential"`
+	SessionMappings  []BotConnectionSessionMappingView `json:"sessionMappings"`
+	LastError        string                            `json:"lastError"`
+	CreatedAt        string                            `json:"createdAt"`
+	UpdatedAt        string                            `json:"updatedAt"`
+}
+
+type BotInstallStartResult struct {
+	OK         bool   `json:"ok"`
+	Provider   string `json:"provider"`
+	Domain     string `json:"domain"`
+	InstallID  string `json:"installId"`
+	URL        string `json:"url"`
+	DeviceCode string `json:"deviceCode"`
+	UserCode   string `json:"userCode"`
+	Interval   int    `json:"interval"`
+	ExpireIn   int    `json:"expireIn"`
+	Message    string `json:"message"`
+}
+
+type BotInstallPollResult struct {
+	Done       bool              `json:"done"`
+	Connection BotConnectionView `json:"connection"`
+	Status     string            `json:"status"`
+	Message    string            `json:"message"`
+	Error      string            `json:"error"`
+}
+
+type BotConnectionDiagnostic struct {
+	ID           string `json:"id"`
+	Label        string `json:"label"`
+	Status       string `json:"status"`
+	Message      string `json:"message"`
+	MessageID    string `json:"messageId"`
+	Phase        string `json:"phase"`
+	Code         string `json:"code"`
+	ReportKind   string `json:"reportKind"`
+	ReportDetail string `json:"reportDetail"`
+	OccurredAt   string `json:"occurredAt"`
+}
+
+type botInstallSession struct {
+	Provider   string
+	Domain     string
+	PollDomain string
+	DeviceCode string
+	UserCode   string
+	StartedAt  time.Time
+	ExpireAt   time.Time
+	Weixin     *weixin.LoginSession
+}
+
+func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) {
+	provider, domain = normalizeBotInstallTarget(provider, domain)
+	if provider == "weixin" {
+		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+		defer cancel()
+		session, err := weixin.StartLogin(ctx)
+		if err != nil {
+			return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil
+		}
+		installID := randomInstallID()
+		a.mu.Lock()
+		if a.botInstalls == nil {
+			a.botInstalls = map[string]*botInstallSession{}
+		}
+		a.botInstalls[installID] = &botInstallSession{
+			Provider:   provider,
+			Domain:     domain,
+			DeviceCode: session.QRCode,
+			StartedAt:  session.StartedAt,
+			ExpireAt:   time.Now().Add(2 * time.Minute),
+			Weixin:     session,
+		}
+		a.mu.Unlock()
+		return BotInstallStartResult{
+			OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode),
+			DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。",
+		}, nil
+	}
+	if provider != "feishu" {
+		return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil
+	}
+	return a.startFeishuConnectionInstall(domain)
+}
+
+func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
+	installID = strings.TrimSpace(installID)
+	// Copy the session under a.mu: overlapping polls of the same install can
+	// race the locked PollDomain upgrade below with unlocked field reads.
+	a.mu.RLock()
+	sessionPtr := a.botInstalls[installID]
+	var sessionCopy botInstallSession
+	if sessionPtr != nil {
+		sessionCopy = *sessionPtr
+	}
+	a.mu.RUnlock()
+	if sessionPtr == nil {
+		return BotInstallPollResult{Error: "install session not found"}, nil
+	}
+	session := &sessionCopy
+	if time.Now().After(session.ExpireAt) {
+		a.deleteBotInstall(installID)
+		return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
+	}
+	if session.Provider == "weixin" {
+		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+		defer cancel()
+		result, status, err := weixin.PollLogin(ctx, session.Weixin)
+		if err != nil {
+			return BotInstallPollResult{Status: status, Error: err.Error()}, nil
+		}
+		if result == nil {
+			return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil
+		}
+		a.deleteBotInstall(installID)
+		conn, err := a.upsertBotConnection(config.BotConnectionConfig{
+			ID:         connectionID("weixin", "weixin"),
+			Provider:   "weixin",
+			Domain:     "weixin",
+			Label:      "微信",
+			Enabled:    true,
+			Status:     "connected",
+			Access:     botInstallAccess(result.UserID),
+			Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
+		}, func(c *config.Config) {
+			c.Bot.Enabled = true
+			c.Bot.Weixin.Enabled = true
+			c.Bot.Weixin.AccountID = result.AccountID
+			c.Bot.Weixin.APIBase = result.BaseURL
+			if c.Bot.Weixin.TokenEnv == "" {
+				c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
+			}
+			c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID)
+		})
+		if err != nil {
+			return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+		}
+		a.refreshBotRuntimeAsync()
+		return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil
+	}
+	return a.pollFeishuConnectionInstall(installID, session)
+}
+
+func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) {
+	cfg, err := a.loadDesktopBotConfig()
+	if err != nil {
+		return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
+	}
+	for _, conn := range cfg.Bot.Connections {
+		if conn.ID == id {
+			status := "ok"
+			message := "连接配置已保存。"
+			phase := "config"
+			code := "config_ok"
+			reportable := false
+			if !conn.Enabled {
+				status = "disabled"
+				message = "连接已保存但未启用。"
+				code = "connection_disabled"
+			} else if conn.Status != "connected" {
+				status = firstNonEmptyBot(conn.Status, "pending")
+				message = firstNonEmptyBot(conn.LastError, "连接还未完成。")
+				phase = "install"
+				code = "connection_not_connected"
+				reportable = status == "error" || strings.TrimSpace(conn.LastError) != ""
+			} else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) {
+				status = "warning"
+				message = conn.Credential.AppSecretEnv + " 未设置。"
+				phase = "credential"
+				code = "secret_missing"
+				reportable = true
+			} else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) {
+				status = "warning"
+				message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。"
+				phase = "credential"
+				code = "secret_missing"
+				reportable = true
+			} else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) {
+				status = "warning"
+				message = "未找到已保存的微信登录凭据。"
+				phase = "credential"
+				code = "secret_missing"
+				reportable = true
+			}
+			return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil
+		}
+	}
+	return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
+}
+
+func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) {
+	cfg, err := a.loadDesktopBotConfig()
+	if err != nil {
+		return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
+	}
+	var conn *config.BotConnectionConfig
+	for i := range cfg.Bot.Connections {
+		if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) {
+			conn = &cfg.Bot.Connections[i]
+			break
+		}
+	}
+	if conn == nil {
+		return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
+	}
+	target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings))
+	if conn.Provider != "feishu" && conn.Provider != "weixin" {
+		return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil
+	}
+	if target == "" {
+		return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	var result bot.SendResult
+	switch conn.Provider {
+	case "feishu":
+		feishuCfg := cfg.Bot.Feishu
+		feishuCfg.Enabled = true
+		feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain)
+		feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID)
+		feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv)
+		result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
+	case "weixin":
+		weixinCfg := cfg.Bot.Weixin
+		weixinCfg.Enabled = true
+		weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID)
+		weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv)
+		result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
+	}
+	if err != nil {
+		return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil
+	}
+	_ = a.rememberBotConnectionRemote(conn.ID, target)
+	msg := "测试消息已发送。"
+	if result.MessageID != "" {
+		msg += " Message ID: " + result.MessageID
+	}
+	diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false)
+	diag.MessageID = result.MessageID
+	return diag, nil
+}
+
+func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
+	id = strings.TrimSpace(id)
+	label := ""
+	if conn != nil {
+		id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id)
+		label = strings.TrimSpace(conn.Label)
+	}
+	occurredAt := time.Now().UTC().Format(time.RFC3339)
+	diag := BotConnectionDiagnostic{
+		ID:         id,
+		Label:      label,
+		Status:     strings.TrimSpace(status),
+		Message:    strings.TrimSpace(message),
+		Phase:      strings.TrimSpace(phase),
+		Code:       strings.TrimSpace(code),
+		OccurredAt: occurredAt,
+	}
+	if reportable {
+		diag.ReportKind = "bot"
+		diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt)
+		if diag.ReportDetail == "" {
+			diag.ReportKind = ""
+		}
+	}
+	return diag
+}
+
+func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string {
+	provider := "unknown"
+	domain := "unknown"
+	configuredStatus := ""
+	enabled := false
+	workspaceScope := "global"
+	sessionMappings := 0
+	appIDSet := false
+	appSecretEnvConfigured := false
+	tokenEnvConfigured := false
+	secretAvailable := false
+	if conn != nil {
+		provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider)
+		domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain)
+		configuredStatus = strings.TrimSpace(conn.Status)
+		enabled = conn.Enabled
+		if strings.TrimSpace(conn.WorkspaceRoot) != "" {
+			workspaceScope = "project"
+		}
+		sessionMappings = len(conn.SessionMappings)
+		appIDSet = strings.TrimSpace(conn.Credential.AppID) != ""
+		appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != ""
+		tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != ""
+		secretAvailable = botCredentialSecretSet(*conn)
+	}
+	summary := botConnectionReportSummary(code, message)
+	lines := []string{
+		"Bot connection diagnostic",
+		"",
+		"connection_id: " + safeBotReportValue(fallbackID),
+		"provider: " + safeBotReportValue(provider),
+		"domain: " + safeBotReportValue(domain),
+		"status: " + safeBotReportValue(status),
+		"phase: " + safeBotReportValue(phase),
+		"code: " + safeBotReportValue(code),
+		fmt.Sprintf("enabled: %t", enabled),
+		"configured_status: " + safeBotReportValue(configuredStatus),
+		fmt.Sprintf("app_id_set: %t", appIDSet),
+		fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured),
+		fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured),
+		fmt.Sprintf("secret_available: %t", secretAvailable),
+		"workspace_scope: " + workspaceScope,
+		fmt.Sprintf("session_mappings: %d", sessionMappings),
+		"",
+		"summary: " + summary,
+	}
+	payload := frontendCrashPayload{
+		SchemaVersion: 2,
+		Kind:          "bot",
+		Source:        "bot.runtime",
+		Label:         botConnectionReportLabel(provider, domain, phase),
+		Message:       strings.Join(lines, "\n"),
+		ErrorType:     "BotConnectionDiagnostic",
+		ErrorMessage:  summary,
+		TopFrame:      "bot." + safeBotReportSegment(phase),
+		OccurredAt:    occurredAt,
+	}
+	detail, err := json.Marshal(payload)
+	if err != nil {
+		return ""
+	}
+	return string(detail)
+}
+
+func botConnectionReportSummary(code, message string) string {
+	switch strings.TrimSpace(code) {
+	case "config_load_failed":
+		return "desktop bot config could not be loaded: " + scrubSensitiveText(message)
+	case "connection_missing":
+		return "bot connection record was not found"
+	case "connection_not_connected":
+		return "bot connection is not connected: " + scrubSensitiveText(message)
+	case "secret_missing":
+		return "required bot credential is not available"
+	case "test_send_failed":
+		return "bot test message failed: " + scrubSensitiveText(message)
+	default:
+		if strings.TrimSpace(message) == "" {
+			return strings.TrimSpace(code)
+		}
+		return scrubSensitiveText(message)
+	}
+}
+
+func botConnectionReportLabel(provider, domain, phase string) string {
+	parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)}
+	return strings.Trim(strings.Join(parts, "."), ".")
+}
+
+func safeBotReportSegment(s string) string {
+	s = strings.ToLower(strings.TrimSpace(s))
+	if s == "" {
+		return "unknown"
+	}
+	var b strings.Builder
+	for _, r := range s {
+		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+			b.WriteRune(r)
+			continue
+		}
+		if b.Len() == 0 || strings.HasSuffix(b.String(), ".") {
+			continue
+		}
+		b.WriteByte('.')
+	}
+	out := strings.Trim(b.String(), ".")
+	if out == "" {
+		return "unknown"
+	}
+	return out
+}
+
+func safeBotReportValue(s string) string {
+	s = safeBotReportSegment(s)
+	if len(s) > 80 {
+		return s[:80]
+	}
+	return s
+}
+
+func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) {
+	// The official registration SDK always begins on the Feishu accounts domain.
+	// Lark tenants are detected from the first poll response, then polling moves
+	// to the Lark accounts domain for the final credential exchange.
+	beginDomain := "feishu"
+	data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{
+		"action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id",
+	})
+	if err != nil {
+		return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
+	}
+	deviceCode := stringValue(data["device_code"])
+	verifyURL := stringValue(data["verification_uri_complete"])
+	userCode := stringValue(data["user_code"])
+	if deviceCode == "" || verifyURL == "" {
+		return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil
+	}
+	qrURL, err := feishuRegistrationQRCodeURL(verifyURL)
+	if err != nil {
+		return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
+	}
+	installID := randomInstallID()
+	interval := intValue(data["interval"], 5)
+	expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300)
+	a.mu.Lock()
+	if a.botInstalls == nil {
+		a.botInstalls = map[string]*botInstallSession{}
+	}
+	a.botInstalls[installID] = &botInstallSession{
+		Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode,
+		StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second),
+	}
+	a.mu.Unlock()
+	return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil
+}
+
+func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) {
+	pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu")
+	data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode})
+	if err != nil {
+		return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+	}
+	if errText := stringValue(data["error"]); errText != "" {
+		if errText == "authorization_pending" || errText == "slow_down" {
+			return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil
+		}
+		a.deleteBotInstall(installID)
+		return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil
+	}
+	if statusCode >= 400 {
+		a.deleteBotInstall(installID)
+		return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil
+	}
+	if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" {
+		a.mu.Lock()
+		if current := a.botInstalls[installID]; current != nil {
+			current.PollDomain = "lark"
+		}
+		a.mu.Unlock()
+		return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil
+	}
+	appID := stringValue(data["client_id"])
+	appSecret := stringValue(data["client_secret"])
+	if appID == "" || appSecret == "" {
+		return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil
+	}
+	a.deleteBotInstall(installID)
+	domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data)
+	userID := feishuInstallUserID(data)
+	secretEnv := "FEISHU_BOT_APP_SECRET"
+	if domain == "lark" {
+		secretEnv = "LARK_BOT_APP_SECRET"
+	}
+	if err := upsertDotEnv(secretEnv, appSecret); err != nil {
+		return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+	}
+	label := "飞书"
+	if domain == "lark" {
+		label = "Lark"
+	}
+	conn, err := a.upsertBotConnection(config.BotConnectionConfig{
+		ID:         connectionID("feishu", domain),
+		Provider:   "feishu",
+		Domain:     domain,
+		Label:      label,
+		Enabled:    true,
+		Status:     "connected",
+		Access:     botInstallAccess(userID),
+		Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
+	}, func(c *config.Config) {
+		c.Bot.Enabled = true
+		c.Bot.Feishu.Enabled = true
+		c.Bot.Feishu.Domain = domain
+		c.Bot.Feishu.AppID = appID
+		c.Bot.Feishu.AppSecretEnv = secretEnv
+		c.Bot.Feishu.Mode = "websocket"
+		c.Bot.Feishu.RequireMention = true
+		c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID)
+	})
+	if err != nil {
+		return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
+	}
+	a.refreshBotRuntimeAsync()
+	return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil
+}
+
+func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) {
+	now := time.Now().UTC().Format(time.RFC3339)
+	if conn.CreatedAt == "" {
+		conn.CreatedAt = now
+	}
+	conn.UpdatedAt = now
+	if conn.Status == "" {
+		conn.Status = "connected"
+	}
+	if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
+		conn.ToolApprovalMode = "ask"
+	}
+	if conn.ID == "" {
+		conn.ID = connectionID(conn.Provider, conn.Domain)
+	}
+	err := a.applyConfigOnly(func(c *config.Config) error {
+		if updateLegacy != nil {
+			updateLegacy(c)
+		}
+		replaced := false
+		for i, existing := range c.Bot.Connections {
+			if existing.ID == conn.ID {
+				conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
+				if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
+					conn.Access = existing.Access
+				}
+				c.Bot.Connections[i] = conn
+				replaced = true
+				break
+			}
+		}
+		if !replaced {
+			c.Bot.Connections = append(c.Bot.Connections, conn)
+		}
+		return nil
+	})
+	return botConnectionView(conn), err
+}
+
+func (a *App) rememberBotConnectionRemote(id, remoteID string) error {
+	id = strings.TrimSpace(id)
+	remoteID = strings.TrimSpace(remoteID)
+	if id == "" || remoteID == "" {
+		return nil
+	}
+	now := time.Now().UTC().Format(time.RFC3339)
+	return a.applyConfigOnly(func(c *config.Config) error {
+		for i := range c.Bot.Connections {
+			if c.Bot.Connections[i].ID != id {
+				continue
+			}
+			for j := range c.Bot.Connections[i].SessionMappings {
+				if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID {
+					workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot)
+					scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot)
+					c.Bot.Connections[i].SessionMappings[j].Scope = scope
+					c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot)
+					c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now
+					c.Bot.Connections[i].UpdatedAt = now
+					return nil
+				}
+			}
+			scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot)
+			c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{
+				RemoteID:      remoteID,
+				SessionID:     "",
+				Scope:         scope,
+				WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot),
+				UpdatedAt:     now,
+			})
+			c.Bot.Connections[i].UpdatedAt = now
+			return nil
+		}
+		return nil
+	})
+}
+
+func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string {
+	for _, mapping := range mappings {
+		if strings.TrimSpace(mapping.RemoteID) != "" {
+			return strings.TrimSpace(mapping.RemoteID)
+		}
+	}
+	return ""
+}
+
+func (a *App) deleteBotInstall(installID string) {
+	a.mu.Lock()
+	delete(a.botInstalls, installID)
+	a.mu.Unlock()
+}
+
+func normalizeBotInstallTarget(provider, domain string) (string, string) {
+	provider = strings.ToLower(strings.TrimSpace(provider))
+	domain = strings.ToLower(strings.TrimSpace(domain))
+	if provider == "lark" {
+		provider = "feishu"
+		domain = "lark"
+	}
+	if provider == "weixin" || provider == "wechat" {
+		return "weixin", "weixin"
+	}
+	if domain != "lark" {
+		domain = "feishu"
+	}
+	return "feishu", domain
+}
+
+func feishuAccountsBase(domain string) string {
+	if domain == "lark" {
+		return "https://accounts.larksuite.com"
+	}
+	return "https://accounts.feishu.cn"
+}
+
+func feishuRegistrationQRCodeURL(rawURL string) (string, error) {
+	parsedURL, err := url.Parse(rawURL)
+	if err != nil {
+		return "", err
+	}
+	query := parsedURL.Query()
+	query.Set("from", "sdk")
+	query.Set("tp", "sdk")
+	query.Set("source", "go-sdk")
+	parsedURL.RawQuery = query.Encode()
+	return parsedURL.String(), nil
+}
+
+func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) {
+	data, status, err := postFeishuInstallFormResult(base, body)
+	if err != nil {
+		return nil, err
+	}
+	if status >= 400 {
+		return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"])))
+	}
+	return data, nil
+}
+
+func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	reqBody := url.Values{}
+	for k, v := range body {
+		reqBody.Set(k, v)
+	}
+	req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
+	if err != nil {
+		return nil, 0, err
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, 0, err
+	}
+	defer resp.Body.Close()
+	var out map[string]any
+	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+		return nil, resp.StatusCode, err
+	}
+	return out, resp.StatusCode, nil
+}
+
+func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
+	return BotConnectionView{
+		ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
+		Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
+		Access: botAccessViewFromConfig(conn.Access),
+		Credential: BotConnectionCredentialView{
+			AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
+			SecretSet: botCredentialSecretSet(conn),
+		},
+		SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot),
+		LastError:       conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt,
+	}
+}
+
+func botCredentialSecretSet(conn config.BotConnectionConfig) bool {
+	if conn.Credential.AppSecretEnv != "" {
+		return envIsSet(conn.Credential.AppSecretEnv)
+	}
+	if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) {
+		return true
+	}
+	if conn.Provider == "weixin" {
+		return weixin.HasSavedAccount(conn.Credential.AccountID)
+	}
+	return false
+}
+
+func feishuInstallDomain(fallback string, data map[string]any) string {
+	if userInfo, ok := data["user_info"].(map[string]any); ok {
+		if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") {
+			return "lark"
+		}
+		return "feishu"
+	}
+	if strings.EqualFold(fallback, "lark") {
+		return "lark"
+	}
+	return "feishu"
+}
+
+func feishuInstallUserID(data map[string]any) string {
+	if userInfo, ok := data["user_info"].(map[string]any); ok {
+		return firstNonEmptyBot(
+			stringValue(userInfo["open_id"]),
+			stringValue(userInfo["union_id"]),
+			stringValue(userInfo["user_id"]),
+		)
+	}
+	return ""
+}
+
+func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView {
+	if connections == nil {
+		return []BotConnectionView{}
+	}
+	out := make([]BotConnectionView, 0, len(connections))
+	for _, conn := range connections {
+		out = append(out, botConnectionView(conn))
+	}
+	return out
+}
+
+func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
+	return config.BotConnectionConfig{
+		ID:               strings.TrimSpace(view.ID),
+		Provider:         strings.TrimSpace(view.Provider),
+		Domain:           strings.TrimSpace(view.Domain),
+		Label:            strings.TrimSpace(view.Label),
+		Enabled:          view.Enabled,
+		Status:           strings.TrimSpace(view.Status),
+		Model:            strings.TrimSpace(view.Model),
+		ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
+		WorkspaceRoot:    strings.TrimSpace(view.WorkspaceRoot),
+		Access:           botAccessConfigFromView(view.Access),
+		Credential: config.BotConnectionCredential{
+			AppID:        strings.TrimSpace(view.Credential.AppID),
+			AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
+			AccountID:    strings.TrimSpace(view.Credential.AccountID),
+			TokenEnv:     strings.TrimSpace(view.Credential.TokenEnv),
+		},
+		SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot),
+		LastError:       strings.TrimSpace(view.LastError),
+		CreatedAt:       strings.TrimSpace(view.CreatedAt),
+		UpdatedAt:       strings.TrimSpace(view.UpdatedAt),
+	}
+}
+
+func normalizeBotConnectionToolApprovalMode(mode string) string {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "ask":
+		return "ask"
+	case "auto":
+		return "auto"
+	case "yolo", "full", "full-access", "bypass":
+		return "yolo"
+	default:
+		return ""
+	}
+}
+
+func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig {
+	if views == nil {
+		return nil
+	}
+	out := make([]config.BotConnectionConfig, 0, len(views))
+	for _, view := range views {
+		cfg := botConnectionConfig(view)
+		if cfg.ID == "" || cfg.Provider == "" {
+			continue
+		}
+		out = append(out, cfg)
+	}
+	return out
+}
+
+func botMappingScope(scope, workspaceRoot string) string {
+	if strings.TrimSpace(scope) == "project" {
+		return "project"
+	}
+	if strings.TrimSpace(workspaceRoot) != "" {
+		return "project"
+	}
+	return "global"
+}
+
+func botMappingWorkspaceRoot(scope, workspaceRoot string) string {
+	if botMappingScope(scope, workspaceRoot) != "project" {
+		return ""
+	}
+	return strings.TrimSpace(workspaceRoot)
+}
+
+func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView {
+	if mappings == nil {
+		return []BotConnectionSessionMappingView{}
+	}
+	out := make([]BotConnectionSessionMappingView, 0, len(mappings))
+	for _, m := range mappings {
+		workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
+		scope := botMappingScope(m.Scope, workspaceRoot)
+		out = append(out, BotConnectionSessionMappingView{
+			RemoteID:      m.RemoteID,
+			SessionID:     m.SessionID,
+			SessionSource: m.SessionSource,
+			ChatType:      m.ChatType,
+			UserID:        m.UserID,
+			ThreadID:      m.ThreadID,
+			Scope:         scope,
+			WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
+			UpdatedAt:     m.UpdatedAt,
+		})
+	}
+	return out
+}
+
+func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping {
+	if mappings == nil {
+		return nil
+	}
+	out := make([]config.BotConnectionSessionMapping, 0, len(mappings))
+	for _, m := range mappings {
+		workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
+		scope := botMappingScope(m.Scope, workspaceRoot)
+		out = append(out, config.BotConnectionSessionMapping{
+			RemoteID:      strings.TrimSpace(m.RemoteID),
+			SessionID:     strings.TrimSpace(m.SessionID),
+			SessionSource: strings.TrimSpace(m.SessionSource),
+			ChatType:      strings.TrimSpace(m.ChatType),
+			UserID:        strings.TrimSpace(m.UserID),
+			ThreadID:      strings.TrimSpace(m.ThreadID),
+			Scope:         scope,
+			WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
+			UpdatedAt:     strings.TrimSpace(m.UpdatedAt),
+		})
+	}
+	return out
+}
+
+func connectionID(provider, domain string) string {
+	return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
+}
+
+func botInstallAccess(userID string) config.BotAccessConfig {
+	userID = strings.TrimSpace(userID)
+	access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
+	if userID != "" {
+		access.Users = []string{userID}
+	}
+	return access
+}
+
+func randomInstallID() string {
+	var b [12]byte
+	if _, err := rand.Read(b[:]); err != nil {
+		return fmt.Sprintf("install-%d", time.Now().UnixNano())
+	}
+	return hex.EncodeToString(b[:])
+}
+
+func envIsSet(name string) bool {
+	return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != ""
+}
+
+func firstAny(values ...any) any {
+	for _, value := range values {
+		if value != nil {
+			return value
+		}
+	}
+	return nil
+}
+
+func firstNonEmptyBot(values ...string) string {
+	for _, value := range values {
+		if strings.TrimSpace(value) != "" {
+			return value
+		}
+	}
+	return ""
+}
+
+func appendUniqueBotString(values []string, next string) []string {
+	next = strings.TrimSpace(next)
+	if next == "" {
+		return values
+	}
+	for _, value := range values {
+		if strings.TrimSpace(value) == next {
+			return values
+		}
+	}
+	return append(values, next)
+}
+
+func stringValue(value any) string {
+	if value == nil {
+		return ""
+	}
+	return strings.TrimSpace(fmt.Sprint(value))
+}
+
+func intValue(value any, fallback int) int {
+	switch v := value.(type) {
+	case float64:
+		if v > 0 {
+			return int(v)
+		}
+	case int:
+		if v > 0 {
+			return v
+		}
+	case string:
+		var n int
+		if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
+			return n
+		}
+	}
+	return fallback
+}
+
+func weixinInstallStatusMessage(status string) string {
+	switch status {
+	case "scaned":
+		return "已扫码,请在微信里确认。"
+	case "scaned_but_redirect":
+		return "已扫码,正在切换微信授权节点。"
+	default:
+		return "等待扫码。"
+	}
+}
diff --git a/desktop/bot_connection_app_test.go b/desktop/bot_connection_app_test.go
new file mode 100644
index 0000000..39f59d5
--- /dev/null
+++ b/desktop/bot_connection_app_test.go
@@ -0,0 +1,620 @@
+package main
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	"reasonix/internal/config"
+)
+
+func TestNormalizeBotInstallTarget(t *testing.T) {
+	cases := []struct {
+		provider     string
+		domain       string
+		wantProvider string
+		wantDomain   string
+	}{
+		{provider: "lark", wantProvider: "feishu", wantDomain: "lark"},
+		{provider: "feishu", domain: "lark", wantProvider: "feishu", wantDomain: "lark"},
+		{provider: "wechat", wantProvider: "weixin", wantDomain: "weixin"},
+		{provider: "weixin", domain: "anything", wantProvider: "weixin", wantDomain: "weixin"},
+		{provider: "unknown", domain: "unknown", wantProvider: "feishu", wantDomain: "feishu"},
+	}
+	for _, tc := range cases {
+		gotProvider, gotDomain := normalizeBotInstallTarget(tc.provider, tc.domain)
+		if gotProvider != tc.wantProvider || gotDomain != tc.wantDomain {
+			t.Fatalf("normalizeBotInstallTarget(%q,%q) = %q,%q; want %q,%q", tc.provider, tc.domain, gotProvider, gotDomain, tc.wantProvider, tc.wantDomain)
+		}
+	}
+}
+
+func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
+	pollCount := 0
+	var beginHost string
+	var pollHosts []string
+	var actions []string
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/oauth/v1/app/registration" {
+			http.NotFound(w, r)
+			return
+		}
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		switch r.Form.Get("action") {
+		case "begin":
+			beginHost = r.Header.Get("X-Test-Original-Host")
+			actions = append(actions, "begin")
+			if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" {
+				http.Error(w, "wrong begin form", http.StatusBadRequest)
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"device_code":               "dev-lark",
+				"verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE",
+				"user_code":                 "CODE",
+				"interval":                  3,
+				"expire_in":                 300,
+			})
+		case "poll":
+			pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
+			actions = append(actions, "poll")
+			if r.Form.Get("device_code") != "dev-lark" {
+				http.Error(w, "wrong device code", http.StatusBadRequest)
+				return
+			}
+			pollCount++
+			if pollCount == 1 {
+				writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"client_id":     "cli-1",
+				"client_secret": "secret-1",
+				"user_info":     map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"},
+			})
+		default:
+			http.Error(w, "unknown action", http.StatusBadRequest)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("lark", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" {
+		t.Fatalf("start result = %+v, want ok lark-capable QR result", start)
+	}
+	qrURL, err := url.Parse(start.URL)
+	if err != nil {
+		t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err)
+	}
+	query := qrURL.Query()
+	if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
+		t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query)
+	}
+	if qrURL.Host != "open.feishu.cn" {
+		t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host)
+	}
+
+	pending, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall pending: %v", err)
+	}
+	if pending.Done || pending.Status != "pending" {
+		t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
+	}
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done {
+		t.Fatalf("poll result = %+v, want done", poll)
+	}
+	if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" {
+		t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection)
+	}
+	if beginHost != "accounts.feishu.cn" {
+		t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost)
+	}
+	if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
+		t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
+	}
+	if got := strings.Join(actions, ","); got != "begin,poll,poll" {
+		t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got)
+	}
+	if poll.Connection.WorkspaceRoot != "" {
+		t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
+	}
+	if poll.Connection.Credential.AppID != "cli-1" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
+		t.Fatalf("credential = %+v, want stored Lark secret", poll.Connection.Credential)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention {
+		t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu)
+	}
+	if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" {
+		t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
+	}
+	if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil {
+		t.Fatalf("unset lark secret env: %v", err)
+	}
+	reloaded, err := config.Load()
+	if err != nil {
+		t.Fatalf("reload config: %v", err)
+	}
+	if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" {
+		t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got)
+	}
+	if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
+		t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
+	}
+}
+
+func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
+	pollCount := 0
+	var beginHost string
+	var pollHosts []string
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/oauth/v1/app/registration" {
+			http.NotFound(w, r)
+			return
+		}
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		switch r.Form.Get("action") {
+		case "begin":
+			beginHost = r.Header.Get("X-Test-Original-Host")
+			writeJSON(t, w, map[string]any{
+				"device_code":               "dev-feishu",
+				"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
+				"user_code":                 "CODE",
+				"interval":                  3,
+				"expire_in":                 300,
+			})
+		case "poll":
+			pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
+			if r.Form.Get("device_code") != "dev-feishu" {
+				http.Error(w, "wrong device code", http.StatusBadRequest)
+				return
+			}
+			pollCount++
+			if pollCount == 1 {
+				writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"client_id":     "cli-lark",
+				"client_secret": "secret-lark",
+				"user_info":     map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"},
+			})
+		default:
+			http.Error(w, "unknown action", http.StatusBadRequest)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("feishu", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" {
+		t.Fatalf("start result = %+v, want Feishu QR result", start)
+	}
+	pending, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall pending: %v", err)
+	}
+	if pending.Done || pending.Status != "pending" {
+		t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
+	}
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" {
+		t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll)
+	}
+	if beginHost != "accounts.feishu.cn" {
+		t.Fatalf("begin host = %q, want Feishu accounts host", beginHost)
+	}
+	if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
+		t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
+	}
+}
+
+func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") })
+	var hosts []string
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/oauth/v1/app/registration" {
+			http.NotFound(w, r)
+			return
+		}
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host"))
+		switch r.Form.Get("action") {
+		case "begin":
+			writeJSON(t, w, map[string]any{
+				"device_code":               "dev-feishu",
+				"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
+				"user_code":                 "CODE",
+				"interval":                  3,
+				"expire_in":                 300,
+			})
+		case "poll":
+			writeJSON(t, w, map[string]any{
+				"client_id":     "cli-feishu",
+				"client_secret": "secret-feishu",
+				"user_info":     map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"},
+			})
+		default:
+			http.Error(w, "unknown action", http.StatusBadRequest)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("feishu", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Domain != "feishu" || start.InstallID == "" {
+		t.Fatalf("start result = %+v, want ok Feishu QR result", start)
+	}
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done {
+		t.Fatalf("poll result = %+v, want done", poll)
+	}
+	if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" {
+		t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection)
+	}
+	if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
+		t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential)
+	}
+	if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" {
+		t.Fatalf("registration hosts = %q, want Feishu begin and poll", got)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" {
+		t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu)
+	}
+	if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" {
+		t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
+	}
+	if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil {
+		t.Fatalf("unset feishu secret env: %v", err)
+	}
+	reloaded, err := config.Load()
+	if err != nil {
+		t.Fatalf("reload config: %v", err)
+	}
+	if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" {
+		t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got)
+	}
+	if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
+		t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
+	}
+}
+
+func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/ilink/bot/get_bot_qrcode":
+			if r.URL.Query().Get("bot_type") != "3" {
+				http.Error(w, "missing bot type", http.StatusBadRequest)
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"qrcode":             "qr-weixin",
+				"qrcode_img_content": "data:image/png;base64,abc",
+			})
+		case "/ilink/bot/get_qrcode_status":
+			if r.URL.Query().Get("qrcode") != "qr-weixin" {
+				http.Error(w, "wrong qr", http.StatusBadRequest)
+				return
+			}
+			writeJSON(t, w, map[string]any{
+				"status":        "confirmed",
+				"ilink_bot_id":  "weixin-account",
+				"bot_token":     "token-1",
+				"ilink_user_id": "user-1",
+				"baseurl":       "https://ilinkai.weixin.qq.com",
+			})
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+
+	app := NewApp()
+	start, err := app.StartBotConnectionInstall("weixin", "")
+	if err != nil {
+		t.Fatalf("StartBotConnectionInstall: %v", err)
+	}
+	if !start.OK || start.Provider != "weixin" || start.Domain != "weixin" || start.URL != "data:image/png;base64,abc" || start.DeviceCode != "qr-weixin" {
+		t.Fatalf("start result = %+v, want weixin QR result", start)
+	}
+
+	poll, err := app.PollBotConnectionInstall(start.InstallID)
+	if err != nil {
+		t.Fatalf("PollBotConnectionInstall: %v", err)
+	}
+	if !poll.Done {
+		t.Fatalf("poll result = %+v, want done", poll)
+	}
+	if poll.Connection.Provider != "weixin" || poll.Connection.Domain != "weixin" || poll.Connection.Credential.AccountID != "weixin-account" {
+		t.Fatalf("connection = %+v, want weixin account connection", poll.Connection)
+	}
+	if poll.Connection.WorkspaceRoot != "" {
+		t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
+	}
+	if poll.Connection.Credential.TokenEnv != "WEIXIN_BOT_TOKEN" || !poll.Connection.Credential.SecretSet {
+		t.Fatalf("credential = %+v, want saved account to count as configured token", poll.Connection.Credential)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" {
+		t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin)
+	}
+	if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
+		t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
+	}
+	reloaded, err := config.Load()
+	if err != nil {
+		t.Fatalf("reload config: %v", err)
+	}
+	if len(reloaded.Bot.Connections) != 1 {
+		t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections)
+	}
+	reloadedConnection := botConnectionView(reloaded.Bot.Connections[0])
+	if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet {
+		t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential)
+	}
+}
+
+func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
+	qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
+	if err != nil {
+		t.Fatalf("feishuRegistrationQRCodeURL: %v", err)
+	}
+	parsed, err := url.Parse(qrURL)
+	if err != nil {
+		t.Fatalf("parse QR URL: %v", err)
+	}
+	query := parsed.Query()
+	if query.Get("user_code") != "ABCD-1234" {
+		t.Fatalf("user_code = %q, want preserved code", query.Get("user_code"))
+	}
+	if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
+		t.Fatalf("query = %v, want SDK registration metadata", query)
+	}
+}
+
+func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "")
+	app := NewApp()
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:            "feishu-lark",
+		Provider:      "feishu",
+		Domain:        "lark",
+		Label:         "Lark",
+		Enabled:       true,
+		Status:        "connected",
+		WorkspaceRoot: "/Users/alice/work/reasonix",
+		Credential: config.BotConnectionCredential{
+			AppID:        "cli-private",
+			AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
+		},
+		SessionMappings: []config.BotConnectionSessionMapping{{
+			RemoteID:      "ou-private",
+			SessionID:     "session-private",
+			Scope:         "project",
+			WorkspaceRoot: "/Users/alice/work/reasonix",
+		}},
+	}, nil); err != nil {
+		t.Fatalf("upsert connection: %v", err)
+	}
+
+	diag, err := app.DiagnoseBotConnection("feishu-lark")
+	if err != nil {
+		t.Fatalf("DiagnoseBotConnection: %v", err)
+	}
+	if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
+		t.Fatalf("diagnostic = %+v, want warning credential report", diag)
+	}
+	for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} {
+		if strings.Contains(diag.ReportDetail, leaked) {
+			t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail)
+		}
+	}
+	var payload frontendCrashPayload
+	if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
+		t.Fatalf("report detail is not structured JSON: %v", err)
+	}
+	if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" {
+		t.Fatalf("payload = %+v, want bot runtime credential label", payload)
+	}
+	for _, want := range []string{
+		"app_secret_env_configured: true",
+		"secret_available: false",
+		"workspace_scope: project",
+		"session_mappings: 1",
+		"summary: required bot credential is not available",
+	} {
+		if !strings.Contains(payload.Message, want) {
+			t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want)
+		}
+	}
+	report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail)
+	if err != nil {
+		t.Fatalf("crashReportFromDetail: %v", err)
+	}
+	if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" {
+		t.Fatalf("report = %+v, want accepted bot report", report)
+	}
+}
+
+func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) {
+	conn := config.BotConnectionConfig{
+		ID:       "feishu-lark",
+		Provider: "feishu",
+		Domain:   "lark",
+		Label:    "Lark",
+		Enabled:  true,
+		Status:   "connected",
+		Credential: config.BotConnectionCredential{
+			AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
+		},
+	}
+	diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true)
+	if diag.ReportKind != "bot" || diag.ReportDetail == "" {
+		t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag)
+	}
+	if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") {
+		t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail)
+	}
+	var payload frontendCrashPayload
+	if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
+		t.Fatalf("report detail is not structured JSON: %v", err)
+	}
+	if !strings.Contains(payload.ErrorMessage, "[redacted-env]") {
+		t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage)
+	}
+}
+
+func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:       "weixin-weixin",
+		Provider: "weixin",
+		Domain:   "weixin",
+		Label:    "微信",
+		Enabled:  true,
+		Status:   "connected",
+		Credential: config.BotConnectionCredential{
+			AccountID: "missing-account",
+		},
+	}, nil); err != nil {
+		t.Fatalf("upsert connection: %v", err)
+	}
+
+	diag, err := app.DiagnoseBotConnection("weixin-weixin")
+	if err != nil {
+		t.Fatalf("DiagnoseBotConnection: %v", err)
+	}
+	if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
+		t.Fatalf("diagnostic = %+v, want missing local credential warning", diag)
+	}
+	if strings.Contains(diag.ReportDetail, "missing-account") {
+		t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail)
+	}
+}
+
+func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	app := NewApp()
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:       "feishu-lark",
+		Provider: "feishu",
+		Domain:   "lark",
+		Label:    "kun",
+		Enabled:  true,
+		Status:   "connected",
+	}, nil); err != nil {
+		t.Fatalf("upsert global connection: %v", err)
+	}
+	if err := app.rememberBotConnectionRemote("feishu-lark", "ou_global"); err != nil {
+		t.Fatalf("remember global remote: %v", err)
+	}
+	cfg := config.LoadForEdit(config.UserConfigPath())
+	if got := cfg.Bot.Connections[0].SessionMappings[0]; got.Scope != "global" || got.WorkspaceRoot != "" || got.RemoteID != "ou_global" {
+		t.Fatalf("global mapping = %+v, want scope=global without workspace", got)
+	}
+
+	if _, err := app.upsertBotConnection(config.BotConnectionConfig{
+		ID:            "weixin-project",
+		Provider:      "weixin",
+		Domain:        "weixin",
+		Label:         "project",
+		Enabled:       true,
+		Status:        "connected",
+		WorkspaceRoot: "/tmp/reasonix-project",
+	}, nil); err != nil {
+		t.Fatalf("upsert project connection: %v", err)
+	}
+	if err := app.rememberBotConnectionRemote("weixin-project", "wxid_project"); err != nil {
+		t.Fatalf("remember project remote: %v", err)
+	}
+	cfg = config.LoadForEdit(config.UserConfigPath())
+	var projectMapping config.BotConnectionSessionMapping
+	for _, conn := range cfg.Bot.Connections {
+		if conn.ID == "weixin-project" && len(conn.SessionMappings) == 1 {
+			projectMapping = conn.SessionMappings[0]
+		}
+	}
+	if projectMapping.Scope != "project" || projectMapping.WorkspaceRoot != "/tmp/reasonix-project" || projectMapping.RemoteID != "wxid_project" {
+		t.Fatalf("project mapping = %+v, want project scope and workspace", projectMapping)
+	}
+}
+
+func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
+	t.Helper()
+	w.Header().Set("Content-Type", "application/json")
+	if err := json.NewEncoder(w).Encode(value); err != nil {
+		t.Fatalf("write json: %v", err)
+	}
+}
+
+func withRewrittenHTTP(t *testing.T, handler http.Handler) {
+	t.Helper()
+	server := httptest.NewServer(handler)
+	target, err := url.Parse(server.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	previous := http.DefaultTransport
+	http.DefaultTransport = rewriteHTTPTransport{target: target, next: previous}
+	t.Cleanup(func() {
+		http.DefaultTransport = previous
+		server.Close()
+	})
+}
+
+type rewriteHTTPTransport struct {
+	target *url.URL
+	next   http.RoundTripper
+}
+
+func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	clone := req.Clone(req.Context())
+	clone.Header.Set("X-Test-Original-Host", req.URL.Host)
+	clone.URL.Scheme = r.target.Scheme
+	clone.URL.Host = r.target.Host
+	clone.Host = r.target.Host
+	if r.next == nil {
+		r.next = http.DefaultTransport
+	}
+	clone.URL.Path = "/" + strings.TrimLeft(clone.URL.Path, "/")
+	return r.next.RoundTrip(clone)
+}
diff --git a/desktop/bot_event_sink.go b/desktop/bot_event_sink.go
new file mode 100644
index 0000000..f95c9b3
--- /dev/null
+++ b/desktop/bot_event_sink.go
@@ -0,0 +1,179 @@
+package main
+
+import (
+	"context"
+	"log"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+const botForwardSendTimeout = 30 * time.Second
+const botForwardQueueSize = 64
+
+// ── Forward target ──────────────────────────────────────────────────────────
+
+// botForwardTarget identifies one remote chat to send forwarded events to.
+type botForwardTarget struct {
+	ConnID   string
+	Domain   string
+	ChatID   string
+	ChatType bot.ChatType
+}
+
+// ── Event forwarder ─────────────────────────────────────────────────────────
+
+// botEventForwarder implements event.Sink and forwards relevant events to
+// connected bot channels through the desktopBotRuntime. It is attached to a
+// tabEventSink when a heartbeat task should push AI output to IM channels.
+//
+// It accumulates Text events and sends them as complete messages on TurnDone
+// (and occasionally during generation when the buffer grows large enough), so
+// the remote side sees progressive streaming output rather than one big blob.
+type botEventForwarder struct {
+	runtime *desktopBotRuntime
+	targets []botForwardTarget
+
+	mu        sync.Mutex
+	buf       strings.Builder
+	queueMu   sync.Mutex
+	queue     chan string
+	closed    bool
+	closeOnce sync.Once
+}
+
+// newBotEventForwarder creates a forwarder that sends to all given targets.
+// runtime may be nil — Emit calls are then no-ops.
+func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
+	f := &botEventForwarder{
+		runtime: runtime,
+		targets: targets,
+		queue:   make(chan string, botForwardQueueSize),
+	}
+	go f.run()
+	return f
+}
+
+// Emit implements event.Sink. It forwards text and lifecycle events to the
+// connected bot channels; reasoning, tool dispatch, and other internal events
+// are dropped to avoid noisy IM output.
+func (f *botEventForwarder) Emit(e event.Event) {
+	if f.runtime == nil || len(f.targets) == 0 {
+		return
+	}
+	switch e.Kind {
+	case event.TurnStarted:
+		f.mu.Lock()
+		f.buf.Reset()
+		f.mu.Unlock()
+
+	case event.Text:
+		f.mu.Lock()
+		f.buf.WriteString(e.Text)
+		size := f.buf.Len()
+		f.mu.Unlock()
+		// Flush opportunistically when the buffer crosses a threshold, so long
+		// streams (e.g. "tell me three jokes") produce multiple messages.
+		if size >= 400 {
+			f.flush()
+		}
+
+	case event.TurnDone:
+		f.flush()
+		f.Close()
+
+	case event.ApprovalRequest:
+		// The heartbeat turn belongs to the desktop tab controller, not the bot
+		// gateway session, so remote /approve replies cannot satisfy this ID.
+		text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
+		text += "\n请回到桌面窗口处理。"
+		f.sendToAll(text)
+
+	case event.AskRequest:
+		var qb strings.Builder
+		qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
+		for i, q := range e.Ask.Questions {
+			if i > 0 {
+				qb.WriteString("\n")
+			}
+			qb.WriteString(q.Prompt)
+		}
+		qb.WriteString("\n请回到桌面窗口处理。")
+		f.sendToAll(qb.String())
+
+	case event.Notice:
+		if e.Level == event.LevelWarn {
+			f.sendToAll("⚠️ " + e.Text)
+		}
+
+	case event.CompactionStarted:
+		f.sendToAll("🔄 正在压缩上下文...")
+	}
+}
+
+// flush sends the accumulated buffer as one message per target channel.
+func (f *botEventForwarder) flush() {
+	f.mu.Lock()
+	text := strings.TrimSpace(f.buf.String())
+	if text == "" {
+		f.mu.Unlock()
+		return
+	}
+	f.buf.Reset()
+	f.mu.Unlock()
+
+	f.sendToAll(text)
+}
+
+// sendToAll dispatches text to every target channel. Errors are logged and
+// non-fatal; a failed target does not block other targets.
+func (f *botEventForwarder) sendToAll(text string) {
+	text = strings.TrimSpace(text)
+	if f.runtime == nil || len(f.targets) == 0 || text == "" {
+		return
+	}
+	f.queueMu.Lock()
+	defer f.queueMu.Unlock()
+	if f.closed {
+		return
+	}
+	select {
+	case f.queue <- text:
+	default:
+		log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
+	}
+}
+
+func (f *botEventForwarder) run() {
+	for text := range f.queue {
+		f.sendToAllNow(text)
+	}
+}
+
+func (f *botEventForwarder) sendToAllNow(text string) {
+	for _, tgt := range f.targets {
+		ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
+		_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
+			ChatID:   tgt.ChatID,
+			ChatType: tgt.ChatType,
+			Text:     text,
+		})
+		cancel()
+		if err != nil {
+			log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
+		}
+	}
+}
+
+func (f *botEventForwarder) Close() {
+	f.closeOnce.Do(func() {
+		f.flush()
+		f.queueMu.Lock()
+		f.closed = true
+		close(f.queue)
+		f.queueMu.Unlock()
+	})
+}
diff --git a/desktop/bot_event_sink_test.go b/desktop/bot_event_sink_test.go
new file mode 100644
index 0000000..f2fd5e0
--- /dev/null
+++ b/desktop/bot_event_sink_test.go
@@ -0,0 +1,133 @@
+package main
+
+import (
+	"context"
+	"io"
+	"log/slog"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/event"
+)
+
+type desktopForwardTestAdapter struct {
+	platform bot.Platform
+	name     string
+	messages chan bot.InboundMessage
+	entered  chan struct{}
+	release  chan struct{}
+	sent     chan bot.OutboundMessage
+	once     sync.Once
+}
+
+func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
+	return &desktopForwardTestAdapter{
+		platform: bot.PlatformFeishu,
+		name:     "forward-test",
+		messages: make(chan bot.InboundMessage),
+		entered:  make(chan struct{}),
+		release:  make(chan struct{}),
+		sent:     make(chan bot.OutboundMessage, 8),
+	}
+}
+
+func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
+func (a *desktopForwardTestAdapter) Name() string           { return a.name }
+func (a *desktopForwardTestAdapter) Start(context.Context) error {
+	return nil
+}
+func (a *desktopForwardTestAdapter) Stop() error { return nil }
+func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
+	return nil
+}
+func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
+	return a.messages
+}
+func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
+	a.once.Do(func() { close(a.entered) })
+	select {
+	case <-a.release:
+	case <-ctx.Done():
+		return bot.SendResult{}, ctx.Err()
+	}
+	a.sent <- msg
+	return bot.SendResult{MessageID: "sent"}, nil
+}
+
+func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
+	gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
+		ID:       "feishu-lark",
+		Domain:   "lark",
+		Platform: bot.PlatformFeishu,
+		Adapter:  adapter,
+	}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+	return &desktopBotRuntime{gw: gw}
+}
+
+func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
+	adapter := newDesktopForwardTestAdapter()
+	forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
+		ConnID:   "feishu-lark",
+		Domain:   "lark",
+		ChatID:   "oc-group-1",
+		ChatType: bot.ChatGroup,
+	}})
+
+	done := make(chan struct{})
+	go func() {
+		forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
+		close(done)
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("Emit blocked behind slow bot send")
+	}
+	select {
+	case <-adapter.entered:
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("adapter send did not start")
+	}
+
+	forwarder.Close()
+	close(adapter.release)
+	select {
+	case <-adapter.sent:
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("queued bot message was not sent after adapter release")
+	}
+}
+
+func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
+	adapter := newDesktopForwardTestAdapter()
+	close(adapter.release)
+	forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
+		ConnID:   "feishu-lark",
+		Domain:   "lark",
+		ChatID:   "oc-group-1",
+		ChatType: bot.ChatGroup,
+	}})
+
+	forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
+		ID:      "approval-1",
+		Tool:    "shell",
+		Subject: "run command",
+	}})
+	forwarder.Close()
+
+	select {
+	case msg := <-adapter.sent:
+		if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
+			t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
+		}
+		if !strings.Contains(msg.Text, "桌面") {
+			t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
+		}
+	case <-time.After(500 * time.Millisecond):
+		t.Fatal("approval notice was not sent")
+	}
+}
diff --git a/desktop/bot_runtime_app.go b/desktop/bot_runtime_app.go
new file mode 100644
index 0000000..8a4089a
--- /dev/null
+++ b/desktop/bot_runtime_app.go
@@ -0,0 +1,415 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"os"
+	"strings"
+	"sync"
+	"time"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+)
+
+type BotRuntimeStatusView struct {
+	Running     bool   `json:"running"`
+	Status      string `json:"status"`
+	Message     string `json:"message"`
+	Connections int    `json:"connections"`
+	StartedAt   string `json:"startedAt"`
+}
+
+type desktopBotRuntime struct {
+	// lifecycleMu serializes start/stop transitions so two apply/stop calls
+	// can't race a gateway into existence. The slow work (gw.Stop teardown,
+	// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
+	// never block on a restart.
+	lifecycleMu sync.Mutex
+	mu          sync.Mutex
+	cancel      context.CancelFunc
+	gw          *bot.BotGateway
+	status      BotRuntimeStatusView
+}
+
+func newDesktopBotRuntime() *desktopBotRuntime {
+	return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
+}
+
+func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
+	channel := bot.ChannelConfig{
+		Model:            strings.TrimSpace(qq.Model),
+		ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
+		WorkspaceRoot:    strings.TrimSpace(qq.WorkspaceRoot),
+	}
+	if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
+		return channels, connectionChannels
+	}
+	if channels == nil {
+		channels = make(map[bot.Platform]bot.ChannelConfig)
+	}
+	if _, ok := channels[bot.PlatformQQ]; !ok {
+		channels[bot.PlatformQQ] = channel
+	}
+	if connectionChannels == nil {
+		connectionChannels = make(map[string]bot.ChannelConfig)
+	}
+	if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
+		connectionChannels[string(bot.PlatformQQ)] = channel
+	}
+	return channels, connectionChannels
+}
+
+func (a *App) refreshBotRuntimeAsync() {
+	if a.ctx == nil {
+		return
+	}
+	a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
+}
+
+func (a *App) refreshBotRuntime() {
+	// NewApp always pre-fills botRuntime; a nil here means a test-constructed
+	// App with no bot runtime, which must not lazily create one from a
+	// background goroutine (that would race a concurrent refresh).
+	if a.botRuntime == nil {
+		return
+	}
+	var watcherVersion uint64
+	if a.botBridge != nil {
+		watcherVersion = a.botBridge.watcherVersion()
+	}
+	cfg, err := a.loadDesktopBotConfig()
+	if err != nil {
+		a.botRuntime.stop("error", err.Error())
+		return
+	}
+	// Assign through a typed local so a nil *botBridgeHub never becomes a
+	// non-nil bot.DesktopBridge interface inside the gateway config.
+	var bridge bot.DesktopBridge
+	if a.botBridge != nil {
+		// 配置是订阅的持久化事实源:每次运行时重算前重新种子,桌面重启后
+		// /desktop watch 的订阅继续生效。
+		a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
+		bridge = a.botBridge
+	}
+	_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
+}
+
+func (a *App) loadDesktopBotConfig() (*config.Config, error) {
+	// Read-only load feeding the bot runtime and connection diagnostics. It
+	// must load credentials: the runtime resolves app secrets and control
+	// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
+	// credential-free view load would leave unset on a fresh process.
+	cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
+	if err != nil {
+		return nil, err
+	}
+	return cfg, nil
+}
+
+func (a *App) stopBotRuntime() {
+	if a.botRuntime != nil {
+		a.botRuntime.stop("stopped", "bot runtime stopped")
+	}
+}
+
+func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
+	if a.botRuntime == nil {
+		return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}
+	}
+	return a.botRuntime.snapshot()
+}
+
+func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
+	if r == nil {
+		return nil
+	}
+	if parent == nil {
+		parent = context.Background()
+	}
+	plan := desktopBotRuntimePlan(cfg)
+	r.lifecycleMu.Lock()
+	defer r.lifecycleMu.Unlock()
+	r.stopCurrent()
+	if !plan.Start {
+		r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
+		return nil
+	}
+
+	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
+	ctx, cancel := context.WithCancel(parent)
+	modelName := botruntime.ModelName(cfg, "")
+	channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
+	connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
+	channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
+	gwCfg := bot.GatewayConfig{
+		Model:              modelName,
+		ToolApprovalMode:   cfg.Bot.ToolApprovalMode,
+		MaxSteps:           cfg.Bot.MaxSteps,
+		QueueMode:          cfg.Bot.QueueMode,
+		QueueCap:           cfg.Bot.QueueCap,
+		QueueDrop:          cfg.Bot.QueueDrop,
+		PairingEnabled:     cfg.Bot.Pairing.Enabled,
+		PairingTTL:         time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
+		PairingMaxPending:  cfg.Bot.Pairing.MaxPendingPerPlatform,
+		IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
+		SelfUserIDs: map[bot.Platform][]string{
+			bot.PlatformQQ:     cfg.Bot.SelfUserIDs.QQ,
+			bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
+			bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
+		},
+		ControlEnabled:     cfg.Bot.Control.Enabled,
+		ControlAddr:        cfg.Bot.Control.Addr,
+		ControlToken:       os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
+		WorkspaceRoot:      workspaceRoot,
+		Channels:           channels,
+		ConnectionChannels: connectionChannels,
+		Routes:             botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
+		ConnectionAccess:   botruntime.ConnectionAccessConfigs(cfg),
+		Enabled:            plan.Enabled,
+		Allowlist: bot.AllowlistConfig{
+			Enabled:  cfg.Bot.Allowlist.Enabled,
+			AllowAll: cfg.Bot.Allowlist.AllowAll,
+			Users: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQUsers,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
+			},
+			Approvers: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQApprovers,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
+			},
+			Admins: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQAdmins,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
+			},
+			Groups: map[bot.Platform][]string{
+				bot.PlatformQQ:     cfg.Bot.Allowlist.QQGroups,
+				bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
+				bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
+			},
+		},
+		Debounce:                 time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
+		OnInbound:                botruntime.NewRemoteRememberer(logger),
+		OnSessionReady:           botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
+		OnToolApprovalModeChange: onToolApprovalModeChange,
+		Desktop:                  bridge,
+	}
+	bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
+	if len(bindings) == 0 {
+		cancel()
+		r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
+		return nil
+	}
+	gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
+	if err := gw.Start(ctx); err != nil {
+		cancel()
+		gw.Stop()
+		r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
+		return err
+	}
+	runningConnections := gw.AdapterCount()
+	startErrors := gw.StartErrors()
+	status := "running"
+	message := fmt.Sprintf("%d bot connection(s) running", runningConnections)
+	if len(startErrors) > 0 {
+		status = "degraded"
+		message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
+	}
+	r.mu.Lock()
+	r.cancel = cancel
+	r.gw = gw
+	r.status = BotRuntimeStatusView{
+		Running:     true,
+		Status:      status,
+		Message:     message,
+		Connections: runningConnections,
+		StartedAt:   time.Now().UTC().Format(time.RFC3339),
+	}
+	r.mu.Unlock()
+	return nil
+}
+
+func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error {
+	mode = normalizeBotConnectionToolApprovalMode(mode)
+	if mode == "" {
+		return nil
+	}
+	return a.applyConfigOnly(func(c *config.Config) error {
+		id := strings.TrimSpace(msg.ConnectionID)
+		now := time.Now().UTC().Format(time.RFC3339)
+		if id != "" {
+			for i := range c.Bot.Connections {
+				if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id {
+					c.Bot.Connections[i].ToolApprovalMode = mode
+					c.Bot.Connections[i].UpdatedAt = now
+					return nil
+				}
+			}
+		}
+		c.Bot.ToolApprovalMode = mode
+		return nil
+	})
+}
+
+func summarizeBotRuntimeErrors(errs []error) string {
+	parts := make([]string, 0, len(errs))
+	for _, err := range errs {
+		if err == nil {
+			continue
+		}
+		parts = append(parts, err.Error())
+	}
+	if len(parts) == 0 {
+		return ""
+	}
+	if len(parts) > 3 {
+		hidden := len(parts) - 3
+		parts = append(parts[:3], fmt.Sprintf("%d more", hidden))
+	}
+	return strings.Join(parts, "; ")
+}
+
+type botRuntimePlan struct {
+	Start   bool
+	Status  string
+	Message string
+	Enabled map[bot.Platform]bool
+}
+
+func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
+	if cfg == nil {
+		return botRuntimePlan{Status: "error", Message: "config is unavailable"}
+	}
+	if !cfg.Bot.Enabled {
+		return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
+	}
+	if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
+		return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
+	}
+	enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
+	if len(unknown) > 0 {
+		return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")}
+	}
+	if !botruntime.HasEnabledPlatform(enabled) {
+		return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"}
+	}
+	return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled}
+}
+
+func (r *desktopBotRuntime) stop(status, message string) {
+	r.lifecycleMu.Lock()
+	defer r.lifecycleMu.Unlock()
+	r.stopCurrent()
+	r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
+}
+
+// stopCurrent detaches the running gateway under r.mu, then tears it down
+// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
+// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
+func (r *desktopBotRuntime) stopCurrent() {
+	r.mu.Lock()
+	cancel := r.cancel
+	gw := r.gw
+	r.cancel = nil
+	r.gw = nil
+	r.mu.Unlock()
+	if cancel != nil {
+		cancel()
+	}
+	if gw != nil {
+		gw.Stop()
+	}
+}
+
+func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
+	r.mu.Lock()
+	r.status = status
+	r.mu.Unlock()
+}
+
+func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return r.status
+}
+
+// updateConnectionToolApprovalMode updates a connection's tool approval mode
+// on the running gateway without restarting. Returns true if updated, false if
+// the gateway is not running or the connection is unknown.
+func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if r.gw == nil {
+		return false
+	}
+	mode = normalizeBotConnectionToolApprovalMode(mode)
+	// Update ConnectionChannels in the internal GatewayConfig so new sessions
+	// pick up the mode. Existing sessions are updated by the gateway directly.
+	r.gw.UpdateConnectionToolApprovalMode(connID, mode)
+	return true
+}
+
+// SendToAdapter sends a message through the running gateway's adapter
+// identified by connID. Returns an error if the gateway is not running
+// or no matching adapter is found.
+func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
+	r.mu.Lock()
+	gw := r.gw
+	r.mu.Unlock()
+	if gw == nil {
+		return bot.SendResult{}, nil // gateway not running — silent no-op
+	}
+	return gw.SendToAdapter(ctx, connID, domain, msg)
+}
+
+// Running returns true if the bot gateway is currently active.
+func (r *desktopBotRuntime) Running() bool {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return r.gw != nil
+}
+
+// ForwardTargets returns the list of bot forward targets derived from the
+// current config's bot connections and their session mappings. Each mapping
+// produces one target (connID + chatID + chatType) for event forwarding.
+func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
+	if cfg == nil {
+		return nil
+	}
+	var targets []botForwardTarget
+	seen := make(map[botForwardTarget]bool)
+	for _, conn := range cfg.Bot.Connections {
+		if !conn.Enabled {
+			continue
+		}
+		connID := botruntime.ConnectionRuntimeID(conn)
+		domain := strings.TrimSpace(conn.Domain)
+		for _, sm := range conn.SessionMappings {
+			remoteID := strings.TrimSpace(sm.RemoteID)
+			if remoteID == "" {
+				continue
+			}
+			chatType := bot.ChatDM
+			if sm.ChatType != "" {
+				chatType = bot.ChatType(sm.ChatType)
+			}
+			target := botForwardTarget{
+				ConnID:   connID,
+				Domain:   domain,
+				ChatID:   remoteID,
+				ChatType: chatType,
+			}
+			if seen[target] {
+				continue
+			}
+			seen[target] = true
+			targets = append(targets, target)
+		}
+	}
+	return targets
+}
diff --git a/desktop/bot_runtime_app_test.go b/desktop/bot_runtime_app_test.go
new file mode 100644
index 0000000..2919848
--- /dev/null
+++ b/desktop/bot_runtime_app_test.go
@@ -0,0 +1,504 @@
+package main
+
+import (
+	"errors"
+	"io"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"reasonix/internal/bot"
+	"reasonix/internal/botruntime"
+	"reasonix/internal/config"
+)
+
+func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = true
+	cfg.Bot.Allowlist.Enabled = true
+	cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
+	cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+		{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if !plan.Start {
+		t.Fatalf("plan = %+v, want start", plan)
+	}
+	if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
+		t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
+	}
+}
+
+func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = true
+	cfg.Bot.Allowlist.Enabled = true
+	cfg.Bot.Pairing.Enabled = false
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if plan.Start || plan.Status != "blocked" {
+		t.Fatalf("plan = %+v, want blocked without allowlist", plan)
+	}
+}
+
+func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = true
+	cfg.Bot.Allowlist.Enabled = true
+	cfg.Bot.Pairing.Enabled = true
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if !plan.Start {
+		t.Fatalf("plan = %+v, want start with pairing enabled", plan)
+	}
+}
+
+func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
+	channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
+		Model:            "qq-model",
+		ToolApprovalMode: "auto",
+		WorkspaceRoot:    "/tmp/qq-project",
+	}, nil, nil)
+
+	channel, ok := channels[bot.PlatformQQ]
+	if !ok {
+		t.Fatalf("platform QQ channel missing: %+v", channels)
+	}
+	if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
+		t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
+	}
+	connectionChannel, ok := connectionChannels["qq"]
+	if !ok {
+		t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
+	}
+	if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
+		t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
+	}
+}
+
+func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Enabled = false
+	cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
+	cfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
+	}
+
+	plan := desktopBotRuntimePlan(cfg)
+	if plan.Start || plan.Status != "stopped" {
+		t.Fatalf("plan = %+v, want stopped when disabled", plan)
+	}
+}
+
+func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
+	cfg := config.Default()
+	cfg.Bot.Connections = []config.BotConnectionConfig{{
+		ID:       "feishu-lark",
+		Provider: "feishu",
+		Domain:   "lark",
+		Enabled:  true,
+		SessionMappings: []config.BotConnectionSessionMapping{
+			{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
+			{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
+			{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
+		},
+	}}
+
+	targets := newDesktopBotRuntime().ForwardTargets(cfg)
+	if len(targets) != 2 {
+		t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
+	}
+	seen := map[string]bool{}
+	for _, target := range targets {
+		key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
+		if seen[key] {
+			t.Fatalf("duplicate target %q in %+v", key, targets)
+		}
+		seen[key] = true
+	}
+	if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
+		t.Fatalf("targets = %+v, want group and dm targets", targets)
+	}
+}
+
+func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.LoadForEdit(config.UserConfigPath())
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
+	userCfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[bot]
+enabled = false
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	got, err := NewApp().loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	plan := desktopBotRuntimePlan(got)
+	if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
+		t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
+	}
+}
+
+func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
+	isolateDesktopUserDirs(t)
+	t.Cleanup(func() {
+		_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
+		_ = os.Unsetenv("LARK_BOT_APP_SECRET")
+	})
+
+	userCfg := config.Default()
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
+	userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
+	userCfg.Bot.Feishu.Enabled = true
+	userCfg.Bot.Weixin.Enabled = true
+	userCfg.Bot.Weixin.AccountID = "weixin-account"
+	userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
+	userCfg.Bot.Connections = []config.BotConnectionConfig{
+		{
+			ID:       "feishu-feishu",
+			Provider: "feishu",
+			Domain:   "feishu",
+			Enabled:  true,
+			Status:   "connected",
+			Credential: config.BotConnectionCredential{
+				AppID:        "cli-feishu",
+				AppSecretEnv: "FEISHU_BOT_APP_SECRET",
+			},
+		},
+		{
+			ID:       "feishu-lark",
+			Provider: "feishu",
+			Domain:   "lark",
+			Enabled:  true,
+			Status:   "connected",
+			Credential: config.BotConnectionCredential{
+				AppID:        "cli-lark",
+				AppSecretEnv: "LARK_BOT_APP_SECRET",
+			},
+		},
+		{
+			ID:       "weixin-weixin",
+			Provider: "weixin",
+			Domain:   "weixin",
+			Enabled:  true,
+			Status:   "connected",
+			Credential: config.BotConnectionCredential{
+				AccountID: "weixin-account",
+				TokenEnv:  "WEIXIN_BOT_TOKEN",
+			},
+		},
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+	if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
+		t.Fatalf("create credentials dir: %v", err)
+	}
+	if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
+		t.Fatalf("write credentials: %v", err)
+	}
+	weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
+	if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
+		t.Fatalf("create weixin account dir: %v", err)
+	}
+	if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
+		t.Fatalf("write weixin account: %v", err)
+	}
+	_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
+	_ = os.Unsetenv("LARK_BOT_APP_SECRET")
+
+	got, err := NewApp().loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	views := botConnectionViews(got.Bot.Connections)
+	if len(views) != 3 {
+		t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
+	}
+	for _, view := range views {
+		if !view.Credential.SecretSet {
+			t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
+		}
+	}
+	plan := desktopBotRuntimePlan(got)
+	if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
+		t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
+	}
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+	bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
+	if len(bindings) != 3 {
+		t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
+	}
+}
+
+func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.Default()
+	if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
+		t.Fatalf("set desktop appearance: %v", err)
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+label = "Lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	app := NewApp()
+	got, err := app.loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
+	}
+
+	// The bot-runtime load is a pure read: the merge above stays in memory and
+	// the user config file is not rewritten.
+	preWrite := config.LoadForEdit(config.UserConfigPath())
+	if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
+		t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
+	}
+
+	// The first locked write path performs the on-disk migration.
+	if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
+		t.Fatalf("applyConfigOnly: %v", err)
+	}
+	persisted := config.LoadForEdit(config.UserConfigPath())
+	if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
+	}
+	if persisted.DesktopTheme() != "dark" {
+		t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
+	}
+}
+
+func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[desktop]
+theme = "dark"
+
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+label = "Lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	app := NewApp()
+	got, err := app.loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
+	}
+
+	// The bot-runtime load is a pure read: it serves the legacy config from
+	// memory and must not create the user config file.
+	if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
+		t.Fatalf("read path must not create the user config, stat err = %v", err)
+	}
+
+	// The first locked write path creates the user config with the migrated
+	// bot settings (adopting the legacy config, ConfigVersion-bumped).
+	if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
+		t.Fatalf("applyConfigOnly: %v", err)
+	}
+	persisted := config.LoadForEdit(config.UserConfigPath())
+	if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
+	}
+}
+
+func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[desktop]
+theme = "dark"
+close_behavior = "quit"
+
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+label = "Lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	settings := NewApp().Settings()
+	if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
+		t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
+	}
+	if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
+		t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
+	}
+
+	persisted := config.LoadForEdit(config.UserConfigPath())
+	if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
+		t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
+	}
+}
+
+func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	userCfg := config.Default()
+	userCfg.Bot.Enabled = true
+	userCfg.Bot.Allowlist.Enabled = true
+	userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
+	userCfg.Bot.Connections = []config.BotConnectionConfig{
+		{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
+	}
+	if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
+		t.Fatalf("save user config: %v", err)
+	}
+
+	project := robustTempDir(t)
+	if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
+[bot]
+enabled = true
+
+[bot.allowlist]
+enabled = true
+feishu_users = ["ou-legacy"]
+
+[[bot.connections]]
+id = "feishu-lark"
+provider = "feishu"
+domain = "lark"
+enabled = true
+status = "connected"
+`), 0o644); err != nil {
+		t.Fatalf("write project config: %v", err)
+	}
+
+	orig, _ := os.Getwd()
+	defer func() { _ = os.Chdir(orig) }()
+	if err := os.Chdir(project); err != nil {
+		t.Fatalf("chdir project: %v", err)
+	}
+
+	got, err := NewApp().loadDesktopBotConfig()
+	if err != nil {
+		t.Fatalf("load desktop bot config: %v", err)
+	}
+	if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
+		t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
+	}
+}
+
+func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
+	got := summarizeBotRuntimeErrors([]error{
+		errors.New("first"),
+		nil,
+		errors.New("second"),
+		errors.New("third"),
+		errors.New("fourth"),
+	})
+
+	for _, want := range []string{"first", "second", "third", "1 more"} {
+		if !strings.Contains(got, want) {
+			t.Fatalf("summary = %q, want %q", got, want)
+		}
+	}
+	if strings.Contains(got, "fourth") {
+		t.Fatalf("summary = %q, should cap extra errors", got)
+	}
+}
diff --git a/desktop/bound_array_contract_test.go b/desktop/bound_array_contract_test.go
new file mode 100644
index 0000000..d5b1f43
--- /dev/null
+++ b/desktop/bound_array_contract_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+	"encoding/json"
+	"reflect"
+	"strings"
+	"testing"
+)
+
+func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
+	isolateDesktopUserDirs(t)
+
+	app := NewApp()
+	cases := []struct {
+		name string
+		got  any
+	}{
+		{"Checkpoints", app.Checkpoints()},
+		{"ListSessions", app.ListSessions()},
+		{"ListTrashedSessions", app.ListTrashedSessions()},
+		{"ListWorkspaces", app.ListWorkspaces()},
+		{"History", app.History()},
+		{"Jobs", app.Jobs()},
+		{"Commands", app.Commands()},
+		{"Models", app.Models()},
+		{"ListDir", app.ListDir("__missing__")},
+		{"ListDirForTab", app.ListDirForTab("missing", "")},
+		{"SearchFileRefsForTab", app.SearchFileRefsForTab("missing", "file")},
+		{"ListTabs", app.ListTabs()},
+		{"ListProjectTree", app.ListProjectTree()},
+		{"AvailableSubagentTools", app.AvailableSubagentTools()},
+		{"AutoResearchList", app.AutoResearchList("missing")},
+		{"AutoResearchFindings", app.AutoResearchFindings("missing", 10)},
+		{"MCPServers", app.MCPServers()},
+		{"Plugins", app.Plugins()},
+		{"HeartbeatListTasks", app.HeartbeatListTasks()},
+		{"HeartbeatReloadTasks", app.HeartbeatReloadTasks()},
+	}
+	for _, tc := range cases {
+		assertNonNilSliceJSON(t, tc.name, tc.got)
+	}
+
+	if got := app.SlashArgs("/skill "); got.Items == nil {
+		t.Fatal("SlashArgs().Items is nil; frontend expects []")
+	}
+	if got := app.WorkspaceChanges(""); got.Files == nil {
+		t.Fatal(`WorkspaceChanges("").Files is nil; frontend expects []`)
+	}
+	if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
+		t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
+	}
+	if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
+		t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
+	}
+	if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
+		got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
+		got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
+		got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
+		got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
+		t.Fatalf("Settings() contains nil array fields: %+v", got)
+	}
+	if got := app.DesktopStartupSettings(); got.StatusBarItems == nil ||
+		got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
+		got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
+		t.Fatalf("DesktopStartupSettings() contains nil array fields: %+v", got)
+	}
+
+	boundPayloads := []struct {
+		name string
+		got  any
+	}{
+		{"CapabilityDiagnostics", app.CapabilityDiagnostics(false)},
+		{"Capabilities", app.Capabilities()},
+		{"SkillsSettings", app.SkillsSettings()},
+		{"HistoryPage", app.HistoryPage(0, 20)},
+		{"Effort", app.Effort()},
+		{"Memory", app.Memory()},
+		{"MemorySuggestions", app.MemorySuggestions()},
+	}
+	for _, tc := range boundPayloads {
+		assertRequiredJSONSlicesNonNil(t, tc.name, reflect.ValueOf(tc.got))
+	}
+}
+
+func assertNonNilSliceJSON(t *testing.T, name string, got any) {
+	t.Helper()
+	v := reflect.ValueOf(got)
+	if v.Kind() != reflect.Slice {
+		t.Fatalf("%s returned %T, want slice", name, got)
+	}
+	if v.IsNil() {
+		t.Fatalf("%s returned nil slice; frontend expects []", name)
+	}
+	raw, err := json.Marshal(got)
+	if err != nil {
+		t.Fatalf("%s JSON marshal: %v", name, err)
+	}
+	if string(raw) == "null" {
+		t.Fatalf("%s JSON encoded as null; frontend expects []", name)
+	}
+}
+
+func assertRequiredJSONSlicesNonNil(t *testing.T, path string, value reflect.Value) {
+	t.Helper()
+	for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
+		if value.IsNil() {
+			return
+		}
+		value = value.Elem()
+	}
+
+	switch value.Kind() {
+	case reflect.Slice, reflect.Array:
+		if value.Kind() == reflect.Slice && value.IsNil() {
+			t.Fatalf("%s is a nil slice; JSON contract requires []", path)
+		}
+		for i := 0; i < value.Len(); i++ {
+			assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
+		}
+	case reflect.Struct:
+		typ := value.Type()
+		for i := 0; i < value.NumField(); i++ {
+			fieldType := typ.Field(i)
+			if fieldType.PkgPath != "" {
+				continue
+			}
+			jsonTag := fieldType.Tag.Get("json")
+			if jsonTag == "-" || strings.Contains(jsonTag, ",omitempty") {
+				continue
+			}
+			fieldPath := path + "." + fieldType.Name
+			assertRequiredJSONSlicesNonNil(t, fieldPath, value.Field(i))
+		}
+	}
+}
diff --git a/desktop/build/appicon.png b/desktop/build/appicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..fc06d8fff79602b2d154adc5e6d0cc2799625573
GIT binary patch
literal 62398
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
F`5)am#(e+)

literal 0
HcmV?d00001

diff --git a/desktop/build/appicon.svg b/desktop/build/appicon.svg
new file mode 100644
index 0000000..05d6b1f
--- /dev/null
+++ b/desktop/build/appicon.svg
@@ -0,0 +1,7 @@
+
+
+  
+    
+    
+  
+
diff --git a/desktop/build/darwin/entitlements.plist b/desktop/build/darwin/entitlements.plist
new file mode 100644
index 0000000..1a05582
--- /dev/null
+++ b/desktop/build/darwin/entitlements.plist
@@ -0,0 +1,17 @@
+
+
+
+
+	
+	com.apple.security.cs.allow-jit
+	
+	com.apple.security.cs.allow-unsigned-executable-memory
+	
+	com.apple.security.cs.disable-library-validation
+	
+
+
diff --git a/desktop/build/darwin/icon.icns b/desktop/build/darwin/icon.icns
new file mode 100644
index 0000000000000000000000000000000000000000..20dba5725ba247416e700679ab1638fdb39fc7a2
GIT binary patch
literal 318836
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�z{L;p;ql-+mm;H8;$}H|l37f44Mz
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

i1Utx*y^FHgl?mjG&pW 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 z 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 OmYq{EF2*MAmJ!|#JJo!Q^>eK_y@~Y;xXk>` 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 au50W0hCQi(FI?=o1pCFl|LfnM%l`-EI!_J& literal 0 HcmV?d00001 diff --git a/desktop/build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..29c3661ac3a2075fc08d13a834799afe1ece7a55 GIT binary patch literal 12244 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 literal 0 HcmV?d00001 diff --git a/desktop/build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..96c565b7e2a11c3e0e976425620a3929445f259b GIT binary patch literal 1456 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 IrKv~UKil-*N&o-= literal 0 HcmV?d00001 diff --git a/desktop/build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..6ea84a5c82d3c9d98b4804337a795d8c62759114 GIT binary patch literal 25141 zcmZ^KWmFqXuy)YkTBJAy3KS?5C@uloV#N!zxD>9L3II?!XSQfap#H!%Q_Z7R0H7W;}_!L6XJO#{DF^O zOi)COpO1@=PmGU`IxT|g|08g4`fOwA^M4cA{FN+25pev!8Qg6gEZp2p9i0Df8mS37 zcL2bYLh+5v2k(WWERWnLGgN)jwYM#d@vwI-a!=4?WD`h!6Yz1o?R_WnibP#T<|dly zMW`7QkAec{FTqK+Wu%zUHoR}i=hX?T(P7N_&Cby1Qd+yX)4Ut3c_+J!c~eoaT)#1q zUCZdjjMK@!7a?J(sdXMKk`mlA{zHY3XKG+vQ|m*k#r`FxT$r@Lv{O}!v9273K_ntx zaN4iNcjG4mzU+$C59SI8p^A6^_*_a-9KWDrUEb*fjjwBTo>e+$x#Mo$a%8se>i9-4 zf#k83^=ISDz*xtI{1z!c&uFsjKm%FJe2Y8EP-4<8EO{B*T)a^|UgixmpCf8Pf&eN=+FS zL@%2eIjhbRyv_BqG{IyfE|QYdwzz$haAnS&Z}XNJ=Z5O_Z!g8C_w8!bEwe-8GZX~N zzY5XERNF_9f?Fg@CIy=Pwwu5exsL?xDkZlIV-r#IIrl^!h4~&94bnSFHC;rjm#MM8(W+lYgG2EzTdgpo(?9IAqijV^1$Z6 zb$W_u)Kj?_af^KEyEuNnIMrM!f|(XB0%+_fr17UfZ9Gn6LWZoXTv5IL%iQP9uJ4JVC)J|_i2 z#XjO4rP>b6Rz4);V8A^aQL_*O*#5A^H21-1s@nvwC4{g@{g z@moSQ5^5xv%w8sGFPzJKxz1ty7lien7GRA$5Fal2PM3D!{-aGFxk7>s-`mV!q7ig>G6*@@qB|zU*Z&uOhFd zZFk*DukP*k{T0+^RBPZ^G9+hIrR%w(lU^l;UoF20U3k2#=H=N|XYir&GE9UhBPM`4tjW)xT;_>(f3pq z=Ls#|$Z7}M)~rl|8m1oSaKx$&PIy5Wv>uulmO&%-5d1r!qC_xtgK#~F_FfN6jYvZD z7+74ZsJ-k9Qhl65-VBDD|6@>@QqcVejq{4};N;5hL!G!raZw@klU297?Jui>cZ7EO zFH=G#QHDrXCzlS*t$u!QDpG1@Y}_Y?`^e2c4iqPl@Q^j*G z!HZ#WcKw}))Xqxl4MTNZDh;6{|D|9>59YtK)OpxxC#2}%MQNz_6Xo_aGA~-hCsVl$ z-o`q}gP)T4?Z7Ld;O;+71q{W_ajOQSkUM2%zgKstaYNZ((W7a*Gz=pn16w-B(_6WZ zkZxaDpe{W=M|w!ht&Vs(Li&^FE&hRhDwI%M(SitGU#uNPGTdrsDa0{(m;SBlBPh1; zlCroSLG>S4VgG{^fSksx{nz_hvgo~~IRX9D+;~F!et4Q3)Qv@W1g~Z}K~I)38ED}+ zX+FEJ_Hy1%2bMVE^Qg0#-$bkGKO^CXpLe+!X>5buWGS4XcFf+d$K7*JD|PWKz4e^U z>5(jDFyK*&j@@;Ymf@`xd2)j^3vTJZ+r?YmFlrVz_~htceEKy`9@)WYW_{MsX89Q; zv`6~Lf`DaQMvY%c4nAvt}oAw>KOlss`5 zdcOIWm4wt7(~9jI%dte3oDXU!c+%B;<8}kw`&EErd{;we4U>jhrQ<7AQD%)KoV(L( zb>RvBbSBMsVJUgspE%6V9+;iNM?+^;1oSRNc`^^hhTv92qx_?cc+zg$3C#i?g%ymlh<7trr7}ywJ>3U$Km)F+~)mfZA^#pJ;L~mgb ze8_vi&f-qFVR;-hG+H!RM{5V`!D^SwHVHH?<{{O^KNC3gSqHOIbFix8#o_s9Wo2;) z;j+r3SkBm!H*Xq#69hn;ej7#>jXTYwReiggK?FJ_%{(j#$;GHNxK9VNz$qU^=pSdJ ztDTj(^Nw22KW&~VpDkQ~=CQl}y`yys#ylbw)d`c?$+WuC<6h`uqKNy*u{r!iMH~QO zxB*Jo>`oaH5*lS*wXxnl9-eL<6V)HYK0 zJuv{JqbgHxb+x*7E8KqnzulXUvhV=OZ2^`{>ksx8ZZZhw8y!rnZ(zyfHKWp%&=@M3 zUB+|#SDj37UVAi?s8V$YB>!I81!8{#!Lz)BfUDeAW`JS7$6j{GjzDqA#A7cv>#=PoCDKa)T{#Mca`R#x?cX%S&k4MLK%xP@HeTz*)4vE-aw(o+(&i30{Y$^I2;-COoT#%Nn@{UL8h8{-<#GdLM^z|`$A(kG@ybiy%LAk>z zwGa-oU}vfUU&X_qHWPexYS&2I$N-tFgLF?dt9iG;ab=Y+7knR_Lo37_wfZVu zvOF(O>mX5fGLSZ>eF%*@(~XB7kFz#7v?@f0Da&(q!}4KKgUT36_Nu81`EBI~1=M=` zPC1C`M{*PPIi84alhVNk^L<~zyf4$=IJLF{V{CatD-n!NmvK?`pr@V7B<2Qn-!TCW zqHJh&CZ*`oHbGi6yRs~Ipz~gR11x4QxA#0lPVHUe7>mOK{|oaud8T3jsSj8`y^K)! zmXP-2LiF009f51*XReBb=N5LQT-fhw+SO6H|;_ zJ$GI%W!`FDyom$Xt6XlL_lJNdA09g7_WVjli7j#Lg1ZLf85HhyMWLQEY5TD67r`9h zCL6>j5oBM|nBiFG{i1x2j9t;V3>5St-#U!A?+0mnGou5WR`LDvU&9k_}{RnWPAXSia2zO8;@ znqX9Y(mFomLF0ZAc8D2RJCes{eWdLecD{X3kKbLcA<{kGy706mpsv2xgTE!Cl235c zO)cmN%5%Km@r?_8x?tR;{|0o5z9v0qDbx5uMeMd6-&mN=K)?9?QG;8>*-C&k$;Bn z!!hk6NH(3+m)hljch(@FhfbeoyGZuRNX4``|DZRwZw)fo)6SyPRV0wTOKls4$obH= zyyH<7J9}5Z2=ZzYuf@B-f3x;4)0;eexbw`g6yBEiJF(VkxCF3RpMp#(v9}(Hdk8Rp zbc0`(`Mqmc(I{`9-!U%=dhcg>TA-`;@TwRSbDuo>CO%!tHi8ANG~&op7bH^;sM`h z-M)~J4QL8~CHp&q5N2~RM`8q7H!-$H4LzphN_$j}%tH*4)ule48Amu)7hmq!d(u@_ z7;KO{9+>D7xuE=bFUC^jU(yXF~s1LE~PH&%4uT>WXiXGy-g45C09dxqZQ#zenOroa;p)+Lp*dz&WDO$53GJcdL5Tha{?qq(rE-6*Ji zY_Rd70!u(gN?-a;-rLz?%I5wd^omoB4A0jufHg(akQZl9bHholUFQW5r1d(iti#lX z^WYw}do{Eu`pjxk&4B^Z_=T6Y(PweNCyXEX zguIJ7od(fxP~oVXRvw-$E>@gSsLik;xw!%;3cWmxVTPzDY88INT7W?S3vkNm{AJ=~ zUsfWCB!PL6C=qHAHacdr%^octJg-62Oak_cySxDwO;R5CSZC+}} z+g+>O!ORY!=u#L*st!~ZGK{_UW`K-oa+3GmLezSJsR+#{y0i|FmnbcK9N@sj1M@Dx zcxte#f3Q6&>RTj(@#<;*12fbDDMS?35!)k@kXiw_wim7oZI*T(D2&8DI{#lv+P~{K z;eJ?glRDjV5X4X100KS2O6@5K+O&IAHAlYF^G!ng7xQ3$_AS8Az0bw)7iwOQ7_>?8 zy*$&bGp3S+hRGiX{BMhsMY_9M>Rajq9Y*j_m?y;ispcnjz+jvf5vYVS(stFSMfpGY zNLEaT_V(zlg}4uWn1&T&I(ZF_Y!IQI-7WqFwPa?P!Vi;Fc{yEWMFCwo7KiUpE0T@tS#kp%y;eU_R}j_5 z@@82_JBCJ(IjZqh}nBRhqn?o*@dtpo^ zwQd}Sii(z6Y;FDbED=p zCYqjIZ%u8y>%KFil~Qa<*hf!!k9)_S%$a1g44d0$1k`79)GpnM=T_eu={)-$ED?v; z@Kw8Vmw%Ma#$3;ie$#zKIXh9MD=WHp`3AZ30^QNY)yaT<47}Gdd}@mK*qN9Ku7ca- z=)5iYwdwth1ZwE@AW9A^ecbq2r)p-X_;~ulp*uQ~F%Eii(L-8;H`OY>w-<*pj|F}A zi)xZb+{8O#Z&r5h@vk*w3dpL4JkzhL@|LYRma+^``Q_hl0q{}e*SNt0MR~o+$X!ym84V@XYm@{tJ#f}@k*O0Gh3WnrO-k7yO zj6ojosJoGwMRI%}blhWp6E(32bd+kG>vOu`M1W}3wo!-l* z{DMxHk~@O^g}>n-Ts$wHJFmk!S^3A9^kG`tAM!Y_X2%CA*Nmji$+qNw$*BGP&h?D5 zTZNej74i|y=K8DSC5;ZfPXL4PS#W3vunjVb+Lh3p8Qfk^vUfL5={``v(U%TQr$;;HG%7QQEzZWQrs>2DYDl-=c(_OBAk|Q=@j{MSJ>pFw`7O~ z-W~mJr>JEVW7ld9>o!pIGJFc{bc4KT|NhfBZ{u%Q=y5FL=`qGyL|Eh8ON)ryr_olCh}^#eH^61#(U0V0UQdG8(W)FyYA zh`HwR=7-ePmkv=tqF8#@8<*?)vDvTDm7%xFNSE0JO_3enW(*kZH@ZUs1Z|@j=ZE;n zfEm>r5#G)R*+5BpT0(fcWXs|2Exwl1%*cr;W?O%QR6k5>nFih_{9x%XQBV-rumRWw z7e8*S6a91&%!3uYPr#!;Z~zdzZtmtj4g|nF|9}e7&!~;EVJOLd3KsoUtK(UF=Pxf! z0sfSOM};pB@rde56Vr`r>XRF0#09tLhP5W5Ks+=c9@!*>`Bj3UVR4rD1rax*9m0Q7 zvZ0-__vyi)t}&xqlQmC zN2LqJjUuu<;-q)!aDh5mdOLqGfO?rL$8F?S5Xt4iPh_g)?N2uMiCc1RMbywU9B99w zIW+4;yMhePj8C{L=lCJ z%G0i;5HbqvCggx;sX_X|*uJ5!*vkzoBintY^lDfNRPu00OyGIa25B^Y!|nJhr|I=P zzQTXoevoY4)2{gD^`$T;byhaQiRlzPz`&OG!VGjdB;_RAB`-@k@7|=&U0QuhoojQ;nG%2`slj#uU^26$ zewxn4i`83sZR_Fhgp~UT4tTgISm7(`LxkY2c6}um$7W3qW+5LFHJIrlO%hajAe<{t zV#fj1YGI{zN?>F3)H!0JSzYiJ6SfudTCfZL`ByG zSiwAuU!<~L?yQ${s%XT@eYVo*EN5ZzTDttnOxJhlVLowKJS8u|S9+Z3HSw!%Wu&+R zz8JveJdtu&<|Jb5%*e&KCuK|LPs}1l5wFqyHVsba!yNDe(xSomrG8mH;p-bgG^o?% z=oVUW#!{l4nxI5ckRt1K>)ku+nY?@YtyVuEVBVm%esa)^(GQ6~laky5m86`l$~QC< z0QecBvQyIQ0G#gFgcXHFRCxbN+-nN>jOy=*vosKO3>Np@pt3Q$Hjco*E7{O8Crm33 z2q56a?rK%KNELBC@=rY`czGpjj$n;ERCQiR#yTvx(w%vZHcjkYKTn690T0Lk~ew%m?Xe~Clph9LcI?@T8;tSGMFnr#&vd1ZObEq?*j5Nz!o<-8ozignM;;AtiK5e+ zN+UK0ult^S=gmd82*Yy|yUHGBBBwctGxdmqt*X|6)3>9G(78JNpr(%Sp%}B2X2$T; zrto}(n1a~mZb>{4q6_=Qjn$DWrI?FUE9-SXxnDBUJitWj==iQjf*H6YtZRpZMU1Id zizPEh9-?Mv+|zva-EG+pItQluWgz3cr*J_5r^)@%5hfihUeEVDq;Njxmr3c^L{_2j z1>GS6d>Bqvj;B+V6Hfe2;w-TLNPW9eHulg%Q7H41!?nr_DI>wr1G^@kiZ`i{o)dPz z6;(Ze^+Lc;=8c2(5zYww1;MW&wWWSZQWOo^Qqx`4nv%_7osNrW>7hExb_zV2$)uK( zEq9w+o-1!QB0V?ulg|UNzkVRjvcexh>|{=UuX`Y-x%^W%?nHuI9HUPHLhQ>9 zNdG}mMiy=_LkD0EGJkzd*%MTA_`3!+Saq1^&`M6+)yuB1KZCSw z28>bBIPk^qPP+OPx^hv%VAlo6Uktu%zu=VJP~s{zE>*kS+(EG*WAHb8vh7-p2kc+K zzMxJv0MhGa53_3H7H050*)gEC9?XGp_iI^21^VZTrB{Zl(4&Crg#Fm!u&YVFhKPo! z__}0Fr1RgQT0{6Oj(=xfFPa2pKcX8L)N zC@w%d^bltIz~z$xSyopkhB+?c72O)Hz{Jq4X+DfhX5)PNA_9=TXU!VF{2o7g+q~V` zNC-KM&Ow)+HfR;@U^+B(ISUrRH8{vhxsU=hh*x6_y%wJeVLakGRmrkX{wBJf4eS*8 zJ^tK5;3#A>2;2M)K!rYTq$6*~X=vH}kOO$a_P%{|PC?LumG&E9>7r87B@Mvn_UP#8 zcfz2Q3q(GFCS)>}_r@c1z&vG@k`|L)mN(Pc0qamtci(Me=-ibW@z%^4!_=2?6ciup z)vK3kLBLAW!L5&mQ&)v?T&&jx5W>-((Saw(d&`X-TxfGy zf~7~hUtoUi#?HwC&KH5cZ{)&-MR(%AXPL}LqUHL&DcLs)gRULJ@CdyLg7vOLJB(+5 z{SKI=TVG9KfWVOb2<6p1BrZYr>b-0(v2|44>@-$p_VN;k$ZSy7Ea3}op0f&wJOH7U zd3n)8&i@s)1uO54f*fD7>H`@A|GAJd_LB0%vU8wLr_)uI8XC9Nz`rl9YyGg!Ez-8PqyYK(p~$?}Cz1ZV`5`_qyjyEz;Vm{@ z2zCsY&Y2HIF;r@puA%~&qjf4kr!M)gzaGbd`nvwe%lp7A-YGOjw)g(6)q+-^Z<|Dh zy;WHS;g)ByV8fj4trEb26-x$QW&Z3lfeRqKO)(VjG~?6Yiu=x!JLj7*`Sq-m&OY-o zgFsTs%sD>pH81A|=k{MgxapM+><%(qa?FPY+Rt9EI!|6bQ!xoz)vDk8V9rL`K54~TpDIMnfMmr_3tW5=Jj((v_BTnO=iWmJyCi}Iet6HPtGsc zq^7*iK~SE>8HPxZ(R>vsnzZSfBSsjW>=Ev&@Q zF2{-ax=Js{l6i!Ay*5GIUKENndl{sqnSKX-S@8D8%RpVKW|f zau*U87WOaXs&e*gOTnRHLXXmMvbCPcmjI0GwvBoNTH-(EjEdQ?Bs6H#)d#2gi~oCYn%0{5XBSV3SHTCD zMvR@v-7)s8KZIu;GdgV^V&yMf!05?g$?cnOn8fzPrrXzU(62LS$ig}k_-8Mk{ zl>jHN68J5Wf!le-}R^`{C!a)bnHQ}3cnM(!9g zXSV&0^f1$fdo;}#E;iL~arHOG<7pwn4+umYO}6yZ&WOLqwa3?P`1$4ZD<~Ir*~H;CLbq`%acjfIw4s=2(cZ2K8tYhmLZ~3 zEx^mJUa=(C+HA}a+||%1*s6ndn6scWffp=LL&OAsrUj6Nbral_8bVc%+vpA$2zrFZ zmgruSsqRrVHg#Z+tO|u#g8oq7bNBxFEp1Gzgf=d`uENLd)a3AHoqHF#crE|Zuj_AX zwgdx$bLrBro>Xcq!K`WoY-P#oRvSKfX^U{n#6TJa5Q_tFTGB@BFFv-vl4rjAtRAqa zk@}(XA-gJNEhH!1=;{`?uJvPI_y?+s9VTawtq%K&+|+sN%ei--sv@izCviIG7(VuA zZN50xBKeV6wlYnB7=fEaY4{b%Fl+0FV5P@wm{)R<=5#BSy~>rH*E4^yM#wrZyGJ0d z$N1@x10(($@^3&h@o-J0eu}#E?|_=MvGszspa&7Hi5cR!)qqnBT@I2wZMBMWZ@H#} zDuVR560#jPx)$qLJH!my(aS6KxQhe1%_MY<=ZY^vNx4J?eb7)|Vkv~pz2)k-y^;MW zgm=~G70(r8=Sbt5klA3o#yZJA38JaZ=pFAg3n@*`^T_G!{3}xJqzLdbYSsonrq)>8 z6gCledJG19ZDwV6d0xrT|9LhjsLE*4jpRn=@EnHLDRrcUw>r&3v>vQPA5(v^K(tbH zBxYP7mN6uI)A-d-vR(uoh7TK?2Y|v4fvJ82RK|MD#JRFXxV?#Foap2X`X5+5g$$?z zwlv@bnqzed2{*}i&GjEi}Sp)75NruHaeW@lE61Vk@mHK zn|8QBDJ*WJ!jvFFdfoHkXB~E@VQOzK#q*1Ifk^kFn}A6KDITdq7-KE{)rH(89fE^C zK)T6jX0aMQ_}MyO)95c6iK|d@H$T_*F?aGDPBPt!uYR{K==Gw;0t=SEtC+25E@gp7 zzSLh|cK;(IqT~1J+I%fE1P`X$8ydVa7opybQei)p!k%(-yZ^b5II6IoGq@YSjUxms z$O&%#<2uY@A)^{sT$C5bIi{}rcsn&kJmvN`DCL?QEx~=lM>dE6)EWg+gDW*@|F;s` zI1$c%A6|HOou6(8tuCEeRB!!^JjP;uJjYMpg6Eso)BQ&0ko6LRxRz1p0KhlnTm0(d z?#>0R{dXPc^1?aN>kHj810it-BACg`0ElE8e1(->m9B|;?*+!4W!an9jNN0RxOL3q zP$RiIcbzk-nCzpX3dYwPrZPB37fn1&+s(u1i7&B7}XY*2I ztf~)*)O#QKeGRiKZf);Cw@gMN6_!5ic9<>krbtIv+%Ntj3&LoR3JfluoVj+aNq|yMO&13SMT{5GTZniS#!mMuE3;x(VZ6 zV;8lkzZ!8|h^$VWk>Wobn@XRb<|c5R+|ISuH4sI{?l&;)@{D`hc+~4b(>DVZ9~kIX zMrHl6s&iQDCcU}_R8vY@^m-!x?lQxyMYInl6?6$jAg}hN;Id73AK$ZiNh)}A+Ef~0 zx-X5L0fUo4Y`Iyp5r|HMBW=83%g6$^xoz%dhT2J}^%1iyNLo?SwkwkCR+l|1*eL8D zI7k!^5x8jP(CIWDWXTj%VA2xT8F-K{!N z!JW~&w}C|C+cKx7Yvm4LfT1K3D$!FCkwVlA49QO_6S@rv{E(MXu;6Tq<+d2qSVnE#EzZ#W`O` z)N&aLm_X$F9(L#m^amMRmR-mi9$efzHW)-|E|w^CgT9=`2aO|N@9s*9Vwv22rv&}F zBJY(sr|M(U;)64Al++B)?1v~P{%UO^?w03n6DSlNE^jb>BkQ+&W|`l3_o7F#)mH72 zFnoQa8?vy|6^%=!+xoS0mvK8TR*HbiKi|mn0WDqS03_*{N0w*wBPh=r^ils79VZ^2 z#!)~B{Ar*kJJ5Q5>=c~}*o@xU-lzTXfx6g)>xE))GZo6hWvWnzH1ccwE*HG2g?Hgw zf@DH77J@^Mf#w*c>U5Pdh}87FO%l(bR%2sM|N=1 zcX3a4p|GItI6+T?V6Ki=Bg-=&VXnLY^V_ejAiq!@Or|nY{;$oxofTPm=({^5k+eiq zB2y!x)KZpR&{Q|RJ=pIpGYlr{id8-blE1TxslOP*uAeI+FE2sAX0v^RSIfn$5&4-R zt{M2kHPV<}SV&@Pm+U|h29QHhK-1Oy*(fpE1vWQuB#MwfAlZ);2qFP2(i|Bt$dJ~H z{lUq*mcZs=EUlgZdgt8*9AWHqUlqk8MEts+-CD|bw^vzDSU!XkRoy0%_h!miOglw$fL*a)Dr5!#qyy6V>p-Y(bjO z&ISlBeMQV;*h*5}jQU2`ifveg_qm$k#_as(WI)-*KLPR9TB4?0P> z-S=#?>fuW&QAVBBt7j4yfdWNAjMt)9zju1T!Sjj6Qe^k!?!+Bh=ez)BG_>Qn$l&bF z`|rn~ppQA77Md@hxS}zmz8-O)zdxZuLWHIFI_m!eeI z50l$h`Up-;V40zltF|s;7~33^5ItcYo(dp$q>l#R$Xc3|V^t~ zXG4zVvUzEZ=sFO)ds3!epq{BD_zgF+Pe3bdnz%aA0BmRA_*QMc@S0WC+Pchn^7bjt z0!&!`V0~}JLK+r>_$<`8h^5s18UqthfT39x8rJH+vr#b|aKd9AUZDH8twF{Jf zqgGoS&r8DaQZ+}mWOS!?E1L@Ttz}(`Kp#nT zZWxA;+LAu&kes>s_MB?lottQMRH+Z<)`1@d$?@wG#cEPJcq^yorPe0?e+3!0 ze}*(ig{0p)=~UQC=d`XxfsLm5eg2Qf0 zDS0ICeaLgTZpCgbrIJt#hq?6H-lzpj^6}LES*s3v2VOPCIz7T2YIS7MExD}7TuXvF z>Jrf@TMs6g8i1nC=G^iH3hy#qxOf=S6mel?Oz=Qr8))263YY0zUH-8O%K4|Xp1>JO z@N3M91sAHldYgMiTNhse8S~rPco}rQOjN7X;V$CO>Mk|c6lqL69><^dEH`RD-WmHu3xY2k zd4qCIMKx%HDO{S9_s<0nv@hjyJZ%=Xj*9kG51{tQ!xLbf!rVP<0x_E1>97!Aw@InQ zi7)!}F;vfP7Prg8GWNoYG6%YaWh{N|o_G?;SD@clmD|`U-04@|fO+AK6lo34)h`c8 z8B_RVKOd4fe$^o6akn71mxD)Hee2iaHvfg^sL`S;g;rXP19L)dBikJg^t2)8INVdk zON$i&geD|b$4NYv44m-DFfR~M2du9#FzcKAgvgc2C4+X;I`tZSa^+j)&#BgN41n~= zKvfy*6s8mhT6e#jD=(xJ=CCCF(oGpdU6x?%K*T|Un}E-!#D6Ft`_ro2C>uww!Bs0#@+{9le)SA*?Y?V69?Q z)B4I|W=kq7Pvdc}JgS>A($(#pLn zzN}HN@yMn4hlEwZ@JU=2A~lK%`7E&BOoBcwn%?^&-M z;XFkY;Zhd*e{fQX?t24DUDEEfqBSD!S;|ldk})i;FHr5PF%Sl;sJs*g*(nnGOQ2M* z>{0bx1-2Dt(`HR1o$szp1J9x75g5R5m8i*8ihIZk?gh2ir(^)y%`_<=zF@%q)ecpY zo5IYtR79@Iv6z5AZGo-T*akQ@uC7($0Q)f2|6-6yMCaT<8)82mYVT)*|Cgp2%ZDCm zgp_<4x~d##Y^!Yoa?WrK$&pb|c_?2X)6_BEoUusSUi~NKg+mnN<-+o%&dj~jDo-w<9TlFw*g>kUUYbi3UsM` zmh|~YI_~8{#|~&*!-yAdxh)SivG%O77040Sd_8>7%B$x}(_ve6Rw+VdPwn9>H)(B> zRZU;t9P>Lt`cW&+Focxjf`7q_Mi?%@K&k8Ni7e)NX7~5w^bHZY@(23!sxTEw!CS*# z`VoI?_fk#XoMY(xqS$*PTT~*UoO(tv?R>1PcPTrJn2{Q&@ zhB8J=_9so$lU+I#D}j0Pn$8v+r|&{|9Txj<2LcQ8i;`ph*(-i5sf^TEv}%m=Ay<7O zQQ7PnY}qKO5Ky7__Sq)!qxHM{VskzGxiXD_nJ$}V`s%ck@WeLyPo`)T1(1!A>rX9 z3H~oDDuURNCV_;Yt&}I9;>IzNg_-9+2LJtGNibpU^5Ln4Sc$Z?6dl-g%|M&$>36WD zh_CXmoazE|A>%nanJq8bMbNjOcRV;h0iL7S0qB^&$F2`wt?~CtoQ%=2c40Zij)O+a1(3@_1^=SQhJ5xUq zGvbL7$q?U2me#A@7-Ykol1&4p>S;G|A#BoKKGcZ&M);egs6<%Ik)c%X%_75Z<{yXb z02Iri2{Zyesub9G)Q7acB6?bfd|%@*5CxFyi6OlJ^!3=+AyTprA=Wiu*X+vluQ<-q ze{G8w+q*T6h4`2cPsL^7G6V7~H1nz$)q{N7v!DMRd@m+wM)+6a*C})?_(Mu)JTjqZUD{SG{xO)h2}Sp?>956ovq#1&=DrvBF(NpiEn z2VWQp#Mk;3?5KMCLhmWdCuIEe4;1-6H4Tpjgc_>vUH+nK4-BKx_^srFL|@8LbrYCZ z8QOw@#LB2TT54Qf4d}idy$bTQ*LGeols@2+IiKqI4R}Y7^YjLvk`*1r=w-DCdc@19 z!^YQ9YIivzq%?%>2SPD8O&G<6N!|DtFoX!IaO6{+E!09E#}Eo9^NqVD6WQn@Jwh<{D6XFDP}Zi#t>v7qrypvHuCG#zZ*Y!!}pf*<}Q6BSwPq2|rNw-L0qoHn5eno2H)IqN*C_j%Wfv;7-!Gp6FZN@Rm%z7&lanmFd+eSoisp5KTM0xN7-s z`i!#om!Ht-v1cDv8Zql`OJLSum^xH*-17*JL`6c3!Uj1^MH7T)8x<9m$nL)JZr zWmIKzvbJ;n?k{&(D%Pph(i8~)12$LfyQLRDD`!ye6M_qq@`OxkgXA5YSj7_Ai2 z#V{A=79rQdlKPUa%d{==_wLE+9Fontt3h>IjsWbe{GULd_PJyJl!5aeYDFmX9gaHk zVh7-oUrA|jd}e>5h~u>=jV3vJ^cii}w%Ih9Z6_^GMynKxkzTm07BJp+2#-O5fz+WR}s(qUR$k6{{cZU>@8vIa^Do;iBMdq2hP4w9UM{9nV> zP@80S9YMfC3Xmb;Cw6OZPcLf#E_Uys#~-(%GGXerzB0zQ@4feWou2*bh&n=x@)YmD zqUQ-2>=eM0jG~_}=bW&-p~Jl~5(l!0y{)T68D3&IePSJ8%h)D?+(2jWU(5q{*jr&FZGjTVm ze%PrQlf?)CTtbg)qEY0opxs&Oj+wgNbF?~DvC4A4;!A;}bK!E5a)n|7bwZsAF$#4O zH=wBxUe{0RXy64?;R167S3pWGtL7pA>K;_``ehsjNf_;}g`FZXjsuPQ(ABFCT%T-< zbg8^Or`n^?^K(x}l`Pu9nERGz=X~-A%gH?cyHi-f)RXY1@QNEIN2YHGzpKRw#_ox} zu~?4)H2Zp_Ebcf>4VBZSMDGdyH0vP65NWyc+#w9PgKr(^jU6&fW0! z7ZCZXoxy0~Onnn8xVaTQZS0Kn>%$WwQlhO(!bS!B(N?KoyqF+mE(L=|bUr+1*U zrRMgCaw%fjs+=H~a`7iwq*s+lEsk@yJgP%48I|(kgB8Num6|hYKdOk*B4zH2ui;fZ zAry5$K&mV$zf&VfAZE2DdNS^>`LRe&IGknM@!M%sP8o7WP>Pz2C9!lDA9P-wv>ljh zbjB|=U#`~g{&;AZ52_ZrUzi==Pm6-$j^niKKVzqv{=W`5ezHj3P}(geHCU& zlJ)Z>((e^(q`1z>^CsiTon5QoH!Q4<;`wYgmz_@Ey~w2^q22do4qKf?(YiACz|JT4 zKtapF*S9v51TES<$DI+XgO-YeC&qv1B`30QR{g6Mr0>yEuaT8pW@FuJa{@_OPUyeu z8J36^h!**tC0yRhHZVQb5YCSgCKJrs5C0aq?l>e23g%s5Tm<-{WuSTU{;r-Z?{piG zXSD26xj3*)B<0bfD_o1;OFpT)$LFT0_mMR-PQ8XywwsxoE*AzQ{y^_S#@cz=ayLJTiN27}zhUf`Cj}fPPmvht0+EzXG zxTtW8X!gsPPJjJp@Q`hX#Slhl(jiXtJXH<-qctaaKe;G?dK;|P5wPdtMgI{oIy**& zszC6&{}Km*R<6R2luLoQROC|QJt1TbZqpbe5TS7ATxC~l>6j}8l5pC`+BFi)DE{H( zcKl!9IPk9^j72>9n|=)f%8C_1={y{6U;p#*H^PT85;k597`hY?-~XxUs^gmczCR@) zqa_4s1Ze^34uyeqHz-I+NOvK2#@BLheZ%8ixbStAH1vmI!Of#+ui{%jAg9mq z#T)!Jf*YscTu14=l*TrcrEkq5{mt2A-SG?3-uAy){vED3ar;6xczF^;PnC|>B%c&n zdw;_*FXP#eDY;kA=0Nxp#oG1vKDZ?gwp zr+N^@Jc~MGfxon1gGKM0N4`S3K3WT9r(grr*07pM;j;L{L%>^twK{&%RN3VF7IFM8 zn$XiIa1`@oy3RbjY$9LJFYHHMm#fMTYO3(lVoDn>l%c!$`SqVEK1EG~z}I7-`MdCy*x?@?H`G8b$ykij2VnDcm0Ae@>Otr(e^*Shg@rv5#Cq}GJ=_e4vUn(p&f7a{t2&{1DSi6vqfl&GJb`Z-$Ud!bNdo~7O;ImbQaq6;f6s?;_ZG}Vf^rLb{bOED<$1#oB?M& zo2b_$6sHM1jR)&4<)i#b+0TB?649}5e}09+>48l^Z%cDTuI@inavv}LeVuj8d-dP7 z2t)HJRHcu06$<~Z+hs*7)Re@CT*3QT2NZUee7K^GwTdP9Lcs0@7UP($(x(=NeQNUL zCH(k`X@I%N#tn55#G!p@lMfDPsy=PuR_&8oQPD95I`yit+2&S^L|m5Q!2=rK`<;yQ9WI_RH;D=v8iR+_|FWhk&6BcH@ld^uN#D;+W0#ZN-CW2>>t0{-e7P4I zd{C!5$U?~SlEYMEe)?JB)MtAIMOuB5;OV-$-;68ni;dvn7`owJjq^%lC76Ude0blC& z)$KOmT@!JkTjcZOAcvc*3%BUy@(yD<9VbFHpJ6YGK}{P{yvu9W9T@tr0xUULRXu zDcU(QnPlg{+(JcEbO%$ll5YRsze1!c!T)9>{>()+7xij^gs(S1EL@ls%N%+X*XPh$cS-bGCkjL!;?%i`yP6&9UFK?hNhSsi>@DN8u)>KuxAK!mQhJKLBE6#4(>O=&H7`t(eR z;R|YLyLh&LWmYGAW2vyJ>jU*TL)3Yx#9nh&=KhY~gB69@VpT7B;;MD{F9}NE*%j+jfVG;n*-wmHO%3aSkiXoRVa8UTgV6iTW;Nh^OMu zj%~lguJ|I8`o=6IQO}FTYwR~X`b*EKCyV7h6NSb z_2SX`%3ZT@0&$|X92s3Jc=A8wQ{Z6UIj(;tgV8C4`VebU*hf94@v%9K3fHm*_gF81<*9E_RMj}fCw(q4eu{rP_ zZBi4kf2+s39e<*^x<0=t1Jka_yPvKjlvAcxE~u?FtToM+A`|U`!YS%pZGQK#%IO!+ zxZ@f}KJrf`xTGUrL-{(zbpj=H(QI7B#${4AN>85|QkFGa7|Yk$GTke2S$Zqqo;@$pdi+$`#vj)vs2L1O3be|ec?w95M5T{#2S9_H(+%?pab9ww#ov_# zK*j_F?nHg8t!Nk@A|F@@SzTN`=sn{sL_M@iwGRpV;@OW|zn@3B6@-u}a+Jo3k(w@iB~89wI+}@0MKi%()?V_H?y>-bpDr13uZ$Yv)C(BBh{^~QN&`>$a@SCcsAdEK$f8n+()W4lPQFh7*J^zqK%K2Aj=fa~K*s&c}o~*#H z>HvdPrulFMHTX5f+t30i{URrLK>1mmENV z`vA^t=1q{4J2B~)j4Cf$bklxz5k?$Zk-Y5*EB-HiI3Xp|hiN*ZU$ukGuj$k`3uqYp zR-x_D)V@%C4@X^oo3cwRabEr`H#-g$lCeC&qpdNcfE&+H2D0NeuUd*MGQBRCUFerh zK6Ry^q;a+33yD9oXB%ej<6xsye0ztOFkXn4XmuI``R@6DljCzSLV)^ypnFGW$m()% zhQX%r@o`5*fmhXjsTlHi$#jcu%2FiA9(-9{LyH*;$6K+S!Ke*$5f{=e%@HC*E@@A3 zR??VKlK(mn|3U%>Y%>ZQc9L5b15iCE%k1H9q(x#^^WnN3C0&oJV0rAn4wTc7ouf1* zuitSlf}B8Fyjw}18AG=_?i66#{_HCsM7bA!{`{K;eU_v31ejMC>}rRIIs_&@a6T(TF!m41A$zB8vxr*@;9@0?L zA$ZpE-!N~2x z#D1`5jtlL*2!j+<021cxfGAM9?Jtd$OI+A^cH9!yXYEyjnU5OZwlkZ_iTZWDwl4om zY>C_;ZN?!NFcm+@D*RiFDrVDB$eRK~)l22!p{VW_^P(X{oRr49KR|0a`K%WWRJ|!Ex!1RGeUinlU+~xU7_6${MygU^fzfe@%$^P#(KFP%(nR7 zowLUvau-|9$Dk(gLf15Y7B`L#+(KXj$C=W1wq2GN*>FB)>>bjrqO%K9bav`-+lQp5k;` zMr1bUt6uwSu{@E{|K9!l@(9L>aM^#97+eytopJzO=lRAI@+}JK-Rf|spxI@H9 zwUmn%ct#Yq{TNql;KlY{?!Q~}y7H%iqiu08EfTDQbd9`~w$KR_F&dvDmls5={v8PGqH}Q78mhN|n zLdl=ctdy}K_~(>AmCN!2pbJ=LOD3jZm&710<~ollr5UgIa>Xd1vyj*)cP*gVCN^@{ z+LSYlBQdw#OXWF{*<|=)${?$tDRFC(^lTlUWU#&EC|jVP&8&j5anZ@u$Br;z+_7aw zQe4kH(T6E+kFf?fkeUR8y6aIw3F|`n4mFZpKcb8^I3{*8_R5I{$lU1va0NX7C`PX@ zPaU+*WEfT=UJW3eRyCS^BHCW1yy#so{on<8%qi^3R|YF&=Xck(H(HCAxOISJ-<^k$ z5djX+e{kFg<97YwJ9(b=i0^{G#r~?{e6kVs{hS3X@&k}Yh0&6pMhW_}zAZr_xXwph z&#cQzy(aoWR`7z36fYsT<^DKBX0#GnxyjzfHDMy+Y_+6_e^v4K0oCVw2!&t@9rkuy zX_^H7&Obq_6gCr2z2L|8PM2hiWFFFnLMt(cSvxU{{wcE;?`Ez+hhxCa*KPZ8kCGtrFA==%dp z>B{a~8(p(3BMyOG^Vhr4@VJgGF_Hf4P&?G4}mA(9IHY1554?cv}gprQ0 zcau(&Zdoh38JR6>>$MOC=}|o_wBV*hC36kGNO2@9m`lp7?b_u|a8Y;R)@r!PK99$5 zXI*_x%IwR)o!O*~KecWxK4!>mjqVl4AoqfR()Mxt_PXHDcLT{97 zb8T)6N;!9K6?q_f^7=}rb2I^r8F~cPn~uk8E^v%}d<_riy%>9Qd_uL1 z7@`{gD+EifIPH*1rje?;blDLRdZdlH1R6<2YFF`evWHF*c22~N`AY@aag&;)1Ad=K z)jzj3jn&J^7?)}K`eA?x4Mbd|w%XS7JV@#XAzS_QwGnI%%~&%_Mc6?pmAZ1kZVwHG zfS&k)Q$uDTEaLG8R~c>|-0X>G)RA$J9nyI%8a}7PVOKkaO=sr9twu9*MR&i;=fbO=e@a~si2xJ)Zph_=VIO~JzGU$W6$bap zsGhvv<0LsxxR#3atKlJODh$f;`mZC;%u<)2&J4r!AVw|gk1Bb{rDZC-y`l^mv0p)+ zO&;lJhh>FWPJQ*04Vnx_zh{ATAIFp{eoA>-0jpdwXB~KGSBBy8!8y+`i`<}ukB!%_ z>?FSSw{@}!YMdRaFyC32CLmi3MRGd|8E+Lg(jA8^YGYHhdVK>x&vuTiUPNLqS8Ol` z`75>pn55m-KyNgsg%o(Bvr{A%>D6|o&jE_LVFz22I4mHysVUf*CySu*X{{`*u*ek|XFFXft8FJoi#9$Cm13pM=+1aO8S;8CcsLuKDv?|M~_L z>)ap!xQ6JI8d*EdB_63PN@Ks0M}CXZ zlT5svgLxK+P0yI-XVJp`E$e3n!dwZ1mQOyGF8fVA_VkZR+k_;nNcd{il;*49-R^PD7-lP|Mun_q7$jP~>%An$GV3=Hpp+REEignW@dGGdFOBu0Z2x~DtBMST0I@5XGU;n{EHOeD@GzrIU-X8`};jd%7L~20_F2=8vG}Ap$r~jBr|Cp@3 zjy=hf>{{vldBE$&rU^8zPZ=Pd>r>rVhCwoa-ZAY6RqC9Zu>eRJKy>9O0KGEL0_BGf zPL5~(a_B)1ixTxt+K+fce#nyL2h2t&QvN$+!@<(9c8rKB{Nj1O0x;f^V<$()L>;GQ z{v9qvP0e+r{Q*uvg?yFdV02~1` zdDiCK)m_))qT$&YlIBD`Z~R-fSiP5S{%q3ocbV)uupN5LZ@%DWPJ7gv=1v~DiEplQ zeLT*18KueSyKlfV3PdDz0rzg%L}8b|YihX4T6rmjjM<6RedTlkcNl6oeCxdYHD4~r zib5&w8D9K-nZy5}VLi=OujXmN|LEg-HEcWIe&V{gvZBZkJZuGLEdfP5_YZj7QI3X#J9nUDS*~h&*1XcjIa!%0Awd(>HAYD`~wjiI;!zcM4!x~FlGx*vMhiZ$p%O8 zNw1?Z5QRlJ?6bJwK8@!_**}(OXZs5bryL7{!jL|ySmbM$!VlIy*MHaE5cMA6)PSSK zKw-}u)mcp9Vn_Q)3Xbz`Hx+en!1Gyy?PK(bohrQgg=RW)6x;18?8hQxJs%r%YTOWuPeIpu#%Lc8qy5pnn+L5m@ySWCsk?uW4$8MdR5j5#D8`U3)&EN2W)f3S;_ZKlGVnbz-<#B^z- zsH$6Q!~bF64BaZk{`wJU9SGY2LMb5EYaF+OIp+ zRgh87C$3-lK2@j+j2ouSPIb`ohI`RQNvK|%-oLSG91E*`gY#Sdm4qQI&tQWk;&~z*s9G?X^HlOXFpEJm5zumCXDe*7nx+rzCh(= zE31vD-1v=rj~E@WvkjJA3*>JSK~$r!fE$Of?l-M7&Pw&0AQZ< zrZhkZs}uvh36vWvZfTUP2c3^DUyN!8+Zb|QZlrZ_x8$`XDHd;heGs{8&-=&$Szfc{ z{mDbjH}(>rLT{0RHE8rm?)kt08ohmMUPNQXFD&ohJQ5ebN~#iAtZRju&g}5i-49Qf zQ|xb6BN$bza2zkYu~Jp9R1D2k*%>U>bbJRq@39Bi3k@7=hXl4VqR6ldu#2jCD*yoX z$BBct=8u&cjtN;D)38)e0x)YRA0b zLFg+)cUb1GPnZFGEbZVyP+KC{D_(2-*It|ZU%(A zP_QCjB8d1JWUv2&zTpv^b<6zOcmWCB#o>alpLhlxlO5j{Aoo?V`bIylS zRLGMQFxflj_ZY|Mjku2q+Z0%au>(8cUIq1JYbs3UOKY_??xvC$u2SQqi3wDmbL@2gUB zL!vgLi`=&`;j$RrN}+xhh8VLlPht*%;9TGQq}YR1kDeB+!VjySOqn}VkU!_-hcoB zcd>GEIIwU*6f8r=_1g^^jf7@uXQ$mSErssI6h8eAKSg`VES9aQ1R7A`Pmw3ylwmPp zVKGy+HQ~FmC44S>~TjF90dN zbSdwp9W}ZOS}Me3VVogXTY3{Ss|S{*osK|%5(JSM8d54Ok~J?QYX57f(Wew0ALG8R zf`u61s}@__54Ld+_i5{fb{Kmv`-{i5b$pe9&%(5-J_!At5fMqH3>SL(AW{zUO{7gH zDT-D&qttR$QZ=FSLCleB6*PL`s-ja9$tyb!qjsfLJ;#U8N!wRU+|gB}P#%_TlVA$& z{shgM#`gI9R8}m}%wXO301|8v786bkjUTA1{ueCvJa<2e;k@%37MIAaJ_3)x#1HsW P1sw2G9kp5&yXgM`(Rt<0 literal 0 HcmV?d00001 diff --git a/desktop/build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..6e25bf2f73e0671a626a498c8ebbd85d3d6f6299 GIT binary patch literal 2844 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 W;{5(;-mK%_5imzunbaA(C;bO}#4qOn literal 0 HcmV?d00001 diff --git a/desktop/build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..798d1da119da02eeeb3c7ae9e80654e207f192da GIT binary patch literal 4448 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 J)oKpt{{fOlQpW%Q literal 0 HcmV?d00001 diff --git a/desktop/build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png b/desktop/build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..5f34a9aa4988fa1bb93cf98443c89e7da2149f8e GIT binary patch literal 53710 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 z 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