@clawpump/claw-agent 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/.dockerignore +67 -0
- package/agent/.envrc +1 -1
- package/agent/.gitattributes +8 -0
- package/agent/AGENTS.md +216 -4
- package/agent/CONTRIBUTING.md +46 -8
- package/agent/Dockerfile +78 -35
- package/agent/MANIFEST.in +2 -0
- package/agent/README.md +12 -5
- package/agent/README.ur-pk.md +261 -0
- package/agent/README.zh-CN.md +11 -8
- package/agent/SECURITY.md +5 -4
- package/agent/acp_adapter/provenance.py +127 -0
- package/agent/acp_adapter/server.py +112 -5
- package/agent/acp_adapter/session.py +1 -6
- package/agent/acp_registry/agent.json +2 -2
- package/agent/agent/account_usage.py +313 -1
- package/agent/agent/agent_init.py +140 -37
- package/agent/agent/agent_runtime_helpers.py +342 -83
- package/agent/agent/anthropic_adapter.py +320 -33
- package/agent/agent/auxiliary_client.py +525 -105
- package/agent/agent/background_review.py +157 -19
- package/agent/agent/bedrock_adapter.py +71 -6
- package/agent/agent/billing_view.py +295 -0
- package/agent/agent/chat_completion_helpers.py +229 -4
- package/agent/agent/codex_responses_adapter.py +86 -10
- package/agent/agent/codex_runtime.py +153 -1
- package/agent/agent/coding_context.py +738 -0
- package/agent/agent/context_compressor.py +392 -44
- package/agent/agent/context_references.py +34 -1
- package/agent/agent/conversation_compression.py +159 -22
- package/agent/agent/conversation_loop.py +643 -908
- package/agent/agent/copilot_acp_client.py +4 -11
- package/agent/agent/credential_pool.py +5 -3
- package/agent/agent/credits_tracker.py +794 -0
- package/agent/agent/curator.py +91 -18
- package/agent/agent/curator_backup.py +26 -10
- package/agent/agent/display.py +42 -1
- package/agent/agent/error_classifier.py +52 -3
- package/agent/agent/errors.py +3 -0
- package/agent/agent/file_safety.py +0 -17
- package/agent/agent/gemini_native_adapter.py +31 -1
- package/agent/agent/i18n.py +48 -4
- package/agent/agent/image_gen_provider.py +74 -5
- package/agent/agent/image_routing.py +29 -0
- package/agent/agent/insights.py +8 -17
- package/agent/agent/lsp/install.py +3 -0
- package/agent/agent/memory_manager.py +326 -31
- package/agent/agent/message_content.py +50 -0
- package/agent/agent/model_metadata.py +214 -3
- package/agent/agent/moonshot_schema.py +8 -1
- package/agent/agent/onboarding.py +60 -0
- package/agent/agent/prompt_builder.py +327 -37
- package/agent/agent/redact.py +1 -0
- package/agent/agent/runtime_cwd.py +34 -5
- package/agent/agent/secret_scope.py +205 -0
- package/agent/agent/secret_sources/bitwarden.py +34 -2
- package/agent/agent/skill_commands.py +90 -1
- package/agent/agent/skill_preprocessing.py +1 -0
- package/agent/agent/skill_utils.py +209 -36
- package/agent/agent/ssl_guard.py +94 -0
- package/agent/agent/system_prompt.py +133 -5
- package/agent/agent/tool_executor.py +496 -70
- package/agent/agent/transports/anthropic.py +83 -21
- package/agent/agent/transports/chat_completions.py +94 -5
- package/agent/agent/transports/codex.py +67 -2
- package/agent/agent/transports/codex_app_server.py +1 -0
- package/agent/agent/transports/codex_app_server_session.py +30 -0
- package/agent/agent/transports/types.py +12 -0
- package/agent/agent/turn_context.py +408 -0
- package/agent/agent/turn_finalizer.py +428 -0
- package/agent/agent/turn_retry_state.py +68 -0
- package/agent/agent/usage_pricing.py +3 -0
- package/agent/apps/bootstrap-installer/package.json +6 -5
- package/agent/apps/bootstrap-installer/src/routes/failure.tsx +12 -5
- package/agent/apps/bootstrap-installer/src/routes/progress.tsx +1 -3
- package/agent/apps/bootstrap-installer/src/store.ts +3 -2
- package/agent/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +172 -7
- package/agent/apps/bootstrap-installer/src-tauri/src/events.rs +14 -1
- package/agent/apps/bootstrap-installer/src-tauri/src/paths.rs +29 -0
- package/agent/apps/bootstrap-installer/src-tauri/src/powershell.rs +93 -3
- package/agent/apps/bootstrap-installer/src-tauri/src/update.rs +695 -39
- package/agent/apps/bootstrap-installer/tsconfig.json +3 -4
- package/agent/apps/desktop/DESIGN.md +167 -0
- package/agent/apps/desktop/README.md +20 -16
- package/agent/apps/desktop/assets/icon.icns +0 -0
- package/agent/apps/desktop/assets/icon.ico +0 -0
- package/agent/apps/desktop/assets/icon.png +0 -0
- package/agent/apps/desktop/electron/backend-env.cjs +112 -0
- package/agent/apps/desktop/electron/backend-env.test.cjs +111 -0
- package/agent/apps/desktop/electron/backend-probes.test.cjs +3 -1
- package/agent/apps/desktop/electron/backend-ready.cjs +66 -0
- package/agent/apps/desktop/electron/bootstrap-platform.cjs +52 -0
- package/agent/apps/desktop/electron/bootstrap-platform.test.cjs +59 -1
- package/agent/apps/desktop/electron/bootstrap-runner.cjs +176 -38
- package/agent/apps/desktop/electron/bootstrap-runner.test.cjs +112 -1
- package/agent/apps/desktop/electron/connection-config.cjs +288 -0
- package/agent/apps/desktop/electron/connection-config.test.cjs +396 -0
- package/agent/apps/desktop/electron/dashboard-token.cjs +99 -0
- package/agent/apps/desktop/electron/dashboard-token.test.cjs +142 -0
- package/agent/apps/desktop/electron/desktop-uninstall.cjs +232 -0
- package/agent/apps/desktop/electron/desktop-uninstall.test.cjs +246 -0
- package/agent/apps/desktop/electron/entitlements.mac.inherit.plist +2 -0
- package/agent/apps/desktop/electron/fs-read-dir.cjs +109 -0
- package/agent/apps/desktop/electron/fs-read-dir.test.cjs +364 -0
- package/agent/apps/desktop/electron/gateway-ws-probe.cjs +188 -0
- package/agent/apps/desktop/electron/gateway-ws-probe.test.cjs +122 -0
- package/agent/apps/desktop/electron/git-root.cjs +54 -0
- package/agent/apps/desktop/electron/git-root.test.cjs +40 -0
- package/agent/apps/desktop/electron/git-worktrees.cjs +174 -0
- package/agent/apps/desktop/electron/hardening.cjs +123 -28
- package/agent/apps/desktop/electron/hardening.test.cjs +163 -0
- package/agent/apps/desktop/electron/main.cjs +3121 -331
- package/agent/apps/desktop/electron/oauth-net-request.cjs +20 -0
- package/agent/apps/desktop/electron/oauth-net-request.test.cjs +34 -0
- package/agent/apps/desktop/electron/preload.cjs +52 -2
- package/agent/apps/desktop/electron/session-windows.cjs +124 -0
- package/agent/apps/desktop/electron/session-windows.test.cjs +199 -0
- package/agent/apps/desktop/electron/update-rebuild.cjs +29 -0
- package/agent/apps/desktop/electron/update-rebuild.test.cjs +55 -0
- package/agent/apps/desktop/electron/update-remote.cjs +56 -0
- package/agent/apps/desktop/electron/update-remote.test.cjs +78 -0
- package/agent/apps/desktop/electron/vscode-marketplace.cjs +331 -0
- package/agent/apps/desktop/electron/vscode-marketplace.test.cjs +113 -0
- package/agent/apps/desktop/electron/windows-child-process.test.cjs +57 -0
- package/agent/apps/desktop/electron/windows-user-env.cjs +76 -0
- package/agent/apps/desktop/electron/windows-user-env.test.cjs +90 -0
- package/agent/apps/desktop/electron/workspace-cwd.cjs +38 -0
- package/agent/apps/desktop/electron/workspace-cwd.test.cjs +45 -0
- package/agent/apps/desktop/eslint.config.mjs +0 -3
- package/agent/apps/desktop/index.html +27 -2
- package/agent/apps/desktop/package.json +31 -11
- package/agent/apps/desktop/pr-assets/session-source-folders.png +0 -0
- package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
- package/agent/apps/desktop/public/nous-girl.jpg +0 -0
- package/agent/apps/desktop/scripts/assert-dist-built.cjs +70 -0
- package/agent/apps/desktop/scripts/assert-dist-built.test.cjs +84 -0
- package/agent/apps/desktop/scripts/before-pack.cjs +78 -0
- package/agent/apps/desktop/scripts/before-pack.test.cjs +53 -0
- package/agent/apps/desktop/scripts/diag-scroll-reset.mjs +229 -0
- package/agent/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +64 -0
- package/agent/apps/desktop/scripts/run-electron-builder.cjs +57 -0
- package/agent/apps/desktop/src/app/agents/index.tsx +53 -45
- package/agent/apps/desktop/src/app/artifacts/index.tsx +102 -83
- package/agent/apps/desktop/src/app/chat/chat-drop-overlay.tsx +29 -8
- package/agent/apps/desktop/src/app/chat/chat-swap-overlay.tsx +47 -0
- package/agent/apps/desktop/src/app/chat/composer/attachments.tsx +81 -45
- package/agent/apps/desktop/src/app/chat/composer/completion-drawer.tsx +13 -24
- package/agent/apps/desktop/src/app/chat/composer/context-menu.tsx +138 -88
- package/agent/apps/desktop/src/app/chat/composer/controls.tsx +138 -90
- package/agent/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
- package/agent/apps/desktop/src/app/chat/composer/focus.ts +32 -0
- package/agent/apps/desktop/src/app/chat/composer/help-hint.tsx +38 -25
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +7 -0
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +22 -12
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +142 -14
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +14 -11
- package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +9 -6
- package/agent/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
- package/agent/apps/desktop/src/app/chat/composer/index.tsx +930 -180
- package/agent/apps/desktop/src/app/chat/composer/inline-refs.ts +136 -32
- package/agent/apps/desktop/src/app/chat/composer/model-pill.tsx +86 -0
- package/agent/apps/desktop/src/app/chat/composer/queue-panel.tsx +54 -75
- package/agent/apps/desktop/src/app/chat/composer/rich-editor.test.ts +117 -1
- package/agent/apps/desktop/src/app/chat/composer/rich-editor.ts +117 -6
- package/agent/apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
- package/agent/apps/desktop/src/app/chat/composer/status-stack/index.tsx +202 -0
- package/agent/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +155 -0
- package/agent/apps/desktop/src/app/chat/composer/text-utils.test.ts +104 -0
- package/agent/apps/desktop/src/app/chat/composer/text-utils.ts +37 -9
- package/agent/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +50 -0
- package/agent/apps/desktop/src/app/chat/composer/trigger-popover.tsx +105 -40
- package/agent/apps/desktop/src/app/chat/composer/types.ts +5 -0
- package/agent/apps/desktop/src/app/chat/composer/url-dialog.tsx +11 -15
- package/agent/apps/desktop/src/app/chat/composer/voice-activity.tsx +8 -4
- package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
- package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +70 -16
- package/agent/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +52 -16
- package/agent/apps/desktop/src/app/chat/index.tsx +234 -81
- package/agent/apps/desktop/src/app/chat/perf-probe.tsx +69 -21
- package/agent/apps/desktop/src/app/chat/right-rail/preview-console.tsx +44 -40
- package/agent/apps/desktop/src/app/chat/right-rail/preview-file.tsx +71 -25
- package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +40 -1
- package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +55 -53
- package/agent/apps/desktop/src/app/chat/right-rail/preview.tsx +35 -17
- package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
- package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +74 -0
- package/agent/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +356 -0
- package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +1189 -364
- package/agent/apps/desktop/src/app/chat/sidebar/load-more-row.tsx +30 -0
- package/agent/apps/desktop/src/app/chat/sidebar/order.test.ts +21 -0
- package/agent/apps/desktop/src/app/chat/sidebar/order.ts +17 -0
- package/agent/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +524 -0
- package/agent/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +80 -45
- package/agent/apps/desktop/src/app/chat/sidebar/session-row.tsx +120 -25
- package/agent/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +7 -13
- package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
- package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.ts +326 -0
- package/agent/apps/desktop/src/app/chat/thread-loading.ts +7 -2
- package/agent/apps/desktop/src/app/command-center/index.tsx +320 -581
- package/agent/apps/desktop/src/app/command-palette/index.tsx +681 -0
- package/agent/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +157 -0
- package/agent/apps/desktop/src/app/cron/index.tsx +392 -324
- package/agent/apps/desktop/src/app/cron/job-state.ts +29 -0
- package/agent/apps/desktop/src/app/desktop-controller.tsx +618 -123
- package/agent/apps/desktop/src/app/floating-hud.ts +22 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +260 -14
- package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +48 -4
- package/agent/apps/desktop/src/app/hooks/use-keybinds.ts +270 -0
- package/agent/apps/desktop/src/app/hooks/use-refresh-hotkey.ts +45 -0
- package/agent/apps/desktop/src/app/layout-constants.ts +19 -0
- package/agent/apps/desktop/src/app/messaging/index.tsx +136 -241
- package/agent/apps/desktop/src/app/messaging/platform-icon.tsx +95 -0
- package/agent/apps/desktop/src/app/model-visibility-overlay.tsx +31 -0
- package/agent/apps/desktop/src/app/overlays/overlay-search-input.tsx +18 -62
- package/agent/apps/desktop/src/app/overlays/overlay-split-layout.tsx +59 -7
- package/agent/apps/desktop/src/app/overlays/overlay-view.tsx +9 -5
- package/agent/apps/desktop/src/app/page-search-shell.tsx +42 -20
- package/agent/apps/desktop/src/app/profiles/create-profile-dialog.tsx +165 -0
- package/agent/apps/desktop/src/app/profiles/delete-profile-dialog.tsx +65 -0
- package/agent/apps/desktop/src/app/profiles/index.tsx +174 -199
- package/agent/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +125 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts +27 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/ipc.test.ts +100 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/ipc.ts +12 -18
- package/agent/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +177 -0
- package/agent/apps/desktop/src/app/right-sidebar/files/tree.tsx +35 -21
- package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +75 -3
- package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +152 -5
- package/agent/apps/desktop/src/app/right-sidebar/index.test.tsx +75 -0
- package/agent/apps/desktop/src/app/right-sidebar/index.tsx +166 -129
- package/agent/apps/desktop/src/app/right-sidebar/store.ts +19 -4
- package/agent/apps/desktop/src/app/right-sidebar/terminal/buffer.ts +65 -0
- package/agent/apps/desktop/src/app/right-sidebar/terminal/index.tsx +29 -34
- package/agent/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +18 -6
- package/agent/apps/desktop/src/app/right-sidebar/terminal/selection.ts +93 -32
- package/agent/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +381 -119
- package/agent/apps/desktop/src/app/routes.ts +9 -0
- package/agent/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +17 -7
- package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +365 -47
- package/agent/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +198 -0
- package/agent/apps/desktop/src/app/session/hooks/use-model-controls.ts +70 -34
- package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +1061 -0
- package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +1143 -165
- package/agent/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +341 -2
- package/agent/apps/desktop/src/app/session/hooks/use-route-resume.ts +176 -5
- package/agent/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +259 -0
- package/agent/apps/desktop/src/app/session/hooks/use-session-actions.ts +452 -149
- package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +327 -0
- package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +133 -4
- package/agent/apps/desktop/src/app/session-picker-overlay.tsx +32 -0
- package/agent/apps/desktop/src/app/session-switcher.tsx +107 -0
- package/agent/apps/desktop/src/app/settings/about-settings.tsx +45 -36
- package/agent/apps/desktop/src/app/settings/appearance-settings.tsx +243 -162
- package/agent/apps/desktop/src/app/settings/config-settings.tsx +86 -66
- package/agent/apps/desktop/src/app/settings/constants.ts +459 -122
- package/agent/apps/desktop/src/app/settings/credential-key-ui.tsx +373 -0
- package/agent/apps/desktop/src/app/settings/env-credentials.tsx +198 -0
- package/agent/apps/desktop/src/app/settings/env-var-actions-menu.tsx +136 -0
- package/agent/apps/desktop/src/app/settings/field-copy.ts +56 -0
- package/agent/apps/desktop/src/app/settings/gateway-settings.tsx +385 -72
- package/agent/apps/desktop/src/app/settings/helpers.test.ts +156 -1
- package/agent/apps/desktop/src/app/settings/helpers.ts +30 -2
- package/agent/apps/desktop/src/app/settings/index.tsx +118 -84
- package/agent/apps/desktop/src/app/settings/keys-settings.tsx +62 -419
- package/agent/apps/desktop/src/app/settings/mcp-settings.tsx +65 -60
- package/agent/apps/desktop/src/app/settings/model-settings.test.tsx +129 -5
- package/agent/apps/desktop/src/app/settings/model-settings.tsx +370 -65
- package/agent/apps/desktop/src/app/settings/notifications-settings.tsx +150 -0
- package/agent/apps/desktop/src/app/settings/primitives.tsx +5 -11
- package/agent/apps/desktop/src/app/settings/provider-config-panel.test.tsx +142 -0
- package/agent/apps/desktop/src/app/settings/provider-config-panel.tsx +182 -0
- package/agent/apps/desktop/src/app/settings/providers-settings.test.tsx +171 -0
- package/agent/apps/desktop/src/app/settings/providers-settings.tsx +471 -0
- package/agent/apps/desktop/src/app/settings/sessions-settings.tsx +183 -71
- package/agent/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +135 -1
- package/agent/apps/desktop/src/app/settings/toolset-config-panel.tsx +180 -57
- package/agent/apps/desktop/src/app/settings/types.ts +9 -6
- package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +185 -0
- package/agent/apps/desktop/src/app/settings/use-deep-link-highlight.ts +60 -0
- package/agent/apps/desktop/src/app/shell/app-shell.tsx +59 -13
- package/agent/apps/desktop/src/app/shell/gateway-menu-panel.tsx +37 -32
- package/agent/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +6 -3
- package/agent/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +212 -53
- package/agent/apps/desktop/src/app/shell/keybind-panel.tsx +215 -0
- package/agent/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +84 -0
- package/agent/apps/desktop/src/app/shell/model-edit-submenu.tsx +244 -0
- package/agent/apps/desktop/src/app/shell/model-menu-panel.tsx +392 -0
- package/agent/apps/desktop/src/app/shell/statusbar-controls.tsx +23 -33
- package/agent/apps/desktop/src/app/shell/titlebar-controls.tsx +79 -95
- package/agent/apps/desktop/src/app/shell/titlebar.ts +8 -2
- package/agent/apps/desktop/src/app/skills/index.test.tsx +11 -0
- package/agent/apps/desktop/src/app/skills/index.tsx +79 -64
- package/agent/apps/desktop/src/app/types.ts +85 -0
- package/agent/apps/desktop/src/app/updates-overlay.tsx +110 -105
- package/agent/apps/desktop/src/components/assistant-ui/ansi-text.tsx +34 -0
- package/agent/apps/desktop/src/components/assistant-ui/block-direction.test.tsx +129 -0
- package/agent/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +102 -81
- package/agent/apps/desktop/src/components/assistant-ui/directive-text.tsx +92 -15
- package/agent/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +38 -0
- package/agent/apps/desktop/src/components/assistant-ui/markdown-text.tsx +304 -45
- package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
- package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +48 -0
- package/agent/apps/desktop/src/components/assistant-ui/streaming.test.tsx +142 -90
- package/agent/apps/desktop/src/components/assistant-ui/thread-list.tsx +337 -0
- package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +667 -190
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +299 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +133 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-approval.tsx +239 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +31 -0
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +152 -134
- package/agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +142 -150
- package/agent/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx +14 -12
- package/agent/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
- package/agent/apps/desktop/src/components/assistant-ui/user-message-text.tsx +152 -0
- package/agent/apps/desktop/src/components/boot-failure-overlay.tsx +150 -33
- package/agent/apps/desktop/src/components/boot-failure-reauth.test.ts +100 -0
- package/agent/apps/desktop/src/components/boot-failure-reauth.ts +81 -0
- package/agent/apps/desktop/src/components/brand-mark.tsx +19 -0
- package/agent/apps/desktop/src/components/chat/code-card.tsx +1 -1
- package/agent/apps/desktop/src/components/chat/composer-dock.ts +31 -0
- package/agent/apps/desktop/src/components/chat/diff-lines.tsx +1 -1
- package/agent/apps/desktop/src/components/chat/disclosure-row.tsx +13 -3
- package/agent/apps/desktop/src/components/chat/expandable-block.tsx +52 -0
- package/agent/apps/desktop/src/components/chat/generated-image-result.tsx +174 -0
- package/agent/apps/desktop/src/components/chat/image-generation-placeholder.tsx +70 -37
- package/agent/apps/desktop/src/components/chat/intro.tsx +8 -7
- package/agent/apps/desktop/src/components/chat/preview-attachment.tsx +4 -2
- package/agent/apps/desktop/src/components/chat/shiki-highlighter.test.ts +37 -0
- package/agent/apps/desktop/src/components/chat/shiki-highlighter.tsx +96 -22
- package/agent/apps/desktop/src/components/chat/status-row.tsx +70 -0
- package/agent/apps/desktop/src/components/chat/status-section.tsx +42 -0
- package/agent/apps/desktop/src/components/chat/terminal-output.tsx +54 -0
- package/agent/apps/desktop/src/components/chat/zoomable-image.tsx +70 -109
- package/agent/apps/desktop/src/components/desktop-install-overlay.tsx +154 -84
- package/agent/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +38 -8
- package/agent/apps/desktop/src/components/desktop-onboarding-overlay.tsx +789 -233
- package/agent/apps/desktop/src/components/error-boundary.tsx +77 -0
- package/agent/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +144 -0
- package/agent/apps/desktop/src/components/gateway-connecting-overlay.tsx +7 -1
- package/agent/apps/desktop/src/components/haptics-provider.tsx +24 -0
- package/agent/apps/desktop/src/components/language-switcher.test.tsx +53 -0
- package/agent/apps/desktop/src/components/language-switcher.tsx +175 -0
- package/agent/apps/desktop/src/components/model-picker.tsx +42 -40
- package/agent/apps/desktop/src/components/model-visibility-dialog.tsx +166 -0
- package/agent/apps/desktop/src/components/notifications.tsx +48 -27
- package/agent/apps/desktop/src/components/pane-shell/index.ts +1 -1
- package/agent/apps/desktop/src/components/pane-shell/pane-shell.tsx +146 -9
- package/agent/apps/desktop/src/components/prompt-overlays.tsx +234 -0
- package/agent/apps/desktop/src/components/session-picker.tsx +108 -0
- package/agent/apps/desktop/src/components/ui/action-status.tsx +25 -0
- package/agent/apps/desktop/src/components/ui/badge.tsx +35 -0
- package/agent/apps/desktop/src/components/ui/button.tsx +37 -13
- package/agent/apps/desktop/src/components/ui/confirm-dialog.tsx +109 -0
- package/agent/apps/desktop/src/components/ui/control.ts +25 -0
- package/agent/apps/desktop/src/components/ui/copy-button.test.tsx +36 -0
- package/agent/apps/desktop/src/components/ui/copy-button.tsx +38 -27
- package/agent/apps/desktop/src/components/ui/dialog.tsx +39 -11
- package/agent/apps/desktop/src/components/ui/dropdown-menu.tsx +98 -24
- package/agent/apps/desktop/src/components/ui/error-state.tsx +50 -0
- package/agent/apps/desktop/src/components/ui/fade-text.tsx +9 -2
- package/agent/apps/desktop/src/components/ui/{braille-spinner.tsx → glyph-spinner.tsx} +15 -13
- package/agent/apps/desktop/src/components/ui/input.tsx +5 -2
- package/agent/apps/desktop/src/components/ui/kbd.tsx +83 -12
- package/agent/apps/desktop/src/components/ui/log-view.tsx +19 -0
- package/agent/apps/desktop/src/components/ui/pagination.tsx +12 -5
- package/agent/apps/desktop/src/components/ui/popover.tsx +44 -0
- package/agent/apps/desktop/src/components/ui/search-field.tsx +80 -0
- package/agent/apps/desktop/src/components/ui/segmented-control.tsx +51 -0
- package/agent/apps/desktop/src/components/ui/select.tsx +10 -3
- package/agent/apps/desktop/src/components/ui/sheet.tsx +8 -2
- package/agent/apps/desktop/src/components/ui/sidebar.tsx +18 -25
- package/agent/apps/desktop/src/components/ui/switch.tsx +38 -15
- package/agent/apps/desktop/src/components/ui/textarea.tsx +4 -11
- package/agent/apps/desktop/src/components/ui/tool-icon.tsx +65 -0
- package/agent/apps/desktop/src/components/ui/tooltip.tsx +31 -4
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/agent/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/agent/apps/desktop/src/global.d.ts +181 -4
- package/agent/apps/desktop/src/hermes.test.ts +60 -0
- package/agent/apps/desktop/src/hermes.ts +190 -13
- package/agent/apps/desktop/src/hooks/use-image-download.ts +85 -0
- package/agent/apps/desktop/src/hooks/use-resize-observer.ts +13 -4
- package/agent/apps/desktop/src/hooks/use-worktree-info.ts +68 -0
- package/agent/apps/desktop/src/i18n/catalog.ts +12 -0
- package/agent/apps/desktop/src/i18n/context.test.tsx +232 -0
- package/agent/apps/desktop/src/i18n/context.tsx +183 -0
- package/agent/apps/desktop/src/i18n/define-locale.ts +41 -0
- package/agent/apps/desktop/src/i18n/en.ts +1921 -0
- package/agent/apps/desktop/src/i18n/index.ts +20 -0
- package/agent/apps/desktop/src/i18n/ja.ts +2053 -0
- package/agent/apps/desktop/src/i18n/languages.test.ts +43 -0
- package/agent/apps/desktop/src/i18n/languages.ts +86 -0
- package/agent/apps/desktop/src/i18n/runtime.test.ts +75 -0
- package/agent/apps/desktop/src/i18n/runtime.ts +53 -0
- package/agent/apps/desktop/src/i18n/types.ts +1559 -0
- package/agent/apps/desktop/src/i18n/zh-hant.ts +1992 -0
- package/agent/apps/desktop/src/i18n/zh.ts +2099 -0
- package/agent/apps/desktop/src/lib/ansi.test.ts +123 -0
- package/agent/apps/desktop/src/lib/ansi.ts +186 -0
- package/agent/apps/desktop/src/lib/chat-messages.test.ts +79 -0
- package/agent/apps/desktop/src/lib/chat-messages.ts +68 -29
- package/agent/apps/desktop/src/lib/chat-runtime.test.ts +65 -1
- package/agent/apps/desktop/src/lib/chat-runtime.ts +39 -3
- package/agent/apps/desktop/src/lib/completion-sound.ts +519 -0
- package/agent/apps/desktop/src/lib/desktop-fs.test.ts +116 -0
- package/agent/apps/desktop/src/lib/desktop-fs.ts +113 -0
- package/agent/apps/desktop/src/lib/desktop-slash-commands.test.ts +89 -6
- package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +270 -131
- package/agent/apps/desktop/src/lib/external-link.test.tsx +27 -0
- package/agent/apps/desktop/src/lib/external-link.tsx +9 -2
- package/agent/apps/desktop/src/lib/gateway-events.test.ts +27 -0
- package/agent/apps/desktop/src/lib/gateway-events.ts +16 -0
- package/agent/apps/desktop/src/lib/gateway-ws-url.test.ts +78 -0
- package/agent/apps/desktop/src/lib/gateway-ws-url.ts +91 -0
- package/agent/apps/desktop/src/lib/generated-images.test.ts +97 -0
- package/agent/apps/desktop/src/lib/generated-images.ts +116 -0
- package/agent/apps/desktop/src/lib/haptics.ts +17 -0
- package/agent/apps/desktop/src/lib/icons.ts +10 -2
- package/agent/apps/desktop/src/lib/keybinds/actions.ts +137 -0
- package/agent/apps/desktop/src/lib/keybinds/combo.test.ts +86 -0
- package/agent/apps/desktop/src/lib/keybinds/combo.ts +195 -0
- package/agent/apps/desktop/src/lib/local-preview.ts +23 -2
- package/agent/apps/desktop/src/lib/markdown-preprocess.ts +20 -7
- package/agent/apps/desktop/src/lib/media.remote.test.ts +90 -0
- package/agent/apps/desktop/src/lib/media.ts +40 -1
- package/agent/apps/desktop/src/lib/model-status-label.test.ts +59 -0
- package/agent/apps/desktop/src/lib/model-status-label.ts +122 -0
- package/agent/apps/desktop/src/lib/mutable-ref.ts +6 -0
- package/agent/apps/desktop/src/lib/profile-color.ts +58 -0
- package/agent/apps/desktop/src/lib/query-client.ts +13 -0
- package/agent/apps/desktop/src/lib/remend-tail.test.ts +105 -0
- package/agent/apps/desktop/src/lib/remend-tail.ts +108 -0
- package/agent/apps/desktop/src/lib/session-export.ts +6 -3
- package/agent/apps/desktop/src/lib/session-ids.test.ts +44 -0
- package/agent/apps/desktop/src/lib/session-ids.ts +26 -0
- package/agent/apps/desktop/src/lib/session-search.test.ts +66 -0
- package/agent/apps/desktop/src/lib/session-search.ts +21 -0
- package/agent/apps/desktop/src/lib/session-source.ts +126 -0
- package/agent/apps/desktop/src/lib/storage.test.ts +25 -0
- package/agent/apps/desktop/src/lib/storage.ts +35 -1
- package/agent/apps/desktop/src/lib/todos.test.ts +46 -1
- package/agent/apps/desktop/src/lib/todos.ts +37 -0
- package/agent/apps/desktop/src/lib/tool-result-summary.ts +5 -1
- package/agent/apps/desktop/src/lib/update-copy.test.ts +38 -0
- package/agent/apps/desktop/src/lib/update-copy.ts +44 -0
- package/agent/apps/desktop/src/lib/use-enter-animation.ts +2 -2
- package/agent/apps/desktop/src/lib/yolo-session.ts +50 -0
- package/agent/apps/desktop/src/main.tsx +19 -19
- package/agent/apps/desktop/src/store/boot.ts +4 -3
- package/agent/apps/desktop/src/store/clarify.test.ts +81 -0
- package/agent/apps/desktop/src/store/clarify.ts +50 -13
- package/agent/apps/desktop/src/store/command-palette.ts +20 -0
- package/agent/apps/desktop/src/store/compaction.test.ts +53 -0
- package/agent/apps/desktop/src/store/compaction.ts +38 -0
- package/agent/apps/desktop/src/store/completion-sound.ts +32 -0
- package/agent/apps/desktop/src/store/composer-input-history.test.ts +147 -0
- package/agent/apps/desktop/src/store/composer-input-history.ts +158 -0
- package/agent/apps/desktop/src/store/composer-queue.test.ts +68 -0
- package/agent/apps/desktop/src/store/composer-queue.ts +76 -0
- package/agent/apps/desktop/src/store/composer-status.test.ts +99 -0
- package/agent/apps/desktop/src/store/composer-status.ts +277 -0
- package/agent/apps/desktop/src/store/composer.test.ts +106 -0
- package/agent/apps/desktop/src/store/composer.ts +116 -0
- package/agent/apps/desktop/src/store/cron.ts +19 -0
- package/agent/apps/desktop/src/store/gateway.ts +280 -6
- package/agent/apps/desktop/src/store/keybinds.ts +143 -0
- package/agent/apps/desktop/src/store/layout.ts +107 -9
- package/agent/apps/desktop/src/store/model-presets.test.ts +51 -0
- package/agent/apps/desktop/src/store/model-presets.ts +86 -0
- package/agent/apps/desktop/src/store/model-visibility.test.ts +99 -0
- package/agent/apps/desktop/src/store/model-visibility.ts +161 -0
- package/agent/apps/desktop/src/store/native-notifications.test.ts +192 -0
- package/agent/apps/desktop/src/store/native-notifications.ts +203 -0
- package/agent/apps/desktop/src/store/notifications.ts +10 -7
- package/agent/apps/desktop/src/store/onboarding.test.ts +271 -1
- package/agent/apps/desktop/src/store/onboarding.ts +268 -38
- package/agent/apps/desktop/src/store/preview.ts +10 -1
- package/agent/apps/desktop/src/store/profile.test.ts +89 -0
- package/agent/apps/desktop/src/store/profile.ts +395 -0
- package/agent/apps/desktop/src/store/prompts.test.ts +127 -0
- package/agent/apps/desktop/src/store/prompts.ts +117 -0
- package/agent/apps/desktop/src/store/session-switcher.test.ts +115 -0
- package/agent/apps/desktop/src/store/session-switcher.ts +128 -0
- package/agent/apps/desktop/src/store/session-sync.ts +25 -0
- package/agent/apps/desktop/src/store/session.test.ts +268 -2
- package/agent/apps/desktop/src/store/session.ts +392 -18
- package/agent/apps/desktop/src/store/subagents.ts +3 -0
- package/agent/apps/desktop/src/store/system-actions.ts +48 -0
- package/agent/apps/desktop/src/store/thread-scroll.ts +58 -5
- package/agent/apps/desktop/src/store/todos.test.ts +47 -0
- package/agent/apps/desktop/src/store/todos.ts +64 -0
- package/agent/apps/desktop/src/store/tool-dismiss.ts +45 -0
- package/agent/apps/desktop/src/store/translucency.ts +38 -0
- package/agent/apps/desktop/src/store/updates.test.ts +187 -2
- package/agent/apps/desktop/src/store/updates.ts +268 -18
- package/agent/apps/desktop/src/store/windows.test.ts +143 -0
- package/agent/apps/desktop/src/store/windows.ts +115 -0
- package/agent/apps/desktop/src/styles.css +510 -119
- package/agent/apps/desktop/src/themes/color.ts +142 -0
- package/agent/apps/desktop/src/themes/context.tsx +128 -75
- package/agent/apps/desktop/src/themes/install.test.ts +119 -0
- package/agent/apps/desktop/src/themes/install.ts +95 -0
- package/agent/apps/desktop/src/themes/presets.test.ts +33 -0
- package/agent/apps/desktop/src/themes/presets.ts +13 -4
- package/agent/apps/desktop/src/themes/profile-theme.test.ts +41 -0
- package/agent/apps/desktop/src/themes/types.ts +35 -0
- package/agent/apps/desktop/src/themes/user-themes.test.ts +63 -0
- package/agent/apps/desktop/src/themes/user-themes.ts +122 -0
- package/agent/apps/desktop/src/themes/vscode.test.ts +171 -0
- package/agent/apps/desktop/src/themes/vscode.ts +343 -0
- package/agent/apps/desktop/src/types/hermes.ts +138 -1
- package/agent/apps/desktop/tsconfig.json +2 -2
- package/agent/apps/desktop/vite.config.ts +18 -0
- package/agent/apps/shared/package.json +1 -1
- package/agent/apps/shared/src/json-rpc-gateway.ts +63 -2
- package/agent/apps/shared/tsconfig.json +2 -2
- package/agent/cli-config.yaml.example +78 -1
- package/agent/cli.py +2177 -3162
- package/agent/cron/blueprint_catalog.py +713 -0
- package/agent/cron/jobs.py +226 -110
- package/agent/cron/scheduler.py +468 -193
- package/agent/cron/scheduler_provider.py +177 -0
- package/agent/cron/scripts/__init__.py +1 -0
- package/agent/cron/scripts/classify_items.py +226 -0
- package/agent/cron/suggestion_catalog.py +154 -0
- package/agent/cron/suggestions.py +257 -0
- package/agent/docs/chronos-managed-cron-contract.md +196 -0
- package/agent/docs/design/profile-builder.md +146 -0
- package/agent/docs/middleware/README.md +260 -0
- package/agent/docs/observability/README.md +316 -0
- package/agent/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md +240 -0
- package/agent/docs/rca-ssl-cacert-post-git-pull.md +54 -0
- package/agent/docs/relay-connector-contract.md +285 -0
- package/agent/gateway/authz_mixin.py +536 -0
- package/agent/gateway/channel_directory.py +65 -3
- package/agent/gateway/config.py +222 -12
- package/agent/gateway/display_config.py +10 -0
- package/agent/gateway/hooks.py +17 -0
- package/agent/gateway/kanban_watchers.py +1146 -0
- package/agent/gateway/message_timestamps.py +166 -0
- package/agent/gateway/platforms/ADDING_A_PLATFORM.md +29 -0
- package/agent/gateway/platforms/api_server.py +216 -38
- package/agent/gateway/platforms/base.py +210 -58
- package/agent/gateway/platforms/email.py +122 -12
- package/agent/gateway/platforms/feishu.py +80 -11
- package/agent/gateway/platforms/feishu_meeting_invite.py +212 -0
- package/agent/gateway/platforms/matrix.py +1498 -297
- package/agent/gateway/platforms/qqbot/adapter.py +6 -0
- package/agent/gateway/platforms/signal.py +8 -0
- package/agent/gateway/platforms/slack.py +308 -12
- package/agent/gateway/platforms/telegram.py +831 -24
- package/agent/gateway/platforms/webhook.py +109 -21
- package/agent/gateway/platforms/weixin.py +113 -2
- package/agent/gateway/platforms/whatsapp.py +94 -288
- package/agent/gateway/platforms/whatsapp_cloud.py +1956 -0
- package/agent/gateway/platforms/whatsapp_common.py +367 -0
- package/agent/gateway/platforms/yuanbao.py +608 -191
- package/agent/gateway/platforms/yuanbao_proto.py +232 -23
- package/agent/gateway/relay/__init__.py +375 -0
- package/agent/gateway/relay/adapter.py +222 -0
- package/agent/gateway/relay/auth.py +168 -0
- package/agent/gateway/relay/descriptor.py +118 -0
- package/agent/gateway/relay/transport.py +101 -0
- package/agent/gateway/relay/ws_transport.py +327 -0
- package/agent/gateway/response_filters.py +53 -0
- package/agent/gateway/rich_sent_store.py +80 -0
- package/agent/gateway/run.py +2940 -5001
- package/agent/gateway/session.py +109 -8
- package/agent/gateway/session_context.py +22 -4
- package/agent/gateway/slash_commands.py +3854 -0
- package/agent/gateway/status.py +141 -21
- package/agent/gateway/stream_consumer.py +288 -31
- package/agent/hermes-already-has-routines.md +1 -1
- package/agent/hermes_cli/__init__.py +62 -17
- package/agent/hermes_cli/_parser.py +30 -0
- package/agent/hermes_cli/_subprocess_compat.py +61 -0
- package/agent/hermes_cli/active_sessions.py +320 -0
- package/agent/hermes_cli/auth.py +707 -59
- package/agent/hermes_cli/auth_commands.py +39 -22
- package/agent/hermes_cli/backup.py +109 -7
- package/agent/hermes_cli/banner.py +88 -0
- package/agent/hermes_cli/blueprint_cmd.py +318 -0
- package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
- package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
- package/agent/hermes_cli/commands.py +215 -91
- package/agent/hermes_cli/config.py +967 -130
- package/agent/hermes_cli/container_boot.py +76 -11
- package/agent/hermes_cli/cron.py +5 -11
- package/agent/hermes_cli/curator.py +21 -0
- package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
- package/agent/hermes_cli/dashboard_auth/base.py +62 -0
- package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
- package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
- package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
- package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
- package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
- package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
- package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
- package/agent/hermes_cli/dashboard_register.py +427 -0
- package/agent/hermes_cli/debug.py +155 -50
- package/agent/hermes_cli/doctor.py +255 -14
- package/agent/hermes_cli/dump.py +60 -6
- package/agent/hermes_cli/env_loader.py +33 -0
- package/agent/hermes_cli/gateway.py +755 -103
- package/agent/hermes_cli/gateway_enroll.py +250 -0
- package/agent/hermes_cli/gateway_windows.py +254 -11
- package/agent/hermes_cli/gui_uninstall.py +285 -0
- package/agent/hermes_cli/inventory.py +105 -4
- package/agent/hermes_cli/kanban.py +58 -71
- package/agent/hermes_cli/kanban_db.py +391 -14
- package/agent/hermes_cli/kanban_decompose.py +2 -2
- package/agent/hermes_cli/kanban_specify.py +3 -1
- package/agent/hermes_cli/logs.py +2 -0
- package/agent/hermes_cli/main.py +2889 -5287
- package/agent/hermes_cli/managed_scope.py +214 -0
- package/agent/hermes_cli/managed_uv.py +254 -0
- package/agent/hermes_cli/mcp_catalog.py +6 -3
- package/agent/hermes_cli/mcp_config.py +145 -21
- package/agent/hermes_cli/mcp_security.py +96 -0
- package/agent/hermes_cli/mcp_startup.py +32 -3
- package/agent/hermes_cli/memory_providers.py +149 -0
- package/agent/hermes_cli/memory_setup.py +97 -42
- package/agent/hermes_cli/middleware.py +313 -0
- package/agent/hermes_cli/model_catalog.py +31 -0
- package/agent/hermes_cli/model_cost_guard.py +134 -0
- package/agent/hermes_cli/model_normalize.py +2 -1
- package/agent/hermes_cli/model_setup_flows.py +2759 -0
- package/agent/hermes_cli/model_switch.py +242 -27
- package/agent/hermes_cli/models.py +284 -44
- package/agent/hermes_cli/nous_account.py +33 -6
- package/agent/hermes_cli/nous_billing.py +406 -0
- package/agent/hermes_cli/nous_subscription.py +202 -5
- package/agent/hermes_cli/platforms.py +1 -0
- package/agent/hermes_cli/plugins.py +218 -18
- package/agent/hermes_cli/plugins_cmd.py +249 -105
- package/agent/hermes_cli/portal_cli.py +56 -16
- package/agent/hermes_cli/profile_distribution.py +6 -1
- package/agent/hermes_cli/profiles.py +283 -32
- package/agent/hermes_cli/provider_catalog.py +170 -0
- package/agent/hermes_cli/providers.py +4 -1
- package/agent/hermes_cli/pty_bridge.py +53 -4
- package/agent/hermes_cli/runtime_provider.py +216 -34
- package/agent/hermes_cli/secret_prompt.py +4 -4
- package/agent/hermes_cli/secrets_cli.py +24 -0
- package/agent/hermes_cli/send_cmd.py +28 -2
- package/agent/hermes_cli/service_manager.py +166 -19
- package/agent/hermes_cli/session_listing.py +97 -0
- package/agent/hermes_cli/setup.py +158 -94
- package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
- package/agent/hermes_cli/skills_config.py +8 -2
- package/agent/hermes_cli/skills_hub.py +149 -7
- package/agent/hermes_cli/status.py +2 -2
- package/agent/hermes_cli/subcommands/__init__.py +18 -0
- package/agent/hermes_cli/subcommands/_shared.py +29 -0
- package/agent/hermes_cli/subcommands/acp.py +52 -0
- package/agent/hermes_cli/subcommands/auth.py +109 -0
- package/agent/hermes_cli/subcommands/backup.py +38 -0
- package/agent/hermes_cli/subcommands/claw.py +92 -0
- package/agent/hermes_cli/subcommands/config.py +49 -0
- package/agent/hermes_cli/subcommands/cron.py +163 -0
- package/agent/hermes_cli/subcommands/dashboard.py +143 -0
- package/agent/hermes_cli/subcommands/debug.py +77 -0
- package/agent/hermes_cli/subcommands/doctor.py +35 -0
- package/agent/hermes_cli/subcommands/dump.py +28 -0
- package/agent/hermes_cli/subcommands/gateway.py +332 -0
- package/agent/hermes_cli/subcommands/gui.py +63 -0
- package/agent/hermes_cli/subcommands/hooks.py +77 -0
- package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
- package/agent/hermes_cli/subcommands/insights.py +25 -0
- package/agent/hermes_cli/subcommands/login.py +78 -0
- package/agent/hermes_cli/subcommands/logout.py +28 -0
- package/agent/hermes_cli/subcommands/logs.py +78 -0
- package/agent/hermes_cli/subcommands/mcp.py +108 -0
- package/agent/hermes_cli/subcommands/memory.py +53 -0
- package/agent/hermes_cli/subcommands/model.py +72 -0
- package/agent/hermes_cli/subcommands/pairing.py +36 -0
- package/agent/hermes_cli/subcommands/plugins.py +94 -0
- package/agent/hermes_cli/subcommands/postinstall.py +23 -0
- package/agent/hermes_cli/subcommands/profile.py +203 -0
- package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
- package/agent/hermes_cli/subcommands/security.py +62 -0
- package/agent/hermes_cli/subcommands/setup.py +58 -0
- package/agent/hermes_cli/subcommands/skills.py +298 -0
- package/agent/hermes_cli/subcommands/slack.py +60 -0
- package/agent/hermes_cli/subcommands/status.py +28 -0
- package/agent/hermes_cli/subcommands/tools.py +95 -0
- package/agent/hermes_cli/subcommands/uninstall.py +41 -0
- package/agent/hermes_cli/subcommands/update.py +70 -0
- package/agent/hermes_cli/subcommands/version.py +18 -0
- package/agent/hermes_cli/subcommands/webhook.py +76 -0
- package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
- package/agent/hermes_cli/suggestions_cmd.py +153 -0
- package/agent/hermes_cli/telegram_managed_bot.py +358 -0
- package/agent/hermes_cli/tips.py +3 -4
- package/agent/hermes_cli/tools_config.py +155 -28
- package/agent/hermes_cli/uninstall.py +231 -35
- package/agent/hermes_cli/web_server.py +6190 -973
- package/agent/hermes_cli/win_pty_bridge.py +179 -0
- package/agent/hermes_cli/write_approval_commands.py +209 -0
- package/agent/hermes_constants.py +164 -33
- package/agent/hermes_logging.py +74 -2
- package/agent/hermes_state.py +919 -106
- package/agent/hermes_time.py +20 -0
- package/agent/locales/af.yaml +23 -0
- package/agent/locales/de.yaml +23 -0
- package/agent/locales/en.yaml +20 -0
- package/agent/locales/es.yaml +23 -0
- package/agent/locales/fr.yaml +23 -0
- package/agent/locales/ga.yaml +23 -0
- package/agent/locales/hu.yaml +23 -0
- package/agent/locales/it.yaml +23 -0
- package/agent/locales/ja.yaml +23 -0
- package/agent/locales/ko.yaml +23 -0
- package/agent/locales/pt.yaml +23 -0
- package/agent/locales/ru.yaml +23 -0
- package/agent/locales/tr.yaml +23 -0
- package/agent/locales/uk.yaml +23 -0
- package/agent/locales/zh-hant.yaml +23 -0
- package/agent/locales/zh.yaml +23 -0
- package/agent/model_tools.py +204 -40
- package/agent/optional-mcps/clawpump/manifest.yaml +4 -2
- package/agent/optional-mcps/clawpump-stdio/manifest.yaml +2 -0
- package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
- package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
- package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
- package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
- package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
- package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
- package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
- package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
- package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
- package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
- package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
- package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
- package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
- package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
- package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
- package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
- package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
- package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
- package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
- package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
- package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
- package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
- package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
- package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
- package/agent/optional-skills/security/1password/SKILL.md +1 -1
- package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
- package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
- package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
- package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
- package/agent/package-lock.json +4082 -7907
- package/agent/package.json +18 -3
- package/agent/plugins/browser/firecrawl/provider.py +4 -1
- package/agent/plugins/cron/__init__.py +344 -0
- package/agent/plugins/cron/chronos/__init__.py +241 -0
- package/agent/plugins/cron/chronos/_nas_client.py +123 -0
- package/agent/plugins/cron/chronos/plugin.yaml +9 -0
- package/agent/plugins/cron/chronos/verify.py +103 -0
- package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
- package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
- package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
- package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
- package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
- package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
- package/agent/plugins/google_meet/audio_bridge.py +4 -0
- package/agent/plugins/google_meet/meet_bot.py +7 -1
- package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
- package/agent/plugins/image_gen/fal/__init__.py +35 -6
- package/agent/plugins/image_gen/krea/__init__.py +56 -13
- package/agent/plugins/image_gen/openai/__init__.py +122 -24
- package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
- package/agent/plugins/image_gen/xai/__init__.py +92 -12
- package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
- package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
- package/agent/plugins/memory/__init__.py +48 -5
- package/agent/plugins/memory/byterover/__init__.py +1 -0
- package/agent/plugins/memory/hindsight/README.md +1 -1
- package/agent/plugins/memory/hindsight/__init__.py +138 -24
- package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
- package/agent/plugins/memory/honcho/README.md +13 -10
- package/agent/plugins/memory/honcho/cli.py +247 -122
- package/agent/plugins/memory/honcho/client.py +112 -102
- package/agent/plugins/memory/openviking/README.md +12 -1
- package/agent/plugins/memory/openviking/__init__.py +2281 -107
- package/agent/plugins/memory/openviking/plugin.yaml +1 -2
- package/agent/plugins/memory/supermemory/README.md +22 -10
- package/agent/plugins/memory/supermemory/__init__.py +142 -37
- package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
- package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
- package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
- package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
- package/agent/plugins/model-providers/custom/__init__.py +8 -2
- package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
- package/agent/plugins/model-providers/minimax/__init__.py +60 -8
- package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
- package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
- package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
- package/agent/plugins/model-providers/zai/__init__.py +1 -0
- package/agent/plugins/observability/langfuse/__init__.py +147 -14
- package/agent/plugins/observability/nemo_relay/README.md +559 -0
- package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
- package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
- package/agent/plugins/platforms/discord/adapter.py +932 -61
- package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
- package/agent/plugins/platforms/google_chat/adapter.py +9 -3
- package/agent/plugins/platforms/google_chat/oauth.py +1 -1
- package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
- package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
- package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
- package/agent/plugins/platforms/irc/adapter.py +4 -1
- package/agent/plugins/platforms/line/adapter.py +16 -1
- package/agent/plugins/platforms/mattermost/adapter.py +100 -24
- package/agent/plugins/platforms/photon/README.md +179 -0
- package/agent/plugins/platforms/photon/__init__.py +4 -0
- package/agent/plugins/platforms/photon/adapter.py +1586 -0
- package/agent/plugins/platforms/photon/auth.py +1046 -0
- package/agent/plugins/platforms/photon/cli.py +439 -0
- package/agent/plugins/platforms/photon/plugin.yaml +88 -0
- package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
- package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
- package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
- package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
- package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
- package/agent/plugins/platforms/raft/__init__.py +3 -0
- package/agent/plugins/platforms/raft/adapter.py +774 -0
- package/agent/plugins/platforms/raft/plugin.yaml +19 -0
- package/agent/plugins/platforms/simplex/adapter.py +777 -220
- package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
- package/agent/plugins/platforms/teams/adapter.py +175 -5
- package/agent/plugins/plugin_utils.py +135 -0
- package/agent/plugins/video_gen/fal/__init__.py +10 -3
- package/agent/plugins/web/searxng/provider.py +15 -2
- package/agent/plugins/web/xai/provider.py +2 -2
- package/agent/providers/base.py +22 -3
- package/agent/pyproject.toml +115 -21
- package/agent/run_agent.py +733 -39
- package/agent/scripts/build_skills_index.py +51 -19
- package/agent/scripts/check_subprocess_stdin.py +177 -0
- package/agent/scripts/contributor_audit.py +2 -0
- package/agent/scripts/docker_config_migrate.py +67 -0
- package/agent/scripts/install.cmd +3 -3
- package/agent/scripts/install.ps1 +580 -154
- package/agent/scripts/install.sh +402 -185
- package/agent/scripts/lib/node-bootstrap.sh +39 -4
- package/agent/scripts/release.py +183 -0
- package/agent/scripts/run_tests.sh +1 -0
- package/agent/scripts/run_tests_parallel.py +18 -23
- package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
- package/agent/setup.py +59 -0
- package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
- package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
- package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
- package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
- package/agent/skills/clawpump/SKILL.md +4 -1
- package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
- package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
- package/agent/skills/github/github-auth/SKILL.md +2 -2
- package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
- package/agent/skills/github/github-code-review/SKILL.md +2 -2
- package/agent/skills/github/github-issues/SKILL.md +2 -2
- package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
- package/agent/skills/github/github-repo-management/SKILL.md +2 -2
- package/agent/skills/media/gif-search/SKILL.md +1 -1
- package/agent/skills/media/youtube-content/SKILL.md +10 -7
- package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
- package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
- package/agent/skills/productivity/airtable/SKILL.md +2 -2
- package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
- package/agent/skills/productivity/notion/SKILL.md +2 -2
- package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
- package/agent/skills/research/llm-wiki/SKILL.md +1 -1
- package/agent/skills/social-media/xurl/SKILL.md +9 -0
- package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
- package/agent/skills/software-development/plan/SKILL.md +285 -5
- package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
- package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
- package/agent/skills/software-development/spike/SKILL.md +2 -2
- package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
- package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
- package/agent/tools/approval.py +302 -4
- package/agent/tools/async_delegation.py +386 -0
- package/agent/tools/blueprints.py +325 -0
- package/agent/tools/browser_cdp_tool.py +3 -3
- package/agent/tools/browser_tool.py +34 -6
- package/agent/tools/checkpoint_manager.py +31 -1
- package/agent/tools/clarify_tool.py +55 -5
- package/agent/tools/code_execution_tool.py +31 -14
- package/agent/tools/computer_use/cua_backend.py +81 -3
- package/agent/tools/computer_use/tool.py +79 -5
- package/agent/tools/computer_use/vision_routing.py +55 -3
- package/agent/tools/credential_files.py +31 -12
- package/agent/tools/cronjob_tools.py +30 -20
- package/agent/tools/delegate_tool.py +356 -31
- package/agent/tools/env_probe.py +1 -0
- package/agent/tools/environments/docker.py +163 -8
- package/agent/tools/environments/file_sync.py +2 -1
- package/agent/tools/environments/local.py +74 -23
- package/agent/tools/environments/singularity.py +4 -1
- package/agent/tools/environments/ssh.py +78 -11
- package/agent/tools/file_operations.py +277 -41
- package/agent/tools/file_tools.py +166 -28
- package/agent/tools/image_generation_tool.py +515 -29
- package/agent/tools/kanban_tools.py +99 -0
- package/agent/tools/lazy_deps.py +33 -2
- package/agent/tools/mcp_oauth.py +5 -5
- package/agent/tools/mcp_oauth_manager.py +7 -5
- package/agent/tools/mcp_tool.py +840 -33
- package/agent/tools/memory_tool.py +335 -38
- package/agent/tools/osv_check.py +15 -1
- package/agent/tools/process_registry.py +155 -11
- package/agent/tools/read_extract.py +248 -0
- package/agent/tools/read_terminal_tool.py +93 -0
- package/agent/tools/schema_sanitizer.py +38 -0
- package/agent/tools/send_message_tool.py +163 -49
- package/agent/tools/session_search_tool.py +189 -7
- package/agent/tools/skill_manager_tool.py +202 -3
- package/agent/tools/skill_usage.py +52 -4
- package/agent/tools/skills_hub.py +184 -44
- package/agent/tools/skills_sync.py +232 -5
- package/agent/tools/skills_tool.py +125 -11
- package/agent/tools/terminal_tool.py +148 -26
- package/agent/tools/tirith_security.py +2 -0
- package/agent/tools/todo_tool.py +32 -1
- package/agent/tools/transcription_tools.py +13 -5
- package/agent/tools/tts_tool.py +332 -38
- package/agent/tools/url_safety.py +52 -1
- package/agent/tools/vision_tools.py +124 -39
- package/agent/tools/voice_mode.py +4 -3
- package/agent/tools/web_tools.py +45 -15
- package/agent/tools/write_approval.py +493 -0
- package/agent/toolsets.py +34 -10
- package/agent/trajectory_compressor.py +81 -10
- package/agent/tui_gateway/entry.py +43 -6
- package/agent/tui_gateway/server.py +3335 -330
- package/agent/tui_gateway/slash_worker.py +61 -0
- package/agent/tui_gateway/ws.py +67 -9
- package/agent/ui-tui/eslint.config.mjs +0 -4
- package/agent/ui-tui/package.json +6 -6
- package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
- package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
- package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
- package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
- package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
- package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
- package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
- package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
- package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
- package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
- package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
- package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
- package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
- package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
- package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
- package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
- package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
- package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
- package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
- package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
- package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
- package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
- package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
- package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
- package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
- package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
- package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
- package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
- package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
- package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
- package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
- package/agent/ui-tui/src/app/interfaces.ts +64 -1
- package/agent/ui-tui/src/app/overlayStore.ts +18 -2
- package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
- package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
- package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
- package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
- package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
- package/agent/ui-tui/src/app/slash/registry.ts +4 -0
- package/agent/ui-tui/src/app/turnController.ts +145 -2
- package/agent/ui-tui/src/app/uiStore.ts +2 -0
- package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
- package/agent/ui-tui/src/app/useMainApp.ts +54 -8
- package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
- package/agent/ui-tui/src/app/useSubmission.ts +23 -31
- package/agent/ui-tui/src/components/appChrome.tsx +112 -5
- package/agent/ui-tui/src/components/appLayout.tsx +9 -0
- package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
- package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
- package/agent/ui-tui/src/components/branding.tsx +15 -3
- package/agent/ui-tui/src/components/messageLine.tsx +25 -3
- package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
- package/agent/ui-tui/src/components/prompts.tsx +31 -17
- package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
- package/agent/ui-tui/src/components/textInput.tsx +16 -0
- package/agent/ui-tui/src/config/env.ts +12 -0
- package/agent/ui-tui/src/config/limits.ts +13 -0
- package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
- package/agent/ui-tui/src/domain/paths.ts +24 -0
- package/agent/ui-tui/src/domain/slash.ts +40 -0
- package/agent/ui-tui/src/entry.tsx +35 -4
- package/agent/ui-tui/src/gatewayClient.ts +22 -10
- package/agent/ui-tui/src/gatewayTypes.ts +130 -1
- package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
- package/agent/ui-tui/src/lib/memory.test.ts +162 -0
- package/agent/ui-tui/src/lib/memory.ts +60 -1
- package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
- package/agent/ui-tui/src/lib/osc52.ts +1 -1
- package/agent/ui-tui/src/lib/text.test.ts +32 -1
- package/agent/ui-tui/src/lib/text.ts +29 -2
- package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
- package/agent/ui-tui/src/types.ts +5 -0
- package/agent/ui-tui/tsconfig.build.json +0 -1
- package/agent/ui-tui/tsconfig.json +2 -1
- package/agent/utils.py +66 -2
- package/agent/uv.lock +300 -684
- package/agent/web/index.html +2 -2
- package/agent/web/package.json +11 -6
- package/agent/web/public/claw-bg.webp +0 -0
- package/agent/web/public/claw-logo.webp +0 -0
- package/agent/web/src/App.tsx +138 -48
- package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
- package/agent/web/src/components/Backdrop.tsx +15 -0
- package/agent/web/src/components/ChatSessionList.tsx +260 -0
- package/agent/web/src/components/ChatSidebar.tsx +262 -78
- package/agent/web/src/components/ConfirmDialog.tsx +122 -0
- package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
- package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
- package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
- package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
- package/agent/web/src/components/ReasoningPicker.tsx +167 -0
- package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
- package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
- package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
- package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
- package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
- package/agent/web/src/contexts/SystemActions.tsx +6 -8
- package/agent/web/src/contexts/profile-context.ts +19 -0
- package/agent/web/src/contexts/useProfileScope.ts +6 -0
- package/agent/web/src/i18n/af.ts +5 -4
- package/agent/web/src/i18n/de.ts +5 -4
- package/agent/web/src/i18n/en.ts +58 -4
- package/agent/web/src/i18n/es.ts +5 -3
- package/agent/web/src/i18n/fr.ts +5 -3
- package/agent/web/src/i18n/ga.ts +5 -4
- package/agent/web/src/i18n/hu.ts +5 -4
- package/agent/web/src/i18n/it.ts +5 -4
- package/agent/web/src/i18n/ja.ts +5 -4
- package/agent/web/src/i18n/ko.ts +5 -4
- package/agent/web/src/i18n/pt.ts +5 -3
- package/agent/web/src/i18n/ru.ts +5 -4
- package/agent/web/src/i18n/tr.ts +5 -4
- package/agent/web/src/i18n/types.ts +59 -1
- package/agent/web/src/i18n/uk.ts +5 -3
- package/agent/web/src/i18n/zh-hant.ts +5 -4
- package/agent/web/src/i18n/zh.ts +5 -4
- package/agent/web/src/index.css +2 -2
- package/agent/web/src/lib/api.ts +819 -52
- package/agent/web/src/lib/dashboard-flags.ts +16 -7
- package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
- package/agent/web/src/lib/reasoning-effort.ts +36 -0
- package/agent/web/src/lib/session-refresh.test.ts +21 -0
- package/agent/web/src/lib/session-refresh.ts +26 -0
- package/agent/web/src/pages/ChannelsPage.tsx +529 -68
- package/agent/web/src/pages/ChatPage.tsx +249 -56
- package/agent/web/src/pages/ConfigPage.tsx +11 -1
- package/agent/web/src/pages/CronPage.tsx +219 -31
- package/agent/web/src/pages/EnvPage.tsx +25 -6
- package/agent/web/src/pages/FilesPage.tsx +525 -0
- package/agent/web/src/pages/McpPage.tsx +80 -3
- package/agent/web/src/pages/ModelsPage.tsx +97 -12
- package/agent/web/src/pages/PluginsPage.tsx +1 -1
- package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
- package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
- package/agent/web/src/pages/SessionsPage.tsx +144 -13
- package/agent/web/src/pages/SkillsPage.tsx +851 -70
- package/agent/web/src/pages/SystemPage.tsx +340 -4
- package/agent/web/src/pages/WalletPage.tsx +401 -0
- package/agent/web/src/pages/WebhooksPage.tsx +145 -15
- package/agent/web/src/pages/X402Page.tsx +207 -0
- package/agent/web/src/plugins/registry.ts +28 -11
- package/agent/web/src/plugins/sdk.d.ts +160 -0
- package/agent/web/src/themes/context.tsx +112 -5
- package/agent/web/src/themes/fonts.ts +167 -0
- package/agent/web/src/themes/index.ts +7 -0
- package/agent/web/tsconfig.app.json +0 -1
- package/agent/web/vite.config.ts +1 -8
- package/agent/web/vitest.config.ts +16 -0
- package/package.json +1 -1
- package/agent/apps/desktop/package-lock.json +0 -18363
- package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
- package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
- package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
- package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
- package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
- package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
- package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
- package/agent/skills/diagramming/DESCRIPTION.md +0 -3
- package/agent/skills/domain/DESCRIPTION.md +0 -24
- package/agent/skills/gifs/DESCRIPTION.md +0 -3
- package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
- package/agent/skills/mcp/DESCRIPTION.md +0 -3
- package/agent/skills/media/spotify/SKILL.md +0 -135
- package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
- package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
- package/agent/skills/productivity/linear/SKILL.md +0 -380
- package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
- package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
- package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
- package/agent/ui-tui/package-lock.json +0 -7449
- package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
- package/agent/web/package-lock.json +0 -8887
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
- /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
- /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
- /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
- /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
- /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
- /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
- /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
- /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/gates-taxonomy.md +0 -0
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
nativeImage,
|
|
10
10
|
nativeTheme,
|
|
11
11
|
net: electronNet,
|
|
12
|
+
powerMonitor,
|
|
12
13
|
protocol,
|
|
13
14
|
safeStorage,
|
|
14
15
|
session,
|
|
@@ -21,24 +22,71 @@ const http = require('node:http')
|
|
|
21
22
|
const https = require('node:https')
|
|
22
23
|
const net = require('node:net')
|
|
23
24
|
const path = require('node:path')
|
|
24
|
-
const {
|
|
25
|
+
const { pathToFileURL } = require('node:url')
|
|
25
26
|
const { execFileSync, spawn } = require('node:child_process')
|
|
26
|
-
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
|
27
|
+
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
|
27
28
|
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
|
29
|
+
const {
|
|
30
|
+
buildSessionWindowUrl,
|
|
31
|
+
chatWindowWebPreferences,
|
|
32
|
+
createSessionWindowRegistry,
|
|
33
|
+
SESSION_WINDOW_MIN_HEIGHT,
|
|
34
|
+
SESSION_WINDOW_MIN_WIDTH
|
|
35
|
+
} = require('./session-windows.cjs')
|
|
28
36
|
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
|
37
|
+
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
|
38
|
+
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
|
39
|
+
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
|
40
|
+
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
|
41
|
+
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
|
42
|
+
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
|
43
|
+
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
|
44
|
+
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
|
45
|
+
const { gitRootForIpc } = require('./git-root.cjs')
|
|
46
|
+
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
|
47
|
+
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
|
48
|
+
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
|
49
|
+
const {
|
|
50
|
+
buildPosixCleanupScript,
|
|
51
|
+
buildWindowsCleanupScript,
|
|
52
|
+
modeRemovesAgent,
|
|
53
|
+
modeRemovesUserData,
|
|
54
|
+
resolveRemovableAppPath,
|
|
55
|
+
shouldRemoveAppBundle,
|
|
56
|
+
uninstallArgsForMode
|
|
57
|
+
} = require('./desktop-uninstall.cjs')
|
|
58
|
+
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
|
|
59
|
+
const {
|
|
60
|
+
authModeFromStatus,
|
|
61
|
+
buildGatewayWsUrl,
|
|
62
|
+
buildGatewayWsUrlWithTicket,
|
|
63
|
+
connectionScopeKey,
|
|
64
|
+
cookiesHaveSession,
|
|
65
|
+
cookiesHaveLiveSession,
|
|
66
|
+
normAuthMode,
|
|
67
|
+
normalizeRemoteBaseUrl,
|
|
68
|
+
pathWithGlobalRemoteProfile,
|
|
69
|
+
profileRemoteOverride,
|
|
70
|
+
resolveAuthMode,
|
|
71
|
+
resolveTestWsUrl,
|
|
72
|
+
tokenPreview
|
|
73
|
+
} = require('./connection-config.cjs')
|
|
29
74
|
const {
|
|
30
75
|
DATA_URL_READ_MAX_BYTES,
|
|
31
76
|
DEFAULT_FETCH_TIMEOUT_MS,
|
|
32
77
|
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
|
33
78
|
encryptDesktopSecret: encryptDesktopSecretStrict,
|
|
34
79
|
resolveReadableFileForIpc,
|
|
80
|
+
resolveRequestedPathForIpc,
|
|
35
81
|
resolveTimeoutMs
|
|
36
82
|
} = require('./hardening.cjs')
|
|
37
83
|
|
|
38
84
|
let nodePty = null
|
|
85
|
+
let nodePtyDir = null
|
|
39
86
|
|
|
40
87
|
try {
|
|
41
88
|
nodePty = require('node-pty')
|
|
89
|
+
nodePtyDir = path.dirname(require.resolve('node-pty/package.json'))
|
|
42
90
|
} catch {
|
|
43
91
|
// Packaged builds set `files:` in package.json, which excludes node_modules
|
|
44
92
|
// from the asar. Workspace dedup also hoists this native dep to the repo
|
|
@@ -51,10 +99,13 @@ try {
|
|
|
51
99
|
const path = require('node:path')
|
|
52
100
|
const resourcesPath = process.resourcesPath
|
|
53
101
|
if (resourcesPath) {
|
|
54
|
-
|
|
102
|
+
nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty')
|
|
103
|
+
nodePty = require(nodePtyDir)
|
|
55
104
|
}
|
|
56
105
|
} catch {
|
|
106
|
+
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
|
|
57
107
|
nodePty = null
|
|
108
|
+
nodePtyDir = null
|
|
58
109
|
}
|
|
59
110
|
}
|
|
60
111
|
|
|
@@ -65,14 +116,53 @@ if (USER_DATA_OVERRIDE) {
|
|
|
65
116
|
app.setPath('userData', resolvedUserData)
|
|
66
117
|
}
|
|
67
118
|
|
|
68
|
-
const PORT_FLOOR = 9120
|
|
69
|
-
const PORT_CEILING = 9199
|
|
70
119
|
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
|
71
120
|
const IS_PACKAGED = app.isPackaged
|
|
72
121
|
const IS_MAC = process.platform === 'darwin'
|
|
73
122
|
const IS_WINDOWS = process.platform === 'win32'
|
|
74
123
|
const IS_WSL = isWslEnvironment()
|
|
75
124
|
const APP_ROOT = app.getAppPath()
|
|
125
|
+
|
|
126
|
+
function hiddenWindowsChildOptions(options = {}) {
|
|
127
|
+
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
|
|
128
|
+
return options
|
|
129
|
+
}
|
|
130
|
+
return { ...options, windowsHide: true }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
|
134
|
+
// compositor flicker — accelerated layers can't be presented cleanly over the
|
|
135
|
+
// wire, so the window flashes during scroll/streaming/animation. Local
|
|
136
|
+
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
|
|
137
|
+
// GPU and never see it. Fall back to software rendering when a remote display
|
|
138
|
+
// is detected; it's rock-steady over the wire and the CPU cost is negligible
|
|
139
|
+
// next to the connection's latency. Must run before app `ready` — these
|
|
140
|
+
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
|
|
141
|
+
// (1/true → always disable, 0/false → keep GPU on).
|
|
142
|
+
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
|
|
143
|
+
if (REMOTE_DISPLAY_REASON) {
|
|
144
|
+
app.disableHardwareAcceleration()
|
|
145
|
+
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
|
|
146
|
+
// with only --disable-gpu: force compositing onto the CPU too.
|
|
147
|
+
app.commandLine.appendSwitch('disable-gpu-compositing')
|
|
148
|
+
console.log(
|
|
149
|
+
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Keep the renderer running at full speed while the window is in the background
|
|
154
|
+
// or occluded. The chat transcript streams to screen through a
|
|
155
|
+
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
|
|
156
|
+
// for backgrounded/occluded renderers, so without these the live answer stalls
|
|
157
|
+
// whenever the window loses focus (switching to your editor mid-turn, detached
|
|
158
|
+
// devtools, another window covering it) and only paints on refocus or refresh.
|
|
159
|
+
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
|
|
160
|
+
// these process-level switches additionally stop Chromium from backgrounding or
|
|
161
|
+
// occlusion-throttling the renderer. Must run before app `ready`.
|
|
162
|
+
app.commandLine.appendSwitch('disable-renderer-backgrounding')
|
|
163
|
+
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
|
|
164
|
+
app.commandLine.appendSwitch('disable-background-timer-throttling')
|
|
165
|
+
|
|
76
166
|
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
|
77
167
|
|
|
78
168
|
// Build-time install stamp -- the git ref this .exe was built against.
|
|
@@ -154,8 +244,18 @@ if (INSTALL_STAMP) {
|
|
|
154
244
|
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
|
|
155
245
|
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
|
|
156
246
|
function resolveHermesHome() {
|
|
157
|
-
if (process.env.HERMES_HOME) return
|
|
247
|
+
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
|
158
248
|
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
|
249
|
+
if (IS_WINDOWS) {
|
|
250
|
+
// A GUI app launched from Explorer inherits the environment block captured
|
|
251
|
+
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
|
|
252
|
+
// process.env even though the CLI (a fresh shell) sees it. Without this the
|
|
253
|
+
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
|
|
254
|
+
// inference provider configured" despite a valid configured home (#45471).
|
|
255
|
+
// Consult the live User-scoped registry value before the default below.
|
|
256
|
+
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
|
|
257
|
+
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
|
|
258
|
+
}
|
|
159
259
|
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
|
160
260
|
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
|
161
261
|
const legacy = path.join(app.getPath('home'), '.hermes')
|
|
@@ -190,6 +290,16 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
|
|
|
190
290
|
|
|
191
291
|
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
|
|
192
292
|
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
|
|
293
|
+
// active-profile.json records which Hermes profile the desktop launches its
|
|
294
|
+
// local backend as. When set, startHermes() passes `hermes --profile <name>
|
|
295
|
+
// dashboard …`, which deterministically pins HERMES_HOME (see
|
|
296
|
+
// _apply_profile_override in hermes_cli/main.py) and bypasses the sticky
|
|
297
|
+
// ~/.hermes/active_profile file. Unset (null) preserves the legacy behavior:
|
|
298
|
+
// no --profile flag, so the backend honors active_profile / default.
|
|
299
|
+
const DESKTOP_PROFILE_CONFIG_PATH = path.join(app.getPath('userData'), 'active-profile.json')
|
|
300
|
+
// Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never hand the backend a
|
|
301
|
+
// value its profile resolver would reject and exit on.
|
|
302
|
+
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
193
303
|
// Branch we track for self-update. The GUI work has merged to main, so this
|
|
194
304
|
// tracks main. User can also override at runtime via
|
|
195
305
|
// hermesDesktop.updates.setBranch().
|
|
@@ -200,6 +310,25 @@ const DEFAULT_UPDATE_BRANCH = 'main'
|
|
|
200
310
|
const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log')
|
|
201
311
|
const DESKTOP_LOG_FLUSH_MS = 120
|
|
202
312
|
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
|
|
313
|
+
// Bound desktop.log on disk. It is an append-only forensic log, so a boot loop
|
|
314
|
+
// (version-skew crash -> backend exits instantly -> renderer keeps hitting
|
|
315
|
+
// Retry) appends the full bootstrap transcript every attempt and grows without
|
|
316
|
+
// bound — we have seen it reach ~326 GB and exhaust the disk, which then breaks
|
|
317
|
+
// update/install (no room for git/venv/npm temp files).
|
|
318
|
+
//
|
|
319
|
+
// Mirror the Python logs (hermes_logging.py RotatingFileHandler, maxBytes x
|
|
320
|
+
// backupCount): cascade live -> .1 -> .2 -> .3, drop the oldest. Steady-state
|
|
321
|
+
// stays bounded at ~(backupCount + 1) x cap however hard the app loops.
|
|
322
|
+
//
|
|
323
|
+
// Bounding alone never RECLAIMS an already-huge file: a plain rotation just
|
|
324
|
+
// renames the monster to .1 and strands it for a cycle a healthy app may never
|
|
325
|
+
// reach. A multi-GB boot-loop transcript has no diagnostic value, so anything
|
|
326
|
+
// past the discard ceiling is deleted outright — the updated app self-heals a
|
|
327
|
+
// disk a stale build filled, on the next launch.
|
|
328
|
+
const DESKTOP_LOG_MAX_BYTES = 10 * 1024 * 1024
|
|
329
|
+
const DESKTOP_LOG_BACKUP_COUNT = 3
|
|
330
|
+
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
|
331
|
+
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
|
203
332
|
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
|
204
333
|
const BOOT_FAKE_STEP_MS = (() => {
|
|
205
334
|
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
|
@@ -230,10 +359,110 @@ const APP_ICON_PATHS = [
|
|
|
230
359
|
let rendererTitleBarTheme = null
|
|
231
360
|
const terminalSessions = new Map()
|
|
232
361
|
|
|
362
|
+
// Force the NATIVE window appearance (vibrancy material, titlebar, the
|
|
363
|
+
// pre-first-paint window background) to follow the APP theme instead of the
|
|
364
|
+
// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that
|
|
365
|
+
// tracks the window's effective appearance and ignores `backgroundColor` —
|
|
366
|
+
// so a dark-themed app on a light-mode Mac flashes a white material on every
|
|
367
|
+
// new window until the renderer covers it. The renderer reports its mode via
|
|
368
|
+
// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin
|
|
369
|
+
// nativeTheme.themeSource to it and persist the value so cold launches paint
|
|
370
|
+
// correctly before the renderer has even loaded.
|
|
371
|
+
const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json')
|
|
372
|
+
const THEME_SOURCES = new Set(['dark', 'light', 'system'])
|
|
373
|
+
|
|
374
|
+
function readPersistedThemeSource() {
|
|
375
|
+
try {
|
|
376
|
+
const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8'))
|
|
377
|
+
|
|
378
|
+
if (parsed && THEME_SOURCES.has(parsed.themeSource)) {
|
|
379
|
+
return parsed.themeSource
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// Missing / malformed → follow the OS like a fresh install.
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return 'system'
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function writePersistedThemeSource(mode) {
|
|
389
|
+
try {
|
|
390
|
+
fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true })
|
|
391
|
+
fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8')
|
|
392
|
+
} catch (error) {
|
|
393
|
+
rememberLog(`[theme] write native theme failed: ${error.message}`)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
nativeTheme.themeSource = readPersistedThemeSource()
|
|
398
|
+
|
|
399
|
+
// Window translucency (see-through window). One lever, 0–100; 0 = off (the
|
|
400
|
+
// default). Mapped to the native window opacity so the desktop shows through
|
|
401
|
+
// the whole window. Persisted so a cold launch applies it at window creation,
|
|
402
|
+
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
|
|
403
|
+
// a no-op on Linux. See store/translucency.
|
|
404
|
+
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
|
|
405
|
+
|
|
406
|
+
function clampIntensity(value) {
|
|
407
|
+
const n = Math.round(Number(value))
|
|
408
|
+
|
|
409
|
+
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function readPersistedTranslucency() {
|
|
413
|
+
try {
|
|
414
|
+
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
|
|
415
|
+
} catch {
|
|
416
|
+
return 0
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function writePersistedTranslucency(intensity) {
|
|
421
|
+
try {
|
|
422
|
+
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
|
|
423
|
+
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
|
|
424
|
+
} catch (error) {
|
|
425
|
+
rememberLog(`[translucency] write failed: ${error.message}`)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let translucencyIntensity = readPersistedTranslucency()
|
|
430
|
+
|
|
431
|
+
// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through
|
|
432
|
+
// setting is still usable rather than nearly invisible. 0 → fully opaque.
|
|
433
|
+
function windowOpacity() {
|
|
434
|
+
return 1 - (translucencyIntensity / 100) * 0.7
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Re-apply translucency to a live window (runtime toggle, no recreation).
|
|
438
|
+
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
|
|
439
|
+
function applyWindowTranslucency(win) {
|
|
440
|
+
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
win.setOpacity(windowOpacity())
|
|
446
|
+
} catch (error) {
|
|
447
|
+
rememberLog(`[translucency] apply failed: ${error.message}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
233
451
|
function isHexColor(value) {
|
|
234
452
|
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
|
235
453
|
}
|
|
236
454
|
|
|
455
|
+
// Background color to paint a window with BEFORE its renderer loads, so a new
|
|
456
|
+
// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme
|
|
457
|
+
// the renderer last reported; fall back to the OS preference on first launch.
|
|
458
|
+
function getWindowBackgroundColor() {
|
|
459
|
+
if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) {
|
|
460
|
+
return rendererTitleBarTheme.background
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
|
|
464
|
+
}
|
|
465
|
+
|
|
237
466
|
function getTitleBarOverlayOptions() {
|
|
238
467
|
if (IS_MAC) {
|
|
239
468
|
return { height: TITLEBAR_HEIGHT }
|
|
@@ -361,8 +590,13 @@ function previewFileMetadata(filePath, mimeType) {
|
|
|
361
590
|
}
|
|
362
591
|
|
|
363
592
|
app.setName(APP_NAME)
|
|
593
|
+
// Seed the native About panel with the live Hermes version. This is refreshed
|
|
594
|
+
// on every open via the explicit "About" menu handler (refreshAboutPanel), so
|
|
595
|
+
// an in-place `hermes update` mid-session is reflected without an app restart;
|
|
596
|
+
// the seed here just covers the first open and any non-menu invocation path.
|
|
364
597
|
app.setAboutPanelOptions({
|
|
365
598
|
applicationName: APP_NAME,
|
|
599
|
+
applicationVersion: resolveHermesVersion(),
|
|
366
600
|
copyright: 'Copyright © 2026 Nous Research'
|
|
367
601
|
})
|
|
368
602
|
|
|
@@ -429,6 +663,31 @@ function registerMediaProtocol() {
|
|
|
429
663
|
let mainWindow = null
|
|
430
664
|
let hermesProcess = null
|
|
431
665
|
let connectionPromise = null
|
|
666
|
+
// Additional per-profile backends, keyed by profile name. The PRIMARY backend
|
|
667
|
+
// (the desktop's launch profile) stays managed by hermesProcess +
|
|
668
|
+
// connectionPromise + startHermes(); this pool only holds EXTRA profile
|
|
669
|
+
// backends spawned lazily when a session belongs to a different profile. A user
|
|
670
|
+
// with no named profiles never populates this map, so their experience is
|
|
671
|
+
// byte-for-byte the single-backend behavior.
|
|
672
|
+
const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt }
|
|
673
|
+
// Keep the pool light: cap concurrent profile backends (LRU eviction) and reap
|
|
674
|
+
// idle ones. A user idles at exactly the primary backend; pool backends only
|
|
675
|
+
// exist while a non-primary profile is actively being chatted through.
|
|
676
|
+
const POOL_MAX_BACKENDS = Math.max(1, Number(process.env.HERMES_DESKTOP_POOL_MAX) || 3)
|
|
677
|
+
const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || 10 * 60_000)
|
|
678
|
+
// A backend touched within this window has a live renderer socket (the keepalive
|
|
679
|
+
// pings every 60s for every open profile). LRU eviction must spare these — a
|
|
680
|
+
// concurrent multi-profile session keeps several backends "fresh" at once, and
|
|
681
|
+
// killing one to honor the soft cap would abort a running agent.
|
|
682
|
+
const POOL_KEEPALIVE_FRESH_MS = 90_000
|
|
683
|
+
let poolIdleReaper = null
|
|
684
|
+
// Auto-reload budget for renderer crashes. A deterministic startup crash would
|
|
685
|
+
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
|
|
686
|
+
// logs. Allow a few reloads per rolling window, then stop and leave the dead
|
|
687
|
+
// window so the user can read the error / quit.
|
|
688
|
+
const RENDERER_RELOAD_WINDOW_MS = 60_000
|
|
689
|
+
const RENDERER_RELOAD_MAX = 3
|
|
690
|
+
let rendererReloadTimes = []
|
|
432
691
|
// Latched bootstrap failure: when the first-launch install fails, we hold
|
|
433
692
|
// onto the error so subsequent startHermes() calls (e.g. the renderer's
|
|
434
693
|
// ensureGatewayOpen retrying after the WS won't open) return the same error
|
|
@@ -439,12 +698,14 @@ let bootstrapFailure = null
|
|
|
439
698
|
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
|
440
699
|
let bootstrapAbortController = null
|
|
441
700
|
let connectionConfigCache = null
|
|
701
|
+
let connectionConfigCacheMtime = null
|
|
442
702
|
const hermesLog = []
|
|
443
703
|
const previewWatchers = new Map()
|
|
444
704
|
let previewShortcutActive = false
|
|
445
705
|
let desktopLogBuffer = ''
|
|
446
706
|
let desktopLogFlushTimer = null
|
|
447
707
|
let desktopLogFlushPromise = Promise.resolve()
|
|
708
|
+
let nativeThemeListenerInstalled = false
|
|
448
709
|
let bootProgressState = {
|
|
449
710
|
error: null,
|
|
450
711
|
fakeMode: BOOT_FAKE_MODE,
|
|
@@ -455,6 +716,59 @@ let bootProgressState = {
|
|
|
455
716
|
timestamp: Date.now()
|
|
456
717
|
}
|
|
457
718
|
|
|
719
|
+
// Pure planner: ordered fs ops to bound a live log of `size`. [] = nothing.
|
|
720
|
+
// Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a
|
|
721
|
+
// missing chain link never aborts the rest.
|
|
722
|
+
function planDesktopLogRotation(size) {
|
|
723
|
+
if (size < DESKTOP_LOG_MAX_BYTES) return []
|
|
724
|
+
const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1))
|
|
725
|
+
// Pathological boot-loop log: reclaim live + every backup outright.
|
|
726
|
+
if (size > DESKTOP_LOG_DISCARD_BYTES) {
|
|
727
|
+
return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p])
|
|
728
|
+
}
|
|
729
|
+
// Cascade: drop oldest, shift each up, live -> .1.
|
|
730
|
+
const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]]
|
|
731
|
+
for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) {
|
|
732
|
+
ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)])
|
|
733
|
+
}
|
|
734
|
+
ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)])
|
|
735
|
+
return ops
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function rotateDesktopLogIfNeededSync() {
|
|
739
|
+
let size
|
|
740
|
+
try {
|
|
741
|
+
size = fs.statSync(DESKTOP_LOG_PATH).size
|
|
742
|
+
} catch {
|
|
743
|
+
return // No live file yet — the append (re)creates it.
|
|
744
|
+
}
|
|
745
|
+
for (const [op, src, dst] of planDesktopLogRotation(size)) {
|
|
746
|
+
try {
|
|
747
|
+
if (op === 'rm') fs.rmSync(src, { force: true })
|
|
748
|
+
else fs.renameSync(src, dst)
|
|
749
|
+
} catch {
|
|
750
|
+
// Best-effort — logging must never block startup/shutdown.
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function rotateDesktopLogIfNeededAsync() {
|
|
756
|
+
let size
|
|
757
|
+
try {
|
|
758
|
+
size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size
|
|
759
|
+
} catch {
|
|
760
|
+
return // No live file yet — the append (re)creates it.
|
|
761
|
+
}
|
|
762
|
+
for (const [op, src, dst] of planDesktopLogRotation(size)) {
|
|
763
|
+
try {
|
|
764
|
+
if (op === 'rm') await fs.promises.rm(src, { force: true })
|
|
765
|
+
else await fs.promises.rename(src, dst)
|
|
766
|
+
} catch {
|
|
767
|
+
// Best-effort — logging must never crash the shell.
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
458
772
|
function flushDesktopLogBufferSync() {
|
|
459
773
|
if (!desktopLogBuffer) return
|
|
460
774
|
const chunk = desktopLogBuffer
|
|
@@ -462,6 +776,7 @@ function flushDesktopLogBufferSync() {
|
|
|
462
776
|
|
|
463
777
|
try {
|
|
464
778
|
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
|
779
|
+
rotateDesktopLogIfNeededSync()
|
|
465
780
|
fs.appendFileSync(DESKTOP_LOG_PATH, chunk)
|
|
466
781
|
} catch {
|
|
467
782
|
// Logging must never block app startup/shutdown.
|
|
@@ -476,6 +791,7 @@ function flushDesktopLogBufferAsync() {
|
|
|
476
791
|
desktopLogFlushPromise = desktopLogFlushPromise
|
|
477
792
|
.then(async () => {
|
|
478
793
|
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
|
794
|
+
await rotateDesktopLogIfNeededAsync()
|
|
479
795
|
await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk)
|
|
480
796
|
})
|
|
481
797
|
.catch(() => {
|
|
@@ -528,6 +844,39 @@ function openExternalUrl(rawUrl) {
|
|
|
528
844
|
return false
|
|
529
845
|
}
|
|
530
846
|
|
|
847
|
+
// `file://` URLs come from the artifacts panel (the renderer can't open
|
|
848
|
+
// them itself because Chromium blocks file:// navigation from the app
|
|
849
|
+
// origin). Hand them to `shell.openPath`, which dispatches to the OS
|
|
850
|
+
// file association. If the OS can't open it (`error` is a non-empty
|
|
851
|
+
// string), fall back to revealing the file in the system file manager.
|
|
852
|
+
if (parsed.protocol === 'file:') {
|
|
853
|
+
let localPath
|
|
854
|
+
try {
|
|
855
|
+
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' })
|
|
856
|
+
} catch {
|
|
857
|
+
return false
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
void shell
|
|
861
|
+
.openPath(localPath)
|
|
862
|
+
.then(error => {
|
|
863
|
+
if (!error) {
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
shell.showItemInFolder(localPath)
|
|
871
|
+
} catch (revealError) {
|
|
872
|
+
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
|
|
873
|
+
}
|
|
874
|
+
})
|
|
875
|
+
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
|
|
876
|
+
|
|
877
|
+
return true
|
|
878
|
+
}
|
|
879
|
+
|
|
531
880
|
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
|
|
532
881
|
return false
|
|
533
882
|
}
|
|
@@ -658,7 +1007,7 @@ function broadcastBootstrapEvent(ev) {
|
|
|
658
1007
|
error: ev.error ?? null
|
|
659
1008
|
}
|
|
660
1009
|
} else if (ev.type === 'log') {
|
|
661
|
-
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line })
|
|
1010
|
+
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' })
|
|
662
1011
|
if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) {
|
|
663
1012
|
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
|
|
664
1013
|
}
|
|
@@ -890,7 +1239,7 @@ function findSystemPython() {
|
|
|
890
1239
|
const out = execFileSync(
|
|
891
1240
|
'reg',
|
|
892
1241
|
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
|
893
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
1242
|
+
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
894
1243
|
)
|
|
895
1244
|
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
|
896
1245
|
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
|
@@ -926,10 +1275,14 @@ function findSystemPython() {
|
|
|
926
1275
|
if (pyExe) {
|
|
927
1276
|
for (const version of SUPPORTED_VERSIONS) {
|
|
928
1277
|
try {
|
|
929
|
-
const out = execFileSync(
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1278
|
+
const out = execFileSync(
|
|
1279
|
+
pyExe,
|
|
1280
|
+
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
|
|
1281
|
+
hiddenWindowsChildOptions({
|
|
1282
|
+
encoding: 'utf8',
|
|
1283
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
1284
|
+
})
|
|
1285
|
+
)
|
|
933
1286
|
const candidate = out.trim()
|
|
934
1287
|
if (candidate && fileExists(candidate)) return candidate
|
|
935
1288
|
} catch {
|
|
@@ -1036,9 +1389,17 @@ function readDesktopUpdateConfig() {
|
|
|
1036
1389
|
}
|
|
1037
1390
|
}
|
|
1038
1391
|
|
|
1392
|
+
// Atomic file write: temp + rename (atomic on all platforms). Prevents
|
|
1393
|
+
// partial writes on crash/power loss that corrupt JSON config files.
|
|
1394
|
+
function writeFileAtomic(targetPath, data, encoding) {
|
|
1395
|
+
const tmp = targetPath + '.tmp'
|
|
1396
|
+
fs.writeFileSync(tmp, data, encoding)
|
|
1397
|
+
fs.renameSync(tmp, targetPath)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1039
1400
|
function writeDesktopUpdateConfig(config) {
|
|
1040
1401
|
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
|
|
1041
|
-
|
|
1402
|
+
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
|
1042
1403
|
}
|
|
1043
1404
|
|
|
1044
1405
|
// Match the backend's source resolution but bias toward a real git checkout.
|
|
@@ -1056,11 +1417,15 @@ function resolveUpdateRoot() {
|
|
|
1056
1417
|
|
|
1057
1418
|
function runGit(args, options = {}) {
|
|
1058
1419
|
return new Promise((resolve, reject) => {
|
|
1059
|
-
const child = spawn(
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1420
|
+
const child = spawn(
|
|
1421
|
+
resolveGitBinary(),
|
|
1422
|
+
IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args,
|
|
1423
|
+
hiddenWindowsChildOptions({
|
|
1424
|
+
cwd: options.cwd,
|
|
1425
|
+
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
|
1426
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1427
|
+
})
|
|
1428
|
+
)
|
|
1064
1429
|
|
|
1065
1430
|
let stdout = ''
|
|
1066
1431
|
let stderr = ''
|
|
@@ -1081,6 +1446,11 @@ function runGit(args, options = {}) {
|
|
|
1081
1446
|
|
|
1082
1447
|
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
|
|
1083
1448
|
|
|
1449
|
+
async function getOriginUrl(updateRoot) {
|
|
1450
|
+
const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot })
|
|
1451
|
+
return origin.code === 0 ? origin.stdout.trim() : ''
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1084
1454
|
function emitUpdateProgress(payload) {
|
|
1085
1455
|
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
|
|
1086
1456
|
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
|
|
@@ -1100,7 +1470,9 @@ async function resolveHealedBranch(updateRoot, branch) {
|
|
|
1100
1470
|
return branch || 'main'
|
|
1101
1471
|
}
|
|
1102
1472
|
|
|
1103
|
-
const
|
|
1473
|
+
const originUrl = await getOriginUrl(updateRoot)
|
|
1474
|
+
const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin'
|
|
1475
|
+
const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot })
|
|
1104
1476
|
if (probe.code !== 2) {
|
|
1105
1477
|
return branch
|
|
1106
1478
|
}
|
|
@@ -1128,6 +1500,40 @@ async function checkUpdates() {
|
|
|
1128
1500
|
}
|
|
1129
1501
|
|
|
1130
1502
|
branch = await resolveHealedBranch(updateRoot, branch)
|
|
1503
|
+
const originUrl = await getOriginUrl(updateRoot)
|
|
1504
|
+
if (isOfficialSshRemote(originUrl)) {
|
|
1505
|
+
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
|
1506
|
+
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
|
|
1507
|
+
git(['rev-parse', 'HEAD']),
|
|
1508
|
+
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
|
|
1509
|
+
git(['status', '--porcelain']),
|
|
1510
|
+
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
1511
|
+
])
|
|
1512
|
+
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
|
|
1513
|
+
if (target.code !== 0 || !targetSha) {
|
|
1514
|
+
return {
|
|
1515
|
+
supported: true,
|
|
1516
|
+
branch,
|
|
1517
|
+
error: 'fetch-failed',
|
|
1518
|
+
message: firstLine(target.stderr) || 'git ls-remote failed.',
|
|
1519
|
+
hermesRoot: updateRoot,
|
|
1520
|
+
fetchedAt: Date.now()
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return {
|
|
1524
|
+
supported: true,
|
|
1525
|
+
branch,
|
|
1526
|
+
currentBranch,
|
|
1527
|
+
behind: currentSha && currentSha === targetSha ? 0 : 1,
|
|
1528
|
+
currentSha,
|
|
1529
|
+
targetSha,
|
|
1530
|
+
commits: [],
|
|
1531
|
+
dirty: dirtyStr.length > 0,
|
|
1532
|
+
hermesRoot: updateRoot,
|
|
1533
|
+
fetchedAt: Date.now()
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1131
1537
|
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
|
1132
1538
|
if (fetched.code !== 0) {
|
|
1133
1539
|
return {
|
|
@@ -1199,6 +1605,148 @@ function resolveUpdaterBinary() {
|
|
|
1199
1605
|
return fileExists(candidate) ? candidate : null
|
|
1200
1606
|
}
|
|
1201
1607
|
|
|
1608
|
+
function repairMacUpdaterHelper(updater) {
|
|
1609
|
+
if (!IS_MAC || !updater) return
|
|
1610
|
+
|
|
1611
|
+
try {
|
|
1612
|
+
execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' })
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
rememberLog(`[updates] macOS updater helper quarantine repair skipped: ${err.message}`)
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
try {
|
|
1618
|
+
execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' })
|
|
1619
|
+
return
|
|
1620
|
+
} catch {
|
|
1621
|
+
// Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper
|
|
1622
|
+
// does not block the staged updater before it can run.
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
try {
|
|
1626
|
+
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', updater], { stdio: 'ignore' })
|
|
1627
|
+
rememberLog('[updates] repaired macOS updater helper signature')
|
|
1628
|
+
} catch (err) {
|
|
1629
|
+
rememberLog(`[updates] macOS updater helper signature repair skipped: ${err.message}`)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Path to the venv shim whose lock decides whether `hermes update` can write
|
|
1634
|
+
// fresh entry points. On Windows this is the file the running backend
|
|
1635
|
+
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
|
|
1636
|
+
function venvHermesShimPath(updateRoot) {
|
|
1637
|
+
return IS_WINDOWS
|
|
1638
|
+
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
|
|
1639
|
+
: path.join(updateRoot, 'venv', 'bin', 'hermes')
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
|
|
1643
|
+
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
|
|
1644
|
+
// this practically always succeeds (no mandatory locking), so it returns false
|
|
1645
|
+
// — correct, since the shim-contention brick is Windows-only.
|
|
1646
|
+
function isShimLocked(shimPath) {
|
|
1647
|
+
if (!IS_WINDOWS) return false
|
|
1648
|
+
let fd
|
|
1649
|
+
try {
|
|
1650
|
+
fd = fs.openSync(shimPath, 'r+')
|
|
1651
|
+
return false
|
|
1652
|
+
} catch (err) {
|
|
1653
|
+
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
|
|
1654
|
+
// EACCES) on Windows means a live handle holds it.
|
|
1655
|
+
return err && err.code !== 'ENOENT'
|
|
1656
|
+
} finally {
|
|
1657
|
+
if (fd !== undefined) {
|
|
1658
|
+
try {
|
|
1659
|
+
fs.closeSync(fd)
|
|
1660
|
+
} catch {
|
|
1661
|
+
void 0
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
|
|
1668
|
+
// only signals the direct child, so on Windows a backend `hermes.exe` that
|
|
1669
|
+
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
|
|
1670
|
+
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
|
|
1671
|
+
// the whole tree synchronously. Windows-only: this is called solely from the
|
|
1672
|
+
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
|
|
1673
|
+
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
|
|
1674
|
+
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
|
|
1675
|
+
function forceKillProcessTree(pid) {
|
|
1676
|
+
if (!IS_WINDOWS) return
|
|
1677
|
+
if (!Number.isInteger(pid) || pid <= 0) return
|
|
1678
|
+
try {
|
|
1679
|
+
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
|
|
1680
|
+
} catch {
|
|
1681
|
+
// Already gone, or no permission — best effort; the unlock wait below is
|
|
1682
|
+
// the real gate.
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Before handing off the update on Windows, the desktop MUST stop every backend
|
|
1687
|
+
// it spawned and WAIT for the venv shim to actually unlock. The old code did
|
|
1688
|
+
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
|
|
1689
|
+
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
|
|
1690
|
+
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
|
|
1691
|
+
// rename failed, uv's `pip install` hit "Access is denied", and the git path
|
|
1692
|
+
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
|
|
1693
|
+
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
|
|
1694
|
+
// pool backends and poll the shim until it's writable (or a bounded timeout),
|
|
1695
|
+
// so by the time we spawn the updater the lock is genuinely gone.
|
|
1696
|
+
//
|
|
1697
|
+
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
|
|
1698
|
+
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
|
|
1699
|
+
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
|
|
1700
|
+
// aggressively SIGKILL-ing the backend here would be an untested behavior change
|
|
1701
|
+
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
|
|
1702
|
+
async function releaseBackendLockForUpdate(updateRoot) {
|
|
1703
|
+
return releaseBackendLock(updateRoot, 'updates')
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update
|
|
1707
|
+
// hand-off and the desktop uninstaller — they have the identical Windows
|
|
1708
|
+
// problem: the desktop's backend (and the grandchildren IT spawned — a hermes
|
|
1709
|
+
// REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the
|
|
1710
|
+
// venv mandatory-locked, so any in-place replace/delete of the install tree
|
|
1711
|
+
// races a live handle and half-fails (#37532). We tree-kill every backend PID
|
|
1712
|
+
// the desktop owns, then poll the shim until it's genuinely writable.
|
|
1713
|
+
//
|
|
1714
|
+
// `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory
|
|
1715
|
+
// locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice).
|
|
1716
|
+
async function releaseBackendLock(updateRoot, tag) {
|
|
1717
|
+
if (!IS_WINDOWS) return { unlocked: true }
|
|
1718
|
+
|
|
1719
|
+
// Collect every backend PID the desktop owns: primary window backend + pool.
|
|
1720
|
+
const pids = []
|
|
1721
|
+
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
|
|
1722
|
+
for (const entry of backendPool.values()) {
|
|
1723
|
+
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
|
|
1727
|
+
if (hermesProcess && !hermesProcess.killed) {
|
|
1728
|
+
try {
|
|
1729
|
+
hermesProcess.kill('SIGTERM')
|
|
1730
|
+
} catch {
|
|
1731
|
+
void 0
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
stopAllPoolBackends()
|
|
1735
|
+
for (const pid of pids) forceKillProcessTree(pid)
|
|
1736
|
+
|
|
1737
|
+
const shim = venvHermesShimPath(updateRoot)
|
|
1738
|
+
const deadlineMs = Date.now() + 15000
|
|
1739
|
+
while (Date.now() < deadlineMs) {
|
|
1740
|
+
if (!isShimLocked(shim)) {
|
|
1741
|
+
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
|
|
1742
|
+
return { unlocked: true }
|
|
1743
|
+
}
|
|
1744
|
+
await new Promise(r => setTimeout(r, 300))
|
|
1745
|
+
}
|
|
1746
|
+
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
|
|
1747
|
+
return { unlocked: false }
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1202
1750
|
// applyUpdates — hand off to the installer's --update flow, then exit.
|
|
1203
1751
|
//
|
|
1204
1752
|
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
|
|
@@ -1254,17 +1802,40 @@ async function applyUpdates(opts = {}) {
|
|
|
1254
1802
|
}
|
|
1255
1803
|
|
|
1256
1804
|
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
|
|
1805
|
+
repairMacUpdaterHelper(updater)
|
|
1806
|
+
|
|
1807
|
+
const updateRoot = resolveUpdateRoot()
|
|
1808
|
+
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
|
1809
|
+
const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
|
1810
|
+
const updaterArgs = ['--update', '--branch', branch]
|
|
1811
|
+
const targetApp = IS_MAC ? runningAppBundle() : null
|
|
1812
|
+
if (targetApp) {
|
|
1813
|
+
updaterArgs.push('--target-app', targetApp)
|
|
1814
|
+
}
|
|
1815
|
+
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
|
1816
|
+
|
|
1817
|
+
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
|
|
1818
|
+
// spawn the updater. Without this the updater races a still-locked
|
|
1819
|
+
// hermes.exe (held by the backend child / its grandchildren) and the update
|
|
1820
|
+
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
|
|
1821
|
+
await releaseBackendLockForUpdate(updateRoot)
|
|
1257
1822
|
|
|
1258
1823
|
// Detached so the updater outlives this process — it needs us GONE before
|
|
1259
1824
|
// `hermes update` will run (the venv shim is locked while we live).
|
|
1260
|
-
const child = spawn(updater,
|
|
1825
|
+
const child = spawn(updater, updaterArgs, {
|
|
1826
|
+
cwd: HERMES_HOME,
|
|
1827
|
+
env: {
|
|
1828
|
+
...process.env,
|
|
1829
|
+
HERMES_HOME,
|
|
1830
|
+
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
|
1831
|
+
},
|
|
1261
1832
|
detached: true,
|
|
1262
1833
|
stdio: 'ignore',
|
|
1263
1834
|
windowsHide: false
|
|
1264
1835
|
})
|
|
1265
1836
|
child.unref()
|
|
1266
1837
|
|
|
1267
|
-
rememberLog(`[updates] launched updater: ${updater}
|
|
1838
|
+
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
|
|
1268
1839
|
|
|
1269
1840
|
// Give the OS a beat to register the new process, then quit. The updater
|
|
1270
1841
|
// rebuilds and relaunches us when it's done.
|
|
@@ -1278,6 +1849,44 @@ async function applyUpdates(opts = {}) {
|
|
|
1278
1849
|
}
|
|
1279
1850
|
}
|
|
1280
1851
|
|
|
1852
|
+
async function handOffWindowsBootstrapRecovery(reason) {
|
|
1853
|
+
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
|
1854
|
+
|
|
1855
|
+
const updater = resolveUpdaterBinary()
|
|
1856
|
+
if (!updater) return false
|
|
1857
|
+
|
|
1858
|
+
const updateRoot = resolveUpdateRoot()
|
|
1859
|
+
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
|
1860
|
+
const branch = directoryExists(path.join(updateRoot, '.git'))
|
|
1861
|
+
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
|
1862
|
+
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
|
1863
|
+
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
|
1864
|
+
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
|
1865
|
+
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
|
1866
|
+
|
|
1867
|
+
await releaseBackendLockForUpdate(updateRoot)
|
|
1868
|
+
|
|
1869
|
+
const child = spawn(updater, updaterArgs, {
|
|
1870
|
+
cwd: HERMES_HOME,
|
|
1871
|
+
env: {
|
|
1872
|
+
...process.env,
|
|
1873
|
+
HERMES_HOME,
|
|
1874
|
+
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
|
1875
|
+
},
|
|
1876
|
+
detached: true,
|
|
1877
|
+
stdio: 'ignore',
|
|
1878
|
+
windowsHide: false
|
|
1879
|
+
})
|
|
1880
|
+
child.unref()
|
|
1881
|
+
|
|
1882
|
+
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
|
1883
|
+
setTimeout(() => {
|
|
1884
|
+
app.quit()
|
|
1885
|
+
}, 600)
|
|
1886
|
+
|
|
1887
|
+
return true
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1281
1890
|
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
|
1282
1891
|
// the install we're updating, fall back to `hermes` on PATH.
|
|
1283
1892
|
function resolveHermesCliBinary(updateRoot) {
|
|
@@ -1291,11 +1900,15 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
|
|
1291
1900
|
return new Promise(resolve => {
|
|
1292
1901
|
let child
|
|
1293
1902
|
try {
|
|
1294
|
-
child = spawn(
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1903
|
+
child = spawn(
|
|
1904
|
+
command,
|
|
1905
|
+
args,
|
|
1906
|
+
hiddenWindowsChildOptions({
|
|
1907
|
+
cwd,
|
|
1908
|
+
env: { ...process.env, ...(env || {}) },
|
|
1909
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1910
|
+
})
|
|
1911
|
+
)
|
|
1299
1912
|
} catch (err) {
|
|
1300
1913
|
resolve({ code: 1, error: err.message })
|
|
1301
1914
|
return
|
|
@@ -1330,7 +1943,7 @@ function shellQuote(value) {
|
|
|
1330
1943
|
// (`hermes desktop --build-only`), then atomically swap the running .app bundle
|
|
1331
1944
|
// with the freshly built one and relaunch. Degrades to "backend updated,
|
|
1332
1945
|
// restart to load the new GUI" if the swap can't be performed.
|
|
1333
|
-
async function applyUpdatesPosixInApp(
|
|
1946
|
+
async function applyUpdatesPosixInApp() {
|
|
1334
1947
|
const updateRoot = resolveUpdateRoot()
|
|
1335
1948
|
const hermes = resolveHermesCliBinary(updateRoot)
|
|
1336
1949
|
if (!hermes) {
|
|
@@ -1348,6 +1961,30 @@ async function applyUpdatesPosixInApp(opts = {}) {
|
|
|
1348
1961
|
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
|
|
1349
1962
|
}
|
|
1350
1963
|
|
|
1964
|
+
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
|
1965
|
+
// leaves the running process serving old Python against the freshly-updated
|
|
1966
|
+
// JS bundle). But OUR backend is one of those processes, and killing it
|
|
1967
|
+
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
|
1968
|
+
// already restarts its own backend via the rebuild+relaunch below, so the
|
|
1969
|
+
// reap must spare it. Hand the live backend's PID to the update process;
|
|
1970
|
+
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
|
1971
|
+
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
|
1972
|
+
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
|
1973
|
+
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
|
1974
|
+
// list (a single int still parses for back-compat).
|
|
1975
|
+
const desktopChildPids = []
|
|
1976
|
+
if (hermesProcess && Number.isInteger(hermesProcess.pid)) {
|
|
1977
|
+
desktopChildPids.push(hermesProcess.pid)
|
|
1978
|
+
}
|
|
1979
|
+
for (const entry of backendPool.values()) {
|
|
1980
|
+
if (entry.process && Number.isInteger(entry.process.pid)) {
|
|
1981
|
+
desktopChildPids.push(entry.process.pid)
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
if (desktopChildPids.length) {
|
|
1985
|
+
env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',')
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1351
1988
|
// Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
|
|
1352
1989
|
// to main when the pinned branch no longer exists on origin).
|
|
1353
1990
|
let branchArgs = []
|
|
@@ -1373,10 +2010,14 @@ async function applyUpdatesPosixInApp(opts = {}) {
|
|
|
1373
2010
|
}
|
|
1374
2011
|
|
|
1375
2012
|
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
2013
|
+
// Retry-once: a first rebuild can fail on a still-settling tree or a
|
|
2014
|
+
// self-healed (network-blocked) Electron download; a second run builds clean
|
|
2015
|
+
// off the healed dist so we reach the swap+relaunch below instead of bailing.
|
|
2016
|
+
const rebuilt = await runRebuildWithRetry(attempt => {
|
|
2017
|
+
if (attempt > 0) {
|
|
2018
|
+
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
|
|
2019
|
+
}
|
|
2020
|
+
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
|
|
1380
2021
|
})
|
|
1381
2022
|
if (rebuilt.code !== 0) {
|
|
1382
2023
|
emitUpdateProgress({
|
|
@@ -1499,7 +2140,7 @@ function writeBootstrapMarker(payload) {
|
|
|
1499
2140
|
completedAt: new Date().toISOString(),
|
|
1500
2141
|
desktopVersion: app.getVersion()
|
|
1501
2142
|
}
|
|
1502
|
-
|
|
2143
|
+
writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
|
1503
2144
|
return merged
|
|
1504
2145
|
}
|
|
1505
2146
|
|
|
@@ -1510,19 +2151,66 @@ function resolveWebDist() {
|
|
|
1510
2151
|
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
|
|
1511
2152
|
if (directoryExists(unpackedDist)) return unpackedDist
|
|
1512
2153
|
|
|
1513
|
-
|
|
2154
|
+
// Final fallback: APP_ROOT/dist. When packaged with asar:true this lives
|
|
2155
|
+
// INSIDE app.asar — not a servable filesystem directory — so the embedded
|
|
2156
|
+
// dashboard backend 404s on static routes (see #41327, #39472). The durable
|
|
2157
|
+
// fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2
|
|
2158
|
+
// unpackedDist above resolves). If we still land here while packaged, log it
|
|
2159
|
+
// so the cause isn't silent.
|
|
2160
|
+
const fallback = path.join(APP_ROOT, 'dist')
|
|
2161
|
+
if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) {
|
|
2162
|
+
rememberLog(
|
|
2163
|
+
`[web-dist] dashboard frontend dir resolved to an asar-internal path that ` +
|
|
2164
|
+
`is not a real directory: ${fallback}. Static routes will 404. ` +
|
|
2165
|
+
`Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.`
|
|
2166
|
+
)
|
|
2167
|
+
}
|
|
2168
|
+
return fallback
|
|
1514
2169
|
}
|
|
1515
2170
|
|
|
1516
2171
|
function resolveRendererIndex() {
|
|
1517
2172
|
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
|
|
1518
|
-
|
|
2173
|
+
const found = candidates.find(fileExists)
|
|
2174
|
+
if (found) return found
|
|
2175
|
+
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
|
|
2176
|
+
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
|
|
2177
|
+
// and the fix before Electron loads the missing file.
|
|
2178
|
+
rememberLog(
|
|
2179
|
+
`[renderer] index.html not found — the desktop app was packaged without a ` +
|
|
2180
|
+
`renderer bundle. Tried: ${candidates.join(', ')}. ` +
|
|
2181
|
+
`Rebuild with: hermes desktop --force-build`
|
|
2182
|
+
)
|
|
2183
|
+
return candidates[0]
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// True when `dir` lives inside the packaged app bundle / install tree.
|
|
2187
|
+
// Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling
|
|
2188
|
+
// leaked into a release build) often resolve here — e.g. win-unpacked on
|
|
2189
|
+
// Windows — which is exactly where PR #37536 item 16 said we must NOT run.
|
|
2190
|
+
function isPackagedInstallPath(dir) {
|
|
2191
|
+
return isPackagedInstallPathUnderRoots(dir, {
|
|
2192
|
+
isPackaged: IS_PACKAGED,
|
|
2193
|
+
installRoots: [
|
|
2194
|
+
APP_ROOT,
|
|
2195
|
+
path.dirname(process.execPath),
|
|
2196
|
+
resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
|
2197
|
+
]
|
|
2198
|
+
})
|
|
1519
2199
|
}
|
|
1520
2200
|
|
|
1521
2201
|
function resolveHermesCwd() {
|
|
2202
|
+
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
|
2203
|
+
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
|
2204
|
+
// on macOS). Sessions spawned there leave files inside the app bundle
|
|
2205
|
+
// and bewilder users when "where did my files go?" is the install dir.
|
|
2206
|
+
// The user-configurable default project directory wins over everything,
|
|
2207
|
+
// followed by env hints (only honored when packaged if they point at a
|
|
2208
|
+
// real directory), then the home dir.
|
|
1522
2209
|
const candidates = [
|
|
2210
|
+
readDefaultProjectDir(),
|
|
1523
2211
|
process.env.HERMES_DESKTOP_CWD,
|
|
1524
|
-
process.env.INIT_CWD,
|
|
1525
|
-
process.cwd(),
|
|
2212
|
+
IS_PACKAGED ? null : process.env.INIT_CWD,
|
|
2213
|
+
IS_PACKAGED ? null : process.cwd(),
|
|
1526
2214
|
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
|
1527
2215
|
app.getPath('home')
|
|
1528
2216
|
]
|
|
@@ -1530,12 +2218,79 @@ function resolveHermesCwd() {
|
|
|
1530
2218
|
for (const candidate of candidates) {
|
|
1531
2219
|
if (!candidate) continue
|
|
1532
2220
|
const resolved = path.resolve(String(candidate))
|
|
2221
|
+
|
|
2222
|
+
if (isPackagedInstallPath(resolved)) {
|
|
2223
|
+
continue
|
|
2224
|
+
}
|
|
2225
|
+
|
|
1533
2226
|
if (directoryExists(resolved)) return resolved
|
|
1534
2227
|
}
|
|
1535
2228
|
|
|
1536
2229
|
return app.getPath('home')
|
|
1537
2230
|
}
|
|
1538
2231
|
|
|
2232
|
+
function sanitizeWorkspaceCwd(cwd) {
|
|
2233
|
+
const trimmed = typeof cwd === 'string' ? cwd.trim() : ''
|
|
2234
|
+
|
|
2235
|
+
if (!trimmed || isPackagedInstallPath(trimmed)) {
|
|
2236
|
+
return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
try {
|
|
2240
|
+
const resolved = path.resolve(trimmed)
|
|
2241
|
+
|
|
2242
|
+
if (directoryExists(resolved)) {
|
|
2243
|
+
return { cwd: resolved, sanitized: false }
|
|
2244
|
+
}
|
|
2245
|
+
} catch {
|
|
2246
|
+
// Fall through to the resolved default.
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// Persisted "Default project directory" — surfaced as a setting in the
|
|
2253
|
+
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
|
2254
|
+
// userData so it survives self-updates without bleeding into the new
|
|
2255
|
+
// install. `null` means "no preference, fall back to the usual chain".
|
|
2256
|
+
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
|
|
2257
|
+
|
|
2258
|
+
function defaultProjectDirConfigPath() {
|
|
2259
|
+
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function readDefaultProjectDir() {
|
|
2263
|
+
try {
|
|
2264
|
+
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
|
|
2265
|
+
const parsed = JSON.parse(raw)
|
|
2266
|
+
|
|
2267
|
+
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
|
|
2268
|
+
const resolved = path.resolve(parsed.dir)
|
|
2269
|
+
|
|
2270
|
+
if (directoryExists(resolved)) {
|
|
2271
|
+
return resolved
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
} catch {
|
|
2275
|
+
// Missing / unreadable / malformed → fall through to the rest of the
|
|
2276
|
+
// candidate chain.
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
return null
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function writeDefaultProjectDir(dir) {
|
|
2283
|
+
const target = defaultProjectDirConfigPath()
|
|
2284
|
+
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
|
|
2285
|
+
|
|
2286
|
+
try {
|
|
2287
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
2288
|
+
fs.writeFileSync(target, payload, 'utf8')
|
|
2289
|
+
} catch (error) {
|
|
2290
|
+
rememberLog(`[settings] write default project dir failed: ${error.message}`)
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
1539
2294
|
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
|
1540
2295
|
const python = findPythonForRoot(root)
|
|
1541
2296
|
if (!python) return null
|
|
@@ -1545,9 +2300,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
|
|
1545
2300
|
label,
|
|
1546
2301
|
command: python,
|
|
1547
2302
|
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
|
1548
|
-
env: {
|
|
1549
|
-
|
|
1550
|
-
|
|
2303
|
+
env: buildDesktopBackendEnv({
|
|
2304
|
+
hermesHome: HERMES_HOME,
|
|
2305
|
+
pythonPathEntries: [root],
|
|
2306
|
+
venvRoot: path.join(root, 'venv')
|
|
2307
|
+
}),
|
|
1551
2308
|
root,
|
|
1552
2309
|
bootstrap: Boolean(options.bootstrap),
|
|
1553
2310
|
shell: false
|
|
@@ -1566,9 +2323,11 @@ function createActiveBackend(dashboardArgs) {
|
|
|
1566
2323
|
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
|
1567
2324
|
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
|
1568
2325
|
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
|
1569
|
-
env: {
|
|
1570
|
-
|
|
1571
|
-
|
|
2326
|
+
env: buildDesktopBackendEnv({
|
|
2327
|
+
hermesHome: HERMES_HOME,
|
|
2328
|
+
pythonPathEntries: [ACTIVE_HERMES_ROOT],
|
|
2329
|
+
venvRoot: VENV_ROOT
|
|
2330
|
+
}),
|
|
1572
2331
|
root: ACTIVE_HERMES_ROOT,
|
|
1573
2332
|
bootstrap: true,
|
|
1574
2333
|
shell: false
|
|
@@ -1729,6 +2488,14 @@ async function ensureRuntime(backend) {
|
|
|
1729
2488
|
if (backend.kind === 'bootstrap-needed') {
|
|
1730
2489
|
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
|
1731
2490
|
|
|
2491
|
+
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
|
2492
|
+
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
|
2493
|
+
handoffError.isBootstrapFailure = true
|
|
2494
|
+
handoffError.bootstrapHandedOff = true
|
|
2495
|
+
bootstrapFailure = handoffError
|
|
2496
|
+
throw handoffError
|
|
2497
|
+
}
|
|
2498
|
+
|
|
1732
2499
|
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
|
1733
2500
|
// shows the install overlay BEFORE the runner finishes fetching the
|
|
1734
2501
|
// manifest (which on slow networks can take tens of seconds and would
|
|
@@ -1741,7 +2508,9 @@ async function ensureRuntime(backend) {
|
|
|
1741
2508
|
stages: [],
|
|
1742
2509
|
protocolVersion: null
|
|
1743
2510
|
})
|
|
1744
|
-
} catch {
|
|
2511
|
+
} catch {
|
|
2512
|
+
void 0
|
|
2513
|
+
}
|
|
1745
2514
|
|
|
1746
2515
|
bootstrapAbortController = new AbortController()
|
|
1747
2516
|
|
|
@@ -1759,10 +2528,14 @@ async function ensureRuntime(backend) {
|
|
|
1759
2528
|
// bootstrap and a log-write failure doesn't suppress the UI signal.
|
|
1760
2529
|
try {
|
|
1761
2530
|
rememberLog(`[bootstrap] ${JSON.stringify(ev)}`)
|
|
1762
|
-
} catch {
|
|
2531
|
+
} catch {
|
|
2532
|
+
void 0
|
|
2533
|
+
}
|
|
1763
2534
|
try {
|
|
1764
2535
|
broadcastBootstrapEvent(ev)
|
|
1765
|
-
} catch {
|
|
2536
|
+
} catch {
|
|
2537
|
+
void 0
|
|
2538
|
+
}
|
|
1766
2539
|
},
|
|
1767
2540
|
writeMarker: writeBootstrapMarker
|
|
1768
2541
|
})
|
|
@@ -1852,23 +2625,6 @@ async function ensureRuntime(backend) {
|
|
|
1852
2625
|
return backend
|
|
1853
2626
|
}
|
|
1854
2627
|
|
|
1855
|
-
function isPortAvailable(port) {
|
|
1856
|
-
return new Promise(resolve => {
|
|
1857
|
-
const server = net.createServer()
|
|
1858
|
-
server.once('error', () => resolve(false))
|
|
1859
|
-
server.once('listening', () => {
|
|
1860
|
-
server.close(() => resolve(true))
|
|
1861
|
-
})
|
|
1862
|
-
server.listen(port, '127.0.0.1')
|
|
1863
|
-
})
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
async function pickPort() {
|
|
1867
|
-
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
|
1868
|
-
if (await isPortAvailable(port)) return port
|
|
1869
|
-
}
|
|
1870
|
-
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
|
1871
|
-
}
|
|
1872
2628
|
|
|
1873
2629
|
function fetchJson(url, token, options = {}) {
|
|
1874
2630
|
return new Promise((resolve, reject) => {
|
|
@@ -1894,6 +2650,7 @@ function fetchJson(url, token, options = {}) {
|
|
|
1894
2650
|
},
|
|
1895
2651
|
res => {
|
|
1896
2652
|
const chunks = []
|
|
2653
|
+
res.on('error', reject)
|
|
1897
2654
|
res.on('data', chunk => chunks.push(chunk))
|
|
1898
2655
|
res.on('end', () => {
|
|
1899
2656
|
const text = Buffer.concat(chunks).toString('utf8')
|
|
@@ -1938,6 +2695,80 @@ function fetchJson(url, token, options = {}) {
|
|
|
1938
2695
|
})
|
|
1939
2696
|
}
|
|
1940
2697
|
|
|
2698
|
+
function fetchPublicJson(url, options = {}) {
|
|
2699
|
+
// Credential-free JSON GET/POST for public gateway endpoints
|
|
2700
|
+
// (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends
|
|
2701
|
+
// NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before
|
|
2702
|
+
// any credentials exist, and any time we must not leak a token to an
|
|
2703
|
+
// endpoint that doesn't need one.
|
|
2704
|
+
return new Promise((resolve, reject) => {
|
|
2705
|
+
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
|
2706
|
+
let parsed
|
|
2707
|
+
try {
|
|
2708
|
+
parsed = new URL(url)
|
|
2709
|
+
} catch (error) {
|
|
2710
|
+
reject(new Error(`Invalid URL: ${error.message}`))
|
|
2711
|
+
return
|
|
2712
|
+
}
|
|
2713
|
+
const client = parsed.protocol === 'https:' ? https : http
|
|
2714
|
+
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
|
2715
|
+
|
|
2716
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
2717
|
+
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
|
2718
|
+
return
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
const req = client.request(
|
|
2722
|
+
parsed,
|
|
2723
|
+
{
|
|
2724
|
+
method: options.method || 'GET',
|
|
2725
|
+
headers: {
|
|
2726
|
+
'Content-Type': 'application/json',
|
|
2727
|
+
...(body ? { 'Content-Length': String(body.length) } : {})
|
|
2728
|
+
}
|
|
2729
|
+
},
|
|
2730
|
+
res => {
|
|
2731
|
+
const chunks = []
|
|
2732
|
+
res.on('data', chunk => chunks.push(chunk))
|
|
2733
|
+
res.on('end', () => {
|
|
2734
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
2735
|
+
if ((res.statusCode || 500) >= 400) {
|
|
2736
|
+
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
|
2737
|
+
return
|
|
2738
|
+
}
|
|
2739
|
+
if (!text) {
|
|
2740
|
+
resolve(null)
|
|
2741
|
+
return
|
|
2742
|
+
}
|
|
2743
|
+
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
|
|
2744
|
+
const contentType = String(res.headers['content-type'] || '')
|
|
2745
|
+
if (looksHtml || contentType.includes('text/html')) {
|
|
2746
|
+
reject(
|
|
2747
|
+
new Error(
|
|
2748
|
+
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
|
|
2749
|
+
'The endpoint is likely missing on the Hermes backend.'
|
|
2750
|
+
)
|
|
2751
|
+
)
|
|
2752
|
+
return
|
|
2753
|
+
}
|
|
2754
|
+
try {
|
|
2755
|
+
resolve(JSON.parse(text))
|
|
2756
|
+
} catch {
|
|
2757
|
+
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
|
|
2758
|
+
}
|
|
2759
|
+
})
|
|
2760
|
+
}
|
|
2761
|
+
)
|
|
2762
|
+
|
|
2763
|
+
req.on('error', reject)
|
|
2764
|
+
req.setTimeout(timeoutMs, () => {
|
|
2765
|
+
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
|
2766
|
+
})
|
|
2767
|
+
if (body) req.write(body)
|
|
2768
|
+
req.end()
|
|
2769
|
+
})
|
|
2770
|
+
}
|
|
2771
|
+
|
|
1941
2772
|
function mimeTypeForPath(filePath) {
|
|
1942
2773
|
const ext = path.extname(filePath || '').toLowerCase()
|
|
1943
2774
|
|
|
@@ -2001,6 +2832,7 @@ const RENDER_TITLE_BLOCKED_RESOURCES = new Set([
|
|
|
2001
2832
|
])
|
|
2002
2833
|
|
|
2003
2834
|
let linkTitleSession = null
|
|
2835
|
+
let oauthSession = null
|
|
2004
2836
|
let renderTitleInFlight = 0
|
|
2005
2837
|
const renderTitleQueue = []
|
|
2006
2838
|
|
|
@@ -2062,7 +2894,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
|
|
|
2062
2894
|
'--raw',
|
|
2063
2895
|
url
|
|
2064
2896
|
]
|
|
2065
|
-
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
2897
|
+
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
|
|
2066
2898
|
const chunks = []
|
|
2067
2899
|
let bytes = 0
|
|
2068
2900
|
|
|
@@ -2217,10 +3049,10 @@ async function resourceBufferFromUrl(rawUrl) {
|
|
|
2217
3049
|
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
|
2218
3050
|
return { buffer, mimeType }
|
|
2219
3051
|
}
|
|
2220
|
-
if (
|
|
2221
|
-
const
|
|
2222
|
-
const buffer = await fs.promises.readFile(
|
|
2223
|
-
return { buffer, mimeType: mimeTypeForPath(
|
|
3052
|
+
if (/^file:/i.test(rawUrl)) {
|
|
3053
|
+
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
|
|
3054
|
+
const buffer = await fs.promises.readFile(resolvedPath)
|
|
3055
|
+
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
|
|
2224
3056
|
}
|
|
2225
3057
|
|
|
2226
3058
|
const parsed = new URL(rawUrl)
|
|
@@ -2233,6 +3065,7 @@ async function resourceBufferFromUrl(rawUrl) {
|
|
|
2233
3065
|
return
|
|
2234
3066
|
}
|
|
2235
3067
|
const chunks = []
|
|
3068
|
+
res.on('error', reject)
|
|
2236
3069
|
res.on('data', chunk => chunks.push(chunk))
|
|
2237
3070
|
res.on('end', () => {
|
|
2238
3071
|
resolve({
|
|
@@ -2297,11 +3130,13 @@ function expandUserPath(filePath) {
|
|
|
2297
3130
|
return value
|
|
2298
3131
|
}
|
|
2299
3132
|
|
|
2300
|
-
function previewFileTarget(rawTarget, baseDir) {
|
|
3133
|
+
async function previewFileTarget(rawTarget, baseDir) {
|
|
2301
3134
|
const raw = String(rawTarget || '').trim()
|
|
2302
3135
|
const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd()
|
|
2303
|
-
|
|
2304
|
-
|
|
3136
|
+
let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), {
|
|
3137
|
+
baseDir: base,
|
|
3138
|
+
purpose: 'Preview target'
|
|
3139
|
+
})
|
|
2305
3140
|
|
|
2306
3141
|
if (directoryExists(resolved)) {
|
|
2307
3142
|
resolved = path.join(resolved, 'index.html')
|
|
@@ -2312,6 +3147,8 @@ function previewFileTarget(rawTarget, baseDir) {
|
|
|
2312
3147
|
return null
|
|
2313
3148
|
}
|
|
2314
3149
|
|
|
3150
|
+
;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' }))
|
|
3151
|
+
|
|
2315
3152
|
const mimeType = mimeTypeForPath(resolved)
|
|
2316
3153
|
const metadata = previewFileMetadata(resolved, mimeType)
|
|
2317
3154
|
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
|
@@ -2357,7 +3194,7 @@ function previewUrlTarget(rawTarget) {
|
|
|
2357
3194
|
}
|
|
2358
3195
|
}
|
|
2359
3196
|
|
|
2360
|
-
function normalizePreviewTarget(rawTarget, baseDir) {
|
|
3197
|
+
async function normalizePreviewTarget(rawTarget, baseDir) {
|
|
2361
3198
|
const raw = String(rawTarget || '').trim()
|
|
2362
3199
|
|
|
2363
3200
|
if (!raw) {
|
|
@@ -2369,20 +3206,15 @@ function normalizePreviewTarget(rawTarget, baseDir) {
|
|
|
2369
3206
|
return previewUrlTarget(raw)
|
|
2370
3207
|
}
|
|
2371
3208
|
|
|
2372
|
-
return previewFileTarget(raw, baseDir)
|
|
3209
|
+
return await previewFileTarget(raw, baseDir)
|
|
2373
3210
|
} catch {
|
|
2374
3211
|
return null
|
|
2375
3212
|
}
|
|
2376
3213
|
}
|
|
2377
3214
|
|
|
2378
|
-
function filePathFromPreviewUrl(rawUrl) {
|
|
2379
|
-
const
|
|
2380
|
-
|
|
2381
|
-
if (!fileExists(filePath)) {
|
|
2382
|
-
throw new Error('Preview file is not readable')
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
return filePath
|
|
3215
|
+
async function filePathFromPreviewUrl(rawUrl) {
|
|
3216
|
+
const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' })
|
|
3217
|
+
return resolvedPath
|
|
2386
3218
|
}
|
|
2387
3219
|
|
|
2388
3220
|
function sendPreviewFileChanged(payload) {
|
|
@@ -2392,8 +3224,8 @@ function sendPreviewFileChanged(payload) {
|
|
|
2392
3224
|
webContents.send('hermes:preview-file-changed', payload)
|
|
2393
3225
|
}
|
|
2394
3226
|
|
|
2395
|
-
function watchPreviewFile(rawUrl) {
|
|
2396
|
-
const filePath = filePathFromPreviewUrl(rawUrl)
|
|
3227
|
+
async function watchPreviewFile(rawUrl) {
|
|
3228
|
+
const filePath = await filePathFromPreviewUrl(rawUrl)
|
|
2397
3229
|
const watchDir = path.dirname(filePath)
|
|
2398
3230
|
const targetName = path.basename(filePath)
|
|
2399
3231
|
const id = crypto.randomBytes(12).toString('base64url')
|
|
@@ -2494,6 +3326,32 @@ function sendClosePreviewRequested() {
|
|
|
2494
3326
|
webContents.send('hermes:close-preview-requested')
|
|
2495
3327
|
}
|
|
2496
3328
|
|
|
3329
|
+
// Tell the renderer the machine just woke. Sleep silently drops the
|
|
3330
|
+
// renderer's WebSocket to the local backend; the renderer reconnects on this
|
|
3331
|
+
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
|
|
3332
|
+
function sendPowerResume() {
|
|
3333
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
3334
|
+
const { webContents } = mainWindow
|
|
3335
|
+
if (!webContents || webContents.isDestroyed()) return
|
|
3336
|
+
webContents.send('hermes:power-resume')
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
let powerResumeRegistered = false
|
|
3340
|
+
|
|
3341
|
+
function registerPowerResumeListeners() {
|
|
3342
|
+
if (powerResumeRegistered) return
|
|
3343
|
+
powerResumeRegistered = true
|
|
3344
|
+
try {
|
|
3345
|
+
// 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a
|
|
3346
|
+
// full suspend. Either can drop an idle socket.
|
|
3347
|
+
powerMonitor.on('resume', sendPowerResume)
|
|
3348
|
+
powerMonitor.on('unlock-screen', sendPowerResume)
|
|
3349
|
+
} catch {
|
|
3350
|
+
// powerMonitor is unavailable before app 'ready' on some platforms; the
|
|
3351
|
+
// caller registers after 'ready', so this should not normally throw.
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
|
|
2497
3355
|
function getAppIconPath() {
|
|
2498
3356
|
return APP_ICON_PATHS.find(fileExists)
|
|
2499
3357
|
}
|
|
@@ -2530,7 +3388,7 @@ function buildApplicationMenu() {
|
|
|
2530
3388
|
template.push({
|
|
2531
3389
|
label: APP_NAME,
|
|
2532
3390
|
submenu: [
|
|
2533
|
-
{
|
|
3391
|
+
{ label: `About ${APP_NAME}`, click: () => showAboutPanelFresh() },
|
|
2534
3392
|
checkForUpdatesItem,
|
|
2535
3393
|
{ type: 'separator' },
|
|
2536
3394
|
{ role: 'services' },
|
|
@@ -2582,9 +3440,31 @@ function buildApplicationMenu() {
|
|
|
2582
3440
|
{ role: 'forceReload' },
|
|
2583
3441
|
{ role: 'toggleDevTools' },
|
|
2584
3442
|
{ type: 'separator' },
|
|
2585
|
-
{
|
|
2586
|
-
|
|
2587
|
-
|
|
3443
|
+
{
|
|
3444
|
+
label: 'Actual Size',
|
|
3445
|
+
accelerator: 'CommandOrControl+0',
|
|
3446
|
+
click: () => {
|
|
3447
|
+
setAndPersistZoomLevel(mainWindow, 0)
|
|
3448
|
+
}
|
|
3449
|
+
},
|
|
3450
|
+
{
|
|
3451
|
+
label: 'Zoom In',
|
|
3452
|
+
accelerator: 'CommandOrControl+Plus',
|
|
3453
|
+
click: () => {
|
|
3454
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
3455
|
+
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1)
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
label: 'Zoom Out',
|
|
3461
|
+
accelerator: 'CommandOrControl+-',
|
|
3462
|
+
click: () => {
|
|
3463
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
3464
|
+
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1)
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
},
|
|
2588
3468
|
{ type: 'separator' },
|
|
2589
3469
|
{ role: 'togglefullscreen' }
|
|
2590
3470
|
]
|
|
@@ -2643,6 +3523,66 @@ function installPreviewShortcut(window) {
|
|
|
2643
3523
|
})
|
|
2644
3524
|
}
|
|
2645
3525
|
|
|
3526
|
+
// Zoom level is persisted in the renderer's own localStorage (per-origin,
|
|
3527
|
+
// survives reloads/restarts) rather than a main-process JSON file. The main
|
|
3528
|
+
// process owns setZoomLevel, so we mirror each change into localStorage and
|
|
3529
|
+
// read it back on did-finish-load to re-apply after reloads or crash recovery.
|
|
3530
|
+
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
|
|
3531
|
+
|
|
3532
|
+
function clampZoomLevel(value) {
|
|
3533
|
+
if (!Number.isFinite(value)) return 0
|
|
3534
|
+
return Math.min(Math.max(value, -9), 9)
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
function setAndPersistZoomLevel(window, zoomLevel) {
|
|
3538
|
+
if (!window || window.isDestroyed()) return
|
|
3539
|
+
const next = clampZoomLevel(zoomLevel)
|
|
3540
|
+
window.webContents.setZoomLevel(next)
|
|
3541
|
+
window.webContents
|
|
3542
|
+
.executeJavaScript(
|
|
3543
|
+
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
|
|
3544
|
+
)
|
|
3545
|
+
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
function restorePersistedZoomLevel(window) {
|
|
3549
|
+
if (!window || window.isDestroyed()) return
|
|
3550
|
+
window.webContents
|
|
3551
|
+
.executeJavaScript(
|
|
3552
|
+
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
|
|
3553
|
+
)
|
|
3554
|
+
.then(stored => {
|
|
3555
|
+
if (stored == null || !window || window.isDestroyed()) return
|
|
3556
|
+
const level = clampZoomLevel(Number(stored))
|
|
3557
|
+
window.webContents.setZoomLevel(level)
|
|
3558
|
+
})
|
|
3559
|
+
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
function installZoomShortcuts(window) {
|
|
3563
|
+
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
|
|
3564
|
+
// The menu items handle this on macOS (where the menu is always present),
|
|
3565
|
+
// but on Linux/Windows the menu is null and Chromium's default handler
|
|
3566
|
+
// would use the full 0.2 step, so we intercept here for consistency.
|
|
3567
|
+
const ZOOM_STEP = 0.1
|
|
3568
|
+
window.webContents.on('before-input-event', (event, input) => {
|
|
3569
|
+
const mod = IS_MAC ? input.meta : input.control
|
|
3570
|
+
if (!mod || input.alt || input.shift) return
|
|
3571
|
+
|
|
3572
|
+
const key = input.key
|
|
3573
|
+
if (key === '0') {
|
|
3574
|
+
event.preventDefault()
|
|
3575
|
+
setAndPersistZoomLevel(window, 0)
|
|
3576
|
+
} else if (key === '=' || key === '+') {
|
|
3577
|
+
event.preventDefault()
|
|
3578
|
+
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP)
|
|
3579
|
+
} else if (key === '-') {
|
|
3580
|
+
event.preventDefault()
|
|
3581
|
+
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP)
|
|
3582
|
+
}
|
|
3583
|
+
})
|
|
3584
|
+
}
|
|
3585
|
+
|
|
2646
3586
|
function installContextMenu(window) {
|
|
2647
3587
|
window.webContents.on('context-menu', (_event, params) => {
|
|
2648
3588
|
const template = []
|
|
@@ -2695,6 +3635,28 @@ function installContextMenu(window) {
|
|
|
2695
3635
|
)
|
|
2696
3636
|
}
|
|
2697
3637
|
|
|
3638
|
+
// Spell-check suggestions for the misspelled word under the caret.
|
|
3639
|
+
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
|
|
3640
|
+
// top 5 plus a "Add to dictionary" affordance.
|
|
3641
|
+
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
|
|
3642
|
+
|
|
3643
|
+
if (isEditable && params.misspelledWord && suggestions.length > 0) {
|
|
3644
|
+
if (template.length) template.push({ type: 'separator' })
|
|
3645
|
+
|
|
3646
|
+
for (const suggestion of suggestions.slice(0, 5)) {
|
|
3647
|
+
template.push({
|
|
3648
|
+
label: suggestion,
|
|
3649
|
+
click: () => window.webContents.replaceMisspelling(suggestion)
|
|
3650
|
+
})
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
template.push({ type: 'separator' })
|
|
3654
|
+
template.push({
|
|
3655
|
+
label: 'Add to dictionary',
|
|
3656
|
+
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
|
3657
|
+
})
|
|
3658
|
+
}
|
|
3659
|
+
|
|
2698
3660
|
if (hasSelection || isEditable) {
|
|
2699
3661
|
if (template.length) template.push({ type: 'separator' })
|
|
2700
3662
|
if (isEditable) {
|
|
@@ -2769,47 +3731,307 @@ function installMediaPermissions() {
|
|
|
2769
3731
|
})
|
|
2770
3732
|
}
|
|
2771
3733
|
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
3734
|
+
// ---------------------------------------------------------------------------
|
|
3735
|
+
// OAuth remote-gateway auth.
|
|
3736
|
+
//
|
|
3737
|
+
// Hosted Hermes gateways gate the dashboard behind an OAuth provider (e.g.
|
|
3738
|
+
// Nous Research) instead of a static session token. The auth model is
|
|
3739
|
+
// fundamentally different from the token path:
|
|
3740
|
+
//
|
|
3741
|
+
// * REST is authed by HttpOnly session cookies (``hermes_session_at``),
|
|
3742
|
+
// established by a browser redirect round-trip (/login → IDP →
|
|
3743
|
+
// /auth/callback sets cookies). We cannot read the HttpOnly cookie value
|
|
3744
|
+
// in JS — instead we let an Electron BrowserWindow complete the round
|
|
3745
|
+
// trip into a PERSISTENT session partition, and thereafter route our REST
|
|
3746
|
+
// through Electron's ``net`` bound to that same partition so the cookie
|
|
3747
|
+
// jar attaches the cookie automatically.
|
|
3748
|
+
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
|
|
3749
|
+
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
|
|
3750
|
+
// path is unconditionally rejected by gated gateways.
|
|
3751
|
+
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
|
|
3752
|
+
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
|
|
3753
|
+
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
|
|
3754
|
+
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
|
|
3755
|
+
// is still alive, the gateway middleware transparently rotates a fresh AT
|
|
3756
|
+
// on the next authenticated request — so connectivity must NOT be gated on
|
|
3757
|
+
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
|
|
3758
|
+
// (which triggers that server-side refresh) and treat a real 401 as
|
|
3759
|
+
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
|
|
3760
|
+
// "is the user signed in at all?" gate / display signal.
|
|
3761
|
+
// ---------------------------------------------------------------------------
|
|
3762
|
+
|
|
3763
|
+
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
|
|
3764
|
+
|
|
3765
|
+
function getOauthSession() {
|
|
3766
|
+
if (oauthSession || !app.isReady()) return oauthSession
|
|
3767
|
+
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
|
|
3768
|
+
return oauthSession
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
// Bare + prefixed variants of the session cookies live in
|
|
3772
|
+
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
|
|
3773
|
+
// that module for details.
|
|
3774
|
+
|
|
3775
|
+
async function hasOauthSessionCookie(baseUrl) {
|
|
3776
|
+
const sess = getOauthSession()
|
|
3777
|
+
if (!sess) return false
|
|
3778
|
+
const parsed = new URL(baseUrl)
|
|
3779
|
+
try {
|
|
3780
|
+
// Query by URL so the cookie jar applies Domain/Path/Secure scoping for us.
|
|
3781
|
+
const cookies = await sess.cookies.get({ url: baseUrl })
|
|
3782
|
+
return cookiesHaveSession(cookies)
|
|
3783
|
+
} catch {
|
|
3784
|
+
// Fall back to a host match if the URL query path errors.
|
|
3785
|
+
try {
|
|
3786
|
+
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
|
3787
|
+
return cookiesHaveSession(cookies)
|
|
3788
|
+
} catch {
|
|
3789
|
+
return false
|
|
3790
|
+
}
|
|
2777
3791
|
}
|
|
3792
|
+
}
|
|
2778
3793
|
|
|
2779
|
-
|
|
3794
|
+
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
|
|
3795
|
+
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
|
|
3796
|
+
// "is the user signed in at all?" check: an expired AT with a live RT is still
|
|
3797
|
+
// a connectable session because the gateway rotates a fresh AT server-side on
|
|
3798
|
+
// the next authenticated request. Gating on the AT alone forces a needless full
|
|
3799
|
+
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
|
|
3800
|
+
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
|
|
3801
|
+
async function hasLiveOauthSession(baseUrl) {
|
|
3802
|
+
const sess = getOauthSession()
|
|
3803
|
+
if (!sess) return false
|
|
3804
|
+
const parsed = new URL(baseUrl)
|
|
2780
3805
|
try {
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
3806
|
+
const cookies = await sess.cookies.get({ url: baseUrl })
|
|
3807
|
+
return cookiesHaveLiveSession(cookies)
|
|
3808
|
+
} catch {
|
|
3809
|
+
try {
|
|
3810
|
+
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
|
3811
|
+
return cookiesHaveLiveSession(cookies)
|
|
3812
|
+
} catch {
|
|
3813
|
+
return false
|
|
3814
|
+
}
|
|
2784
3815
|
}
|
|
3816
|
+
}
|
|
2785
3817
|
|
|
2786
|
-
|
|
2787
|
-
|
|
3818
|
+
async function clearOauthSession(baseUrl) {
|
|
3819
|
+
const sess = getOauthSession()
|
|
3820
|
+
if (!sess) return
|
|
3821
|
+
try {
|
|
3822
|
+
const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {})
|
|
3823
|
+
await Promise.all(
|
|
3824
|
+
cookies.map(c => {
|
|
3825
|
+
const scheme = c.secure ? 'https' : 'http'
|
|
3826
|
+
const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}`
|
|
3827
|
+
return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined)
|
|
3828
|
+
})
|
|
3829
|
+
)
|
|
3830
|
+
} catch {
|
|
3831
|
+
// Best effort — a stale cookie self-expires anyway.
|
|
2788
3832
|
}
|
|
3833
|
+
}
|
|
2789
3834
|
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
3835
|
+
// Open the gateway's /login page in a visible window using the OAuth session
|
|
3836
|
+
// partition, and resolve once the access-token cookie appears (login done) or
|
|
3837
|
+
// reject if the user closes the window first. The window navigates through the
|
|
3838
|
+
// IDP and back to /auth/callback, which sets the session cookies on the
|
|
3839
|
+
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
|
|
3840
|
+
function openOauthLoginWindow(baseUrl) {
|
|
3841
|
+
return new Promise((resolve, reject) => {
|
|
3842
|
+
if (!app.isReady()) {
|
|
3843
|
+
reject(new Error('Desktop is not ready to start an OAuth login.'))
|
|
3844
|
+
return
|
|
3845
|
+
}
|
|
3846
|
+
const sess = getOauthSession()
|
|
3847
|
+
if (!sess) {
|
|
3848
|
+
reject(new Error('OAuth session partition is unavailable.'))
|
|
3849
|
+
return
|
|
3850
|
+
}
|
|
2793
3851
|
|
|
2794
|
-
|
|
2795
|
-
|
|
3852
|
+
let settled = false
|
|
3853
|
+
let win = null
|
|
3854
|
+
let pollTimer = null
|
|
2796
3855
|
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
3856
|
+
const finish = err => {
|
|
3857
|
+
if (settled) return
|
|
3858
|
+
settled = true
|
|
3859
|
+
if (pollTimer) clearInterval(pollTimer)
|
|
3860
|
+
try {
|
|
3861
|
+
if (win && !win.isDestroyed()) win.destroy()
|
|
3862
|
+
} catch {
|
|
3863
|
+
// window already torn down
|
|
3864
|
+
}
|
|
3865
|
+
if (err) reject(err)
|
|
3866
|
+
else resolve({ baseUrl, ok: true })
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
const checkCookie = async () => {
|
|
3870
|
+
if (settled) return
|
|
3871
|
+
if (await hasOauthSessionCookie(baseUrl)) finish(null)
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
try {
|
|
3875
|
+
win = new BrowserWindow({
|
|
3876
|
+
width: 520,
|
|
3877
|
+
height: 720,
|
|
3878
|
+
title: 'Sign in to Hermes gateway',
|
|
3879
|
+
autoHideMenuBar: true,
|
|
3880
|
+
webPreferences: {
|
|
3881
|
+
contextIsolation: true,
|
|
3882
|
+
nodeIntegration: false,
|
|
3883
|
+
sandbox: true,
|
|
3884
|
+
session: sess,
|
|
3885
|
+
webSecurity: true
|
|
3886
|
+
}
|
|
3887
|
+
})
|
|
3888
|
+
} catch (error) {
|
|
3889
|
+
finish(error instanceof Error ? error : new Error(String(error)))
|
|
3890
|
+
return
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
// Re-check the cookie jar on every successful navigation (the callback
|
|
3894
|
+
// redirect is the moment cookies get set) plus a low-frequency poll as a
|
|
3895
|
+
// belt-and-braces fallback for IDPs that finish via in-page JS.
|
|
3896
|
+
win.webContents.on('did-navigate', () => void checkCookie())
|
|
3897
|
+
win.webContents.on('did-redirect-navigation', () => void checkCookie())
|
|
3898
|
+
win.webContents.on('did-frame-navigate', () => void checkCookie())
|
|
3899
|
+
pollTimer = setInterval(() => void checkCookie(), 750)
|
|
3900
|
+
|
|
3901
|
+
win.on('closed', () => {
|
|
3902
|
+
if (!settled) finish(new Error('Login window closed before authentication completed.'))
|
|
3903
|
+
})
|
|
2801
3904
|
|
|
2802
|
-
|
|
3905
|
+
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
|
|
3906
|
+
// login, which is a valid authenticated page that sets the cookies. We
|
|
3907
|
+
// only care that the cookie jar is populated.
|
|
3908
|
+
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
|
|
3909
|
+
win.loadURL(loginUrl).catch(error => {
|
|
3910
|
+
finish(error instanceof Error ? error : new Error(String(error)))
|
|
3911
|
+
})
|
|
3912
|
+
})
|
|
2803
3913
|
}
|
|
2804
3914
|
|
|
2805
|
-
|
|
2806
|
-
|
|
3915
|
+
// JSON request routed through the OAuth session partition so the HttpOnly
|
|
3916
|
+
// session cookie is attached automatically by Electron's net stack. Used for
|
|
3917
|
+
// authed REST against a gated gateway, including minting WS tickets.
|
|
3918
|
+
function fetchJsonViaOauthSession(url, options = {}) {
|
|
3919
|
+
return new Promise((resolve, reject) => {
|
|
3920
|
+
const sess = getOauthSession()
|
|
3921
|
+
if (!sess) {
|
|
3922
|
+
reject(new Error('OAuth session partition is unavailable.'))
|
|
3923
|
+
return
|
|
3924
|
+
}
|
|
3925
|
+
let parsed
|
|
3926
|
+
try {
|
|
3927
|
+
parsed = new URL(url)
|
|
3928
|
+
} catch (error) {
|
|
3929
|
+
reject(new Error(`Invalid URL: ${error.message}`))
|
|
3930
|
+
return
|
|
3931
|
+
}
|
|
3932
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
3933
|
+
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
|
3934
|
+
return
|
|
3935
|
+
}
|
|
3936
|
+
const body = serializeJsonBody(options.body)
|
|
3937
|
+
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
|
2807
3938
|
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
3939
|
+
const request = electronNet.request({
|
|
3940
|
+
method: options.method || 'GET',
|
|
3941
|
+
url,
|
|
3942
|
+
session: sess,
|
|
3943
|
+
useSessionCookies: true,
|
|
3944
|
+
redirect: 'follow'
|
|
3945
|
+
})
|
|
3946
|
+
setJsonRequestHeaders(request)
|
|
3947
|
+
|
|
3948
|
+
let timedOut = false
|
|
3949
|
+
const timer = setTimeout(() => {
|
|
3950
|
+
timedOut = true
|
|
3951
|
+
try {
|
|
3952
|
+
request.abort()
|
|
3953
|
+
} catch {
|
|
3954
|
+
// already finished
|
|
3955
|
+
}
|
|
3956
|
+
reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
|
3957
|
+
}, timeoutMs)
|
|
3958
|
+
|
|
3959
|
+
request.on('response', res => {
|
|
3960
|
+
const chunks = []
|
|
3961
|
+
res.on('data', chunk => chunks.push(Buffer.from(chunk)))
|
|
3962
|
+
res.on('end', () => {
|
|
3963
|
+
if (timedOut) return
|
|
3964
|
+
clearTimeout(timer)
|
|
3965
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
3966
|
+
const statusCode = res.statusCode || 500
|
|
3967
|
+
if (statusCode >= 400) {
|
|
3968
|
+
const err = new Error(`${statusCode}: ${text || ''}`)
|
|
3969
|
+
err.statusCode = statusCode
|
|
3970
|
+
reject(err)
|
|
3971
|
+
return
|
|
3972
|
+
}
|
|
3973
|
+
if (!text) {
|
|
3974
|
+
resolve(null)
|
|
3975
|
+
return
|
|
3976
|
+
}
|
|
3977
|
+
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
|
|
3978
|
+
const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '')
|
|
3979
|
+
if (looksHtml || contentType.includes('text/html')) {
|
|
3980
|
+
reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`))
|
|
3981
|
+
return
|
|
3982
|
+
}
|
|
3983
|
+
try {
|
|
3984
|
+
resolve(JSON.parse(text))
|
|
3985
|
+
} catch {
|
|
3986
|
+
reject(new Error(`Invalid JSON from ${url} (status ${statusCode}): ${text.slice(0, 200)}`))
|
|
3987
|
+
}
|
|
3988
|
+
})
|
|
3989
|
+
})
|
|
3990
|
+
request.on('error', error => {
|
|
3991
|
+
if (timedOut) return
|
|
3992
|
+
clearTimeout(timer)
|
|
3993
|
+
reject(error)
|
|
3994
|
+
})
|
|
3995
|
+
if (body) request.write(body)
|
|
3996
|
+
request.end()
|
|
3997
|
+
})
|
|
3998
|
+
}
|
|
2811
3999
|
|
|
2812
|
-
|
|
4000
|
+
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
|
|
4001
|
+
// Throws (with statusCode 401) if the session cookie is missing/expired —
|
|
4002
|
+
// callers treat that as "needs re-login".
|
|
4003
|
+
async function mintGatewayWsTicket(baseUrl) {
|
|
4004
|
+
const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
|
|
4005
|
+
method: 'POST',
|
|
4006
|
+
timeoutMs: 8_000
|
|
4007
|
+
})
|
|
4008
|
+
const ticket = body?.ticket
|
|
4009
|
+
if (!ticket || typeof ticket !== 'string') {
|
|
4010
|
+
throw new Error('Gateway did not return a WS ticket.')
|
|
4011
|
+
}
|
|
4012
|
+
return ticket
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
// Build a fresh WS URL for the *current* connection. Critical for reconnects:
|
|
4016
|
+
// OAuth WS tickets are single-use with a ~30s TTL, so the ticket baked into
|
|
4017
|
+
// the cached connection's wsUrl is stale on the second connect. The renderer
|
|
4018
|
+
// calls this immediately before every gateway.connect() so each WS upgrade
|
|
4019
|
+
// carries a freshly-minted ticket. For local/token connections this just
|
|
4020
|
+
// reuses the static token (no minting needed).
|
|
4021
|
+
async function freshGatewayWsUrl(profile) {
|
|
4022
|
+
// Mint for the requested profile's backend, NOT always the primary. The
|
|
4023
|
+
// renderer re-mints right before every gateway.connect(); when swapping to a
|
|
4024
|
+
// pooled profile we must return THAT backend's ws URL, otherwise the connect
|
|
4025
|
+
// silently lands back on the primary (default) backend and writes sessions to
|
|
4026
|
+
// the wrong profile's DB. A null/empty profile resolves to the primary, so
|
|
4027
|
+
// legacy callers and single-profile users are unchanged.
|
|
4028
|
+
const connection = await ensureBackend(profile)
|
|
4029
|
+
if (connection.authMode === 'oauth') {
|
|
4030
|
+
const ticket = await mintGatewayWsTicket(connection.baseUrl)
|
|
4031
|
+
return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket)
|
|
4032
|
+
}
|
|
4033
|
+
// Local/token: the cached wsUrl already carries the (long-lived) token.
|
|
4034
|
+
return connection.wsUrl
|
|
2813
4035
|
}
|
|
2814
4036
|
|
|
2815
4037
|
function encryptDesktopSecret(value) {
|
|
@@ -2838,21 +4060,72 @@ function decryptDesktopSecret(secret) {
|
|
|
2838
4060
|
return value
|
|
2839
4061
|
}
|
|
2840
4062
|
|
|
4063
|
+
// Validate + normalize the per-profile remote overrides map read from disk.
|
|
4064
|
+
// Drops malformed names/entries and keeps only the recognized fields so a
|
|
4065
|
+
// hand-edited or stale connection.json can't inject junk into resolution.
|
|
4066
|
+
function sanitizeConnectionProfiles(raw) {
|
|
4067
|
+
if (!raw || typeof raw !== 'object') {
|
|
4068
|
+
return {}
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
const out = {}
|
|
4072
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
4073
|
+
if (!entry || typeof entry !== 'object') {
|
|
4074
|
+
continue
|
|
4075
|
+
}
|
|
4076
|
+
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
|
|
4077
|
+
continue
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
|
|
4081
|
+
const url = String(entry.url || '').trim()
|
|
4082
|
+
if (url) {
|
|
4083
|
+
cleaned.url = url
|
|
4084
|
+
}
|
|
4085
|
+
cleaned.authMode = normAuthMode(entry.authMode)
|
|
4086
|
+
if (entry.token && typeof entry.token === 'object') {
|
|
4087
|
+
cleaned.token = entry.token
|
|
4088
|
+
}
|
|
4089
|
+
out[name] = cleaned
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
return out
|
|
4093
|
+
}
|
|
4094
|
+
|
|
2841
4095
|
function readDesktopConnectionConfig() {
|
|
2842
|
-
if (
|
|
4096
|
+
// Check if file changed on disk since last read (e.g. modified by another
|
|
4097
|
+
// process or an external tool). Our own writes update the cache inline
|
|
4098
|
+
// via writeDesktopConnectionConfig, but external changes would be missed.
|
|
4099
|
+
let mtime = null
|
|
4100
|
+
try {
|
|
4101
|
+
mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
|
4102
|
+
} catch {
|
|
4103
|
+
mtime = null
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
if (connectionConfigCache && connectionConfigCacheMtime === mtime) {
|
|
2843
4107
|
return connectionConfigCache
|
|
2844
4108
|
}
|
|
2845
4109
|
|
|
2846
|
-
let config = { mode: 'local', remote: {} }
|
|
4110
|
+
let config = { mode: 'local', remote: {}, profiles: {} }
|
|
2847
4111
|
|
|
2848
4112
|
try {
|
|
2849
4113
|
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
|
|
2850
4114
|
const parsed = JSON.parse(raw)
|
|
2851
4115
|
|
|
2852
4116
|
if (parsed && typeof parsed === 'object') {
|
|
4117
|
+
const remote = parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
|
|
4118
|
+
// authMode lives on the remote sub-object: 'oauth' (cookie + ws-ticket)
|
|
4119
|
+
// or 'token' (legacy static session token). Default to 'token' for
|
|
4120
|
+
// backward compatibility with configs written before OAuth support.
|
|
4121
|
+
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
|
2853
4122
|
config = {
|
|
2854
4123
|
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
|
2855
|
-
remote
|
|
4124
|
+
remote,
|
|
4125
|
+
// Per-profile remote overrides: each profile may point at its own
|
|
4126
|
+
// backend (local spawn or its own remote URL). Preserved verbatim so
|
|
4127
|
+
// profileRemoteOverride() can resolve them; normalized lazily on save.
|
|
4128
|
+
profiles: sanitizeConnectionProfiles(parsed.profiles)
|
|
2856
4129
|
}
|
|
2857
4130
|
}
|
|
2858
4131
|
} catch {
|
|
@@ -2860,86 +4133,190 @@ function readDesktopConnectionConfig() {
|
|
|
2860
4133
|
}
|
|
2861
4134
|
|
|
2862
4135
|
connectionConfigCache = config
|
|
4136
|
+
connectionConfigCacheMtime = mtime
|
|
2863
4137
|
|
|
2864
4138
|
return config
|
|
2865
4139
|
}
|
|
2866
4140
|
|
|
2867
4141
|
function writeDesktopConnectionConfig(config) {
|
|
2868
4142
|
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
|
|
2869
|
-
|
|
4143
|
+
writeFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
|
2870
4144
|
connectionConfigCache = config
|
|
4145
|
+
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
|
2871
4146
|
}
|
|
2872
4147
|
|
|
2873
|
-
|
|
2874
|
-
|
|
4148
|
+
// Returns the desktop's chosen profile name, or null when unset. "default" is
|
|
4149
|
+
// a valid stored value (pins the root HERMES_HOME explicitly); null means "no
|
|
4150
|
+
// preference" and preserves the legacy launch (no --profile flag).
|
|
4151
|
+
function readActiveDesktopProfile() {
|
|
4152
|
+
try {
|
|
4153
|
+
const raw = fs.readFileSync(DESKTOP_PROFILE_CONFIG_PATH, 'utf8')
|
|
4154
|
+
const parsed = JSON.parse(raw)
|
|
4155
|
+
const name = parsed && typeof parsed.profile === 'string' ? parsed.profile.trim() : ''
|
|
2875
4156
|
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
|
4157
|
+
if (name && (name === 'default' || PROFILE_NAME_RE.test(name))) {
|
|
4158
|
+
return name
|
|
4159
|
+
}
|
|
4160
|
+
} catch {
|
|
4161
|
+
// Missing or malformed → no preference.
|
|
2882
4162
|
}
|
|
2883
|
-
}
|
|
2884
4163
|
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
|
2888
|
-
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
|
2889
|
-
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
|
2890
|
-
const existingToken = existing.remote?.token
|
|
2891
|
-
const nextRemote = {
|
|
2892
|
-
url: remoteUrl,
|
|
2893
|
-
token: incomingToken
|
|
2894
|
-
? persistToken
|
|
2895
|
-
? encryptDesktopSecret(incomingToken)
|
|
2896
|
-
: { encoding: 'plain', value: incomingToken }
|
|
2897
|
-
: existingToken
|
|
2898
|
-
}
|
|
4164
|
+
return null
|
|
4165
|
+
}
|
|
2899
4166
|
|
|
2900
|
-
|
|
2901
|
-
|
|
4167
|
+
function writeActiveDesktopProfile(name) {
|
|
4168
|
+
const value = typeof name === 'string' ? name.trim() : ''
|
|
2902
4169
|
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
}
|
|
2906
|
-
} else if (remoteUrl) {
|
|
2907
|
-
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
|
4170
|
+
if (value && value !== 'default' && !PROFILE_NAME_RE.test(value)) {
|
|
4171
|
+
throw new Error(`Invalid profile name: ${value}`)
|
|
2908
4172
|
}
|
|
2909
4173
|
|
|
2910
|
-
|
|
4174
|
+
fs.mkdirSync(path.dirname(DESKTOP_PROFILE_CONFIG_PATH), { recursive: true })
|
|
4175
|
+
writeFileAtomic(DESKTOP_PROFILE_CONFIG_PATH, JSON.stringify({ profile: value || null }, null, 2))
|
|
4176
|
+
|
|
4177
|
+
return value || null
|
|
2911
4178
|
}
|
|
2912
4179
|
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
4180
|
+
// Sanitize a connection config into the renderer-facing shape. With no
|
|
4181
|
+
// `profile` this describes the global/default connection (the existing
|
|
4182
|
+
// behavior); with a `profile` it describes that profile's per-profile remote
|
|
4183
|
+
// override (or an empty "local/inherit" view when the profile has none).
|
|
4184
|
+
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
|
|
4185
|
+
const key = connectionScopeKey(profile)
|
|
4186
|
+
const scoped = key ? config.profiles?.[key] || null : null
|
|
4187
|
+
const block = key ? scoped || {} : config.remote || {}
|
|
2916
4188
|
|
|
2917
|
-
|
|
2918
|
-
if (!rawEnvToken) {
|
|
2919
|
-
throw new Error(
|
|
2920
|
-
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
|
2921
|
-
'Both must be provided to connect to a remote Hermes backend.'
|
|
2922
|
-
)
|
|
2923
|
-
}
|
|
4189
|
+
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
|
2924
4190
|
|
|
2925
|
-
|
|
4191
|
+
const remoteToken = decryptDesktopSecret(block.token)
|
|
4192
|
+
const authMode = normAuthMode(block.authMode)
|
|
4193
|
+
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
|
|
4194
|
+
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
|
2926
4195
|
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
4196
|
+
let remoteOauthConnected = false
|
|
4197
|
+
if (authMode === 'oauth' && remoteUrl) {
|
|
4198
|
+
try {
|
|
4199
|
+
// Display signal: treat a live RT cookie as "connected" even if the AT
|
|
4200
|
+
// cookie has lapsed — the gateway refreshes the AT on the next request,
|
|
4201
|
+
// so the session is still usable. The authoritative liveness check is
|
|
4202
|
+
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
|
|
4203
|
+
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
|
|
4204
|
+
} catch {
|
|
4205
|
+
remoteOauthConnected = false
|
|
2933
4206
|
}
|
|
2934
4207
|
}
|
|
2935
4208
|
|
|
2936
|
-
|
|
4209
|
+
return {
|
|
4210
|
+
mode,
|
|
4211
|
+
// Echo the scope back so the UI knows which profile (if any) this reflects.
|
|
4212
|
+
profile: key,
|
|
4213
|
+
remoteAuthMode: authMode,
|
|
4214
|
+
remoteOauthConnected,
|
|
4215
|
+
remoteUrl,
|
|
4216
|
+
remoteTokenPreview: tokenPreview(remoteToken),
|
|
4217
|
+
remoteTokenSet: Boolean(remoteToken),
|
|
4218
|
+
// The env override only forces the global/primary connection; a per-profile
|
|
4219
|
+
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
|
4220
|
+
envOverride
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
2937
4223
|
|
|
2938
|
-
|
|
2939
|
-
|
|
4224
|
+
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
|
|
4225
|
+
// authenticate via the login-window session cookie (verified at connect time in
|
|
4226
|
+
// resolveRemoteBackend), so only token-auth remotes require a saved token.
|
|
4227
|
+
function buildRemoteBlock(remoteUrl, authMode, token) {
|
|
4228
|
+
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
|
|
4229
|
+
throw new Error('Remote gateway session token is required.')
|
|
2940
4230
|
}
|
|
4231
|
+
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
|
4235
|
+
const persistToken = options.persistToken !== false
|
|
4236
|
+
const key = connectionScopeKey(input.profile)
|
|
4237
|
+
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
|
2941
4238
|
|
|
2942
|
-
|
|
4239
|
+
// The block being edited: a per-profile entry or the global remote block.
|
|
4240
|
+
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
|
4241
|
+
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
|
|
4242
|
+
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
|
|
4243
|
+
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
|
|
4244
|
+
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
|
4245
|
+
const nextToken = incomingToken
|
|
4246
|
+
? persistToken
|
|
4247
|
+
? encryptDesktopSecret(incomingToken)
|
|
4248
|
+
: { encoding: 'plain', value: incomingToken }
|
|
4249
|
+
: existingBlock.token
|
|
4250
|
+
|
|
4251
|
+
if (key) {
|
|
4252
|
+
// Per-profile scope: a remote entry pins this profile to its own backend; a
|
|
4253
|
+
// local entry clears the override so the profile inherits the default.
|
|
4254
|
+
const profiles = { ...(existing.profiles || {}) }
|
|
4255
|
+
if (mode === 'remote') {
|
|
4256
|
+
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
|
|
4257
|
+
} else {
|
|
4258
|
+
delete profiles[key]
|
|
4259
|
+
}
|
|
4260
|
+
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
const nextRemote =
|
|
4264
|
+
mode === 'remote'
|
|
4265
|
+
? buildRemoteBlock(remoteUrl, authMode, nextToken)
|
|
4266
|
+
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
|
4267
|
+
|
|
4268
|
+
// Preserve per-profile overrides when saving the global connection.
|
|
4269
|
+
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
// Build a remote backend connection descriptor from an already-resolved remote
|
|
4273
|
+
// config. Handles both auth models (OAuth ws-ticket vs static session token)
|
|
4274
|
+
// and is shared by the per-profile, env, and global resolution paths. `token`
|
|
4275
|
+
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
|
|
4276
|
+
// for diagnostics ('profile' | 'env' | 'settings').
|
|
4277
|
+
async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
|
4278
|
+
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
|
4279
|
+
|
|
4280
|
+
if (authMode === 'oauth') {
|
|
4281
|
+
// OAuth gateway: auth comes from the session cookies in the OAuth
|
|
4282
|
+
// partition. Liveness is NOT "is the access-token cookie present?" —
|
|
4283
|
+
// Portal issues a 24h rotating refresh token (hermes #37247), and the
|
|
4284
|
+
// gateway middleware transparently rotates a fresh ~15-min access token
|
|
4285
|
+
// from it on the next authenticated request. So a session with an expired
|
|
4286
|
+
// AT cookie but a live RT cookie is still perfectly connectable. We
|
|
4287
|
+
// early-out only when neither cookie is present, then mint a ws-ticket as
|
|
4288
|
+
// the authoritative liveness check.
|
|
4289
|
+
if (!(await hasLiveOauthSession(baseUrl))) {
|
|
4290
|
+
const err = new Error(
|
|
4291
|
+
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
|
4292
|
+
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
|
4293
|
+
)
|
|
4294
|
+
err.needsOauthLogin = true
|
|
4295
|
+
throw err
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
let ticket
|
|
4299
|
+
try {
|
|
4300
|
+
ticket = await mintGatewayWsTicket(baseUrl)
|
|
4301
|
+
} catch (error) {
|
|
4302
|
+
const err = new Error(
|
|
4303
|
+
'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.'
|
|
4304
|
+
)
|
|
4305
|
+
err.needsOauthLogin = true
|
|
4306
|
+
err.cause = error
|
|
4307
|
+
throw err
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
return {
|
|
4311
|
+
baseUrl,
|
|
4312
|
+
mode: 'remote',
|
|
4313
|
+
source,
|
|
4314
|
+
authMode: 'oauth',
|
|
4315
|
+
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
|
4316
|
+
token: null,
|
|
4317
|
+
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
|
4318
|
+
}
|
|
4319
|
+
}
|
|
2943
4320
|
|
|
2944
4321
|
if (!token) {
|
|
2945
4322
|
throw new Error(
|
|
@@ -2948,31 +4325,211 @@ function resolveRemoteBackend() {
|
|
|
2948
4325
|
)
|
|
2949
4326
|
}
|
|
2950
4327
|
|
|
2951
|
-
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
|
2952
|
-
|
|
2953
4328
|
return {
|
|
2954
4329
|
baseUrl,
|
|
2955
4330
|
mode: 'remote',
|
|
2956
|
-
source
|
|
4331
|
+
source,
|
|
4332
|
+
authMode: 'token',
|
|
2957
4333
|
token,
|
|
2958
4334
|
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
|
2959
4335
|
}
|
|
2960
4336
|
}
|
|
2961
4337
|
|
|
4338
|
+
// Resolve the remote backend for a given profile, or null when that profile
|
|
4339
|
+
// should run a LOCAL backend. Precedence:
|
|
4340
|
+
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
|
|
4341
|
+
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
|
|
4342
|
+
// 3. global remote (connection.json `mode: 'remote'`)
|
|
4343
|
+
// A null/empty profile resolves the env/global remote, so legacy callers and
|
|
4344
|
+
// the connection test (which pass no profile) are unchanged.
|
|
4345
|
+
async function resolveRemoteBackend(profile) {
|
|
4346
|
+
const config = readDesktopConnectionConfig()
|
|
4347
|
+
|
|
4348
|
+
// 1. Per-profile override — "a profile with its own remote host". Wins even
|
|
4349
|
+
// over the env override so an explicitly-configured profile always
|
|
4350
|
+
// reaches its intended backend.
|
|
4351
|
+
const override = profileRemoteOverride(config, profile)
|
|
4352
|
+
if (override) {
|
|
4353
|
+
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
|
|
4354
|
+
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
// 2. Env override (global, token-auth only).
|
|
4358
|
+
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
|
4359
|
+
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
|
4360
|
+
if (rawEnvUrl) {
|
|
4361
|
+
if (!rawEnvToken) {
|
|
4362
|
+
throw new Error(
|
|
4363
|
+
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
|
4364
|
+
'Both must be provided to connect to a remote Hermes backend.'
|
|
4365
|
+
)
|
|
4366
|
+
}
|
|
4367
|
+
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
// 3. Global remote.
|
|
4371
|
+
if (config.mode !== 'remote') {
|
|
4372
|
+
return null
|
|
4373
|
+
}
|
|
4374
|
+
const authMode = normAuthMode(config.remote?.authMode)
|
|
4375
|
+
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
|
|
4376
|
+
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
// A remote profile's sessions live on its remote host's state.db, not on a local
|
|
4380
|
+
// file the primary can open — so reads for it must route to the remote backend,
|
|
4381
|
+
// not the local-disk fast path. These three helpers drive that (see
|
|
4382
|
+
// interceptSessionReadForRemote).
|
|
4383
|
+
function profileHasRemoteOverride(profile) {
|
|
4384
|
+
return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
function configuredRemoteProfileNames() {
|
|
4388
|
+
const config = readDesktopConnectionConfig()
|
|
4389
|
+
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
// True when the app is in app-global remote mode (Settings → "All profiles" →
|
|
4393
|
+
// Remote, or the env override): a SINGLE remote backend serves every profile via
|
|
4394
|
+
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
|
|
4395
|
+
function globalRemoteActive() {
|
|
4396
|
+
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
|
4397
|
+
return true
|
|
4398
|
+
}
|
|
4399
|
+
return readDesktopConnectionConfig().mode === 'remote'
|
|
4400
|
+
}
|
|
4401
|
+
|
|
4402
|
+
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
|
|
4403
|
+
async function fetchJsonForProfile(profile, path) {
|
|
4404
|
+
return requestJsonForProfile(profile, path, 'GET')
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
|
|
4408
|
+
async function requestJsonForProfile(profile, path, method, body) {
|
|
4409
|
+
const conn = await ensureBackend(profile)
|
|
4410
|
+
const url = `${conn.baseUrl}${path}`
|
|
4411
|
+
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
|
4412
|
+
return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
async function probeRemoteAuthMode(rawUrl) {
|
|
4416
|
+
// Determine how a remote gateway expects callers to authenticate, WITHOUT
|
|
4417
|
+
// sending any credentials. ``/api/status`` is public on every Hermes
|
|
4418
|
+
// gateway (it backs the portal liveness probe) and reports:
|
|
4419
|
+
// auth_required: true → OAuth gate is engaged (cookie + ws-ticket auth)
|
|
4420
|
+
// auth_required: false → loopback/--insecure: legacy session-token auth
|
|
4421
|
+
// ``/api/auth/providers`` (also public, only meaningful when gated) gives
|
|
4422
|
+
// the human-facing provider name(s) for the login button label.
|
|
4423
|
+
//
|
|
4424
|
+
// The settings UI calls this as the user types a URL so it can render an
|
|
4425
|
+
// OAuth login button vs a session-token entry box. Network/parse failures
|
|
4426
|
+
// surface as ``reachable: false`` rather than throwing, so a half-typed or
|
|
4427
|
+
// unreachable URL degrades to "can't tell yet" instead of a hard error.
|
|
4428
|
+
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
|
4429
|
+
|
|
4430
|
+
let status
|
|
4431
|
+
try {
|
|
4432
|
+
status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
|
|
4433
|
+
} catch (error) {
|
|
4434
|
+
return {
|
|
4435
|
+
baseUrl,
|
|
4436
|
+
reachable: false,
|
|
4437
|
+
authMode: 'unknown',
|
|
4438
|
+
providers: [],
|
|
4439
|
+
version: null,
|
|
4440
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
const authRequired = authModeFromStatus(status) === 'oauth'
|
|
4445
|
+
let providers = []
|
|
4446
|
+
|
|
4447
|
+
if (authRequired) {
|
|
4448
|
+
// Best-effort: a gated gateway exposes the registered providers so the
|
|
4449
|
+
// button can read "Sign in with Nous Research" instead of a generic
|
|
4450
|
+
// label, and so a username/password provider can be distinguished from
|
|
4451
|
+
// an OAuth-redirect one (``supports_password``). A failure here doesn't
|
|
4452
|
+
// change the auth mode, so swallow it.
|
|
4453
|
+
try {
|
|
4454
|
+
const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
|
|
4455
|
+
if (Array.isArray(body?.providers)) {
|
|
4456
|
+
providers = body.providers
|
|
4457
|
+
.filter(p => p && typeof p === 'object')
|
|
4458
|
+
.map(p => ({
|
|
4459
|
+
name: String(p.name || ''),
|
|
4460
|
+
displayName: String(p.display_name || p.name || ''),
|
|
4461
|
+
supportsPassword: Boolean(p.supports_password)
|
|
4462
|
+
}))
|
|
4463
|
+
.filter(p => p.name)
|
|
4464
|
+
}
|
|
4465
|
+
} catch {
|
|
4466
|
+
// Provider listing is optional metadata; the auth mode is already known.
|
|
4467
|
+
}
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
return {
|
|
4471
|
+
baseUrl,
|
|
4472
|
+
reachable: true,
|
|
4473
|
+
authMode: authRequired ? 'oauth' : 'token',
|
|
4474
|
+
providers,
|
|
4475
|
+
version: status?.version || null,
|
|
4476
|
+
error: null
|
|
4477
|
+
}
|
|
4478
|
+
}
|
|
4479
|
+
|
|
2962
4480
|
async function testDesktopConnectionConfig(input = {}) {
|
|
2963
4481
|
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
|
2964
|
-
const
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
4482
|
+
const key = connectionScopeKey(input.profile)
|
|
4483
|
+
// The block under test: a per-profile entry or the global remote. Coerce has
|
|
4484
|
+
// already normalized the URL and resolved token inheritance for the scope.
|
|
4485
|
+
const block = key ? config.profiles?.[key] || null : config.remote
|
|
4486
|
+
const wantRemote =
|
|
4487
|
+
block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
|
4488
|
+
// ``/api/status`` is public on every gateway (no creds needed), so a
|
|
4489
|
+
// reachability test works for local, token, and oauth modes alike — we only
|
|
4490
|
+
// need a base URL. For a remote config we normalize the URL from the input;
|
|
4491
|
+
// for local we fall back to the resolved/started backend.
|
|
4492
|
+
let baseUrl
|
|
4493
|
+
let token = null
|
|
4494
|
+
let authMode = 'token'
|
|
4495
|
+
if (wantRemote && block?.url) {
|
|
4496
|
+
baseUrl = normalizeRemoteBaseUrl(block.url)
|
|
4497
|
+
authMode = normAuthMode(block.authMode)
|
|
4498
|
+
if (authMode !== 'oauth') {
|
|
4499
|
+
token = decryptDesktopSecret(block.token)
|
|
4500
|
+
}
|
|
4501
|
+
} else {
|
|
4502
|
+
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
|
|
4503
|
+
baseUrl = remote.baseUrl
|
|
4504
|
+
token = remote.token
|
|
4505
|
+
authMode = normAuthMode(remote.authMode)
|
|
4506
|
+
}
|
|
4507
|
+
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
|
|
4508
|
+
|
|
4509
|
+
// The HTTP status check above proves the backend is reachable, but the chat
|
|
4510
|
+
// surface only works once the renderer's live WebSocket to ``/api/ws``
|
|
4511
|
+
// connects — a separate transport with separate server-side guards (Host/
|
|
4512
|
+
// Origin, ws-ticket/token auth). Validating only the HTTP side produced a
|
|
4513
|
+
// false-positive "reachable" while the real boot still failed with "Could not
|
|
4514
|
+
// connect to Hermes gateway". Mirror the renderer's connect here so the test
|
|
4515
|
+
// reflects the full path the app actually uses.
|
|
4516
|
+
const wsUrl = await resolveTestWsUrl(baseUrl, authMode, token, { mintTicket: mintGatewayWsTicket })
|
|
4517
|
+
// Skip the WS leg only when the runtime genuinely lacks a WebSocket (so an
|
|
4518
|
+
// older Electron/Node never fails the test spuriously); Electron's main
|
|
4519
|
+
// process ships a global WebSocket on every supported version.
|
|
4520
|
+
if (wsUrl && typeof globalThis.WebSocket === 'function') {
|
|
4521
|
+
const probe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
|
|
4522
|
+
if (!probe.ok) {
|
|
4523
|
+
throw new Error(
|
|
4524
|
+
`Reached the gateway over HTTP, but the live WebSocket (/api/ws) connection failed: ${probe.reason} ` +
|
|
4525
|
+
'The HTTP check can pass while the WebSocket is blocked by a proxy, firewall, or gateway auth/origin guard.'
|
|
4526
|
+
)
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
2972
4529
|
|
|
2973
4530
|
return {
|
|
2974
4531
|
ok: true,
|
|
2975
|
-
baseUrl
|
|
4532
|
+
baseUrl,
|
|
2976
4533
|
version: status?.version || null
|
|
2977
4534
|
}
|
|
2978
4535
|
}
|
|
@@ -3001,6 +4558,314 @@ function resetHermesConnection() {
|
|
|
3001
4558
|
resetBootProgressForReconnect()
|
|
3002
4559
|
}
|
|
3003
4560
|
|
|
4561
|
+
// Re-home the primary backend: reset connection state, then wait for the live
|
|
4562
|
+
// dashboard process to actually exit (SIGKILL after 5s) so the next
|
|
4563
|
+
// startHermes() spawns fresh instead of racing the dying one. Shared by the
|
|
4564
|
+
// connection-config and profile switch flows.
|
|
4565
|
+
async function teardownPrimaryBackendAndWait() {
|
|
4566
|
+
// Capture the reference before resetHermesConnection() nulls hermesProcess.
|
|
4567
|
+
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
|
4568
|
+
resetHermesConnection()
|
|
4569
|
+
|
|
4570
|
+
await waitForBackendExit(dying)
|
|
4571
|
+
}
|
|
4572
|
+
|
|
4573
|
+
async function waitForBackendExit(child, timeoutMs = 5000) {
|
|
4574
|
+
if (!child) {
|
|
4575
|
+
return
|
|
4576
|
+
}
|
|
4577
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
4578
|
+
return
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
await new Promise(resolve => {
|
|
4582
|
+
const timer = setTimeout(() => {
|
|
4583
|
+
try {
|
|
4584
|
+
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
|
4585
|
+
forceKillProcessTree(child.pid)
|
|
4586
|
+
} else {
|
|
4587
|
+
child.kill('SIGKILL')
|
|
4588
|
+
}
|
|
4589
|
+
} catch {
|
|
4590
|
+
// Already gone.
|
|
4591
|
+
}
|
|
4592
|
+
resolve()
|
|
4593
|
+
}, timeoutMs)
|
|
4594
|
+
child.once('exit', () => {
|
|
4595
|
+
clearTimeout(timer)
|
|
4596
|
+
resolve()
|
|
4597
|
+
})
|
|
4598
|
+
})
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4601
|
+
// The profile the primary (window) backend runs as. readActiveDesktopProfile()
|
|
4602
|
+
// returns the desktop's stored preference, or null when unset (legacy launch
|
|
4603
|
+
// that defers to active_profile / default).
|
|
4604
|
+
function primaryProfileKey() {
|
|
4605
|
+
return readActiveDesktopProfile() || 'default'
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
// Resolve a backend connection for the given profile. Routes the primary
|
|
4609
|
+
// profile to startHermes() (the window backend: boot UI, bootstrap, remote
|
|
4610
|
+
// mode), and any OTHER profile to a lazily-spawned pool backend. An empty /
|
|
4611
|
+
// unknown profile resolves to the primary, so all legacy callers are unchanged.
|
|
4612
|
+
async function ensureBackend(profile) {
|
|
4613
|
+
const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey()
|
|
4614
|
+
|
|
4615
|
+
if (key === primaryProfileKey()) {
|
|
4616
|
+
return startHermes()
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
const existing = backendPool.get(key)
|
|
4620
|
+
if (existing) {
|
|
4621
|
+
existing.lastActiveAt = Date.now()
|
|
4622
|
+
return existing.connectionPromise
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
|
|
4626
|
+
|
|
4627
|
+
const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() }
|
|
4628
|
+
entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => {
|
|
4629
|
+
backendPool.delete(key)
|
|
4630
|
+
throw error
|
|
4631
|
+
})
|
|
4632
|
+
backendPool.set(key, entry)
|
|
4633
|
+
startPoolIdleReaper()
|
|
4634
|
+
return entry.connectionPromise
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
// Mark a pool profile as recently used so the idle reaper spares it. The
|
|
4638
|
+
// renderer calls this when it opens a profile's chat WS and periodically while
|
|
4639
|
+
// streaming, since the main process can't see the direct renderer↔backend WS.
|
|
4640
|
+
function touchPoolBackend(profile) {
|
|
4641
|
+
const key = profile && String(profile).trim() ? String(profile).trim() : null
|
|
4642
|
+
if (!key) return
|
|
4643
|
+
const entry = backendPool.get(key)
|
|
4644
|
+
if (entry) entry.lastActiveAt = Date.now()
|
|
4645
|
+
}
|
|
4646
|
+
|
|
4647
|
+
// Evict least-recently-used pool backends until at most `keep` remain — but only
|
|
4648
|
+
// ever evict backends without a live renderer socket (stale beyond the keepalive
|
|
4649
|
+
// window). When every backend is actively kept alive we let the pool exceed the
|
|
4650
|
+
// soft cap rather than kill a running session.
|
|
4651
|
+
function evictLruPoolBackends(keep) {
|
|
4652
|
+
if (backendPool.size <= keep) return
|
|
4653
|
+
const now = Date.now()
|
|
4654
|
+
const evictable = [...backendPool.entries()]
|
|
4655
|
+
.filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS)
|
|
4656
|
+
.sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0))
|
|
4657
|
+
let removable = backendPool.size - Math.max(0, keep)
|
|
4658
|
+
for (const [profile] of evictable) {
|
|
4659
|
+
if (removable <= 0) break
|
|
4660
|
+
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
|
|
4661
|
+
stopPoolBackend(profile)
|
|
4662
|
+
removable -= 1
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
function startPoolIdleReaper() {
|
|
4667
|
+
if (poolIdleReaper) return
|
|
4668
|
+
poolIdleReaper = setInterval(() => {
|
|
4669
|
+
const now = Date.now()
|
|
4670
|
+
for (const [profile, entry] of [...backendPool.entries()]) {
|
|
4671
|
+
if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) {
|
|
4672
|
+
rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`)
|
|
4673
|
+
stopPoolBackend(profile)
|
|
4674
|
+
}
|
|
4675
|
+
}
|
|
4676
|
+
if (backendPool.size === 0 && poolIdleReaper) {
|
|
4677
|
+
clearInterval(poolIdleReaper)
|
|
4678
|
+
poolIdleReaper = null
|
|
4679
|
+
}
|
|
4680
|
+
}, 60_000)
|
|
4681
|
+
if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref()
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
// Spawn an additional dashboard backend pinned to a named profile. Mirrors the
|
|
4685
|
+
// local-spawn portion of startHermes() but without the boot-progress UI,
|
|
4686
|
+
// bootstrap, or remote handling (those belong to the primary backend only).
|
|
4687
|
+
async function spawnPoolBackend(profile, entry) {
|
|
4688
|
+
// A profile may point at its OWN remote backend (connection.json
|
|
4689
|
+
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
|
|
4690
|
+
// In either case there is no local child to spawn — we just verify the
|
|
4691
|
+
// remote is reachable and hand back its connection descriptor. The pool
|
|
4692
|
+
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
|
|
4693
|
+
// tolerate.
|
|
4694
|
+
const remote = await resolveRemoteBackend(profile)
|
|
4695
|
+
if (remote) {
|
|
4696
|
+
await waitForHermes(remote.baseUrl, remote.token)
|
|
4697
|
+
return {
|
|
4698
|
+
...remote,
|
|
4699
|
+
profile,
|
|
4700
|
+
logs: hermesLog.slice(-80),
|
|
4701
|
+
...getWindowState()
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4705
|
+
const token = crypto.randomBytes(32).toString('base64url')
|
|
4706
|
+
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
|
4707
|
+
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
|
4708
|
+
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
|
4709
|
+
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
|
4710
|
+
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
|
4711
|
+
const hermesCwd = resolveHermesCwd()
|
|
4712
|
+
const webDist = resolveWebDist()
|
|
4713
|
+
|
|
4714
|
+
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
|
4715
|
+
|
|
4716
|
+
const child = spawn(
|
|
4717
|
+
backend.command,
|
|
4718
|
+
backend.args,
|
|
4719
|
+
hiddenWindowsChildOptions({
|
|
4720
|
+
cwd: hermesCwd,
|
|
4721
|
+
env: {
|
|
4722
|
+
...process.env,
|
|
4723
|
+
HERMES_HOME,
|
|
4724
|
+
...backend.env,
|
|
4725
|
+
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
|
4726
|
+
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
|
4727
|
+
// can still point at the install dir even when spawn cwd is home.
|
|
4728
|
+
TERMINAL_CWD: hermesCwd,
|
|
4729
|
+
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
|
4730
|
+
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
|
4731
|
+
// scheduler tick loop (the gateway isn't running under the app).
|
|
4732
|
+
HERMES_DESKTOP: '1',
|
|
4733
|
+
HERMES_WEB_DIST: webDist
|
|
4734
|
+
},
|
|
4735
|
+
shell: backend.shell,
|
|
4736
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
4737
|
+
})
|
|
4738
|
+
)
|
|
4739
|
+
entry.process = child
|
|
4740
|
+
entry.token = token
|
|
4741
|
+
|
|
4742
|
+
child.stdout.on('data', rememberLog)
|
|
4743
|
+
child.stderr.on('data', rememberLog)
|
|
4744
|
+
|
|
4745
|
+
let ready = false
|
|
4746
|
+
let rejectStart = null
|
|
4747
|
+
const startFailed = new Promise((_resolve, reject) => {
|
|
4748
|
+
rejectStart = reject
|
|
4749
|
+
})
|
|
4750
|
+
child.once('error', error => {
|
|
4751
|
+
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
|
4752
|
+
backendPool.delete(profile)
|
|
4753
|
+
rejectStart?.(error)
|
|
4754
|
+
})
|
|
4755
|
+
child.once('exit', (code, signal) => {
|
|
4756
|
+
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
|
4757
|
+
backendPool.delete(profile)
|
|
4758
|
+
if (!ready) {
|
|
4759
|
+
rejectStart?.(
|
|
4760
|
+
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
|
|
4761
|
+
)
|
|
4762
|
+
}
|
|
4763
|
+
})
|
|
4764
|
+
|
|
4765
|
+
// Discover the ephemeral port the child bound to
|
|
4766
|
+
const port = await Promise.race([waitForDashboardPort(child), startFailed])
|
|
4767
|
+
entry.port = port
|
|
4768
|
+
|
|
4769
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
4770
|
+
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
|
4771
|
+
ready = true
|
|
4772
|
+
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
|
4773
|
+
childAlive: () => child.exitCode === null && !child.killed,
|
|
4774
|
+
label: `Hermes backend for profile "${profile}"`,
|
|
4775
|
+
rememberLog
|
|
4776
|
+
})
|
|
4777
|
+
entry.token = authToken
|
|
4778
|
+
|
|
4779
|
+
return {
|
|
4780
|
+
baseUrl,
|
|
4781
|
+
mode: 'local',
|
|
4782
|
+
source: 'local',
|
|
4783
|
+
authMode: 'token',
|
|
4784
|
+
token: authToken,
|
|
4785
|
+
profile,
|
|
4786
|
+
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
|
4787
|
+
logs: hermesLog.slice(-80),
|
|
4788
|
+
...getWindowState()
|
|
4789
|
+
}
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
function stopPoolBackend(profile) {
|
|
4793
|
+
const entry = backendPool.get(profile)
|
|
4794
|
+
if (!entry) return
|
|
4795
|
+
backendPool.delete(profile)
|
|
4796
|
+
if (entry.process && !entry.process.killed) {
|
|
4797
|
+
try {
|
|
4798
|
+
entry.process.kill('SIGTERM')
|
|
4799
|
+
} catch {
|
|
4800
|
+
// Already gone.
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
|
|
4805
|
+
async function teardownPoolBackendAndWait(profile) {
|
|
4806
|
+
const entry = backendPool.get(profile)
|
|
4807
|
+
if (!entry) return
|
|
4808
|
+
backendPool.delete(profile)
|
|
4809
|
+
|
|
4810
|
+
if (entry.process && !entry.process.killed) {
|
|
4811
|
+
try {
|
|
4812
|
+
entry.process.kill('SIGTERM')
|
|
4813
|
+
} catch {
|
|
4814
|
+
// Already gone.
|
|
4815
|
+
}
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
await waitForBackendExit(entry.process)
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
function stopAllPoolBackends() {
|
|
4822
|
+
for (const profile of [...backendPool.keys()]) {
|
|
4823
|
+
stopPoolBackend(profile)
|
|
4824
|
+
}
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
function profileNameFromDeleteRequest(request) {
|
|
4828
|
+
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
|
|
4829
|
+
return null
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
|
|
4833
|
+
if (!match) {
|
|
4834
|
+
return null
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4837
|
+
let raw = ''
|
|
4838
|
+
try {
|
|
4839
|
+
raw = decodeURIComponent(match[1])
|
|
4840
|
+
} catch {
|
|
4841
|
+
return null
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
const name = raw.trim()
|
|
4845
|
+
if (!name) {
|
|
4846
|
+
return null
|
|
4847
|
+
}
|
|
4848
|
+
if (name.toLowerCase() === 'default') {
|
|
4849
|
+
return 'default'
|
|
4850
|
+
}
|
|
4851
|
+
return name.toLowerCase()
|
|
4852
|
+
}
|
|
4853
|
+
|
|
4854
|
+
async function prepareProfileDeleteRequest(request) {
|
|
4855
|
+
const profile = profileNameFromDeleteRequest(request)
|
|
4856
|
+
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
|
|
4857
|
+
return
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
if (profile === primaryProfileKey()) {
|
|
4861
|
+
writeActiveDesktopProfile('default')
|
|
4862
|
+
await teardownPrimaryBackendAndWait()
|
|
4863
|
+
return
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
await teardownPoolBackendAndWait(profile)
|
|
4867
|
+
}
|
|
4868
|
+
|
|
3004
4869
|
async function startHermes() {
|
|
3005
4870
|
// Latched-failure short-circuit: once bootstrap has failed in this
|
|
3006
4871
|
// process, every subsequent startHermes() call re-throws the same error
|
|
@@ -3015,7 +4880,9 @@ async function startHermes() {
|
|
|
3015
4880
|
|
|
3016
4881
|
connectionPromise = (async () => {
|
|
3017
4882
|
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
|
3018
|
-
|
|
4883
|
+
// Resolve for the desktop's primary profile so a per-profile remote
|
|
4884
|
+
// override on the active profile is honored (falls back to env / global).
|
|
4885
|
+
const remote = await resolveRemoteBackend(primaryProfileKey())
|
|
3019
4886
|
if (remote) {
|
|
3020
4887
|
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
|
3021
4888
|
await waitForHermes(remote.baseUrl, remote.token)
|
|
@@ -3030,6 +4897,7 @@ async function startHermes() {
|
|
|
3030
4897
|
baseUrl: remote.baseUrl,
|
|
3031
4898
|
mode: 'remote',
|
|
3032
4899
|
source: remote.source,
|
|
4900
|
+
authMode: remote.authMode || 'token',
|
|
3033
4901
|
token: remote.token,
|
|
3034
4902
|
wsUrl: remote.wsUrl,
|
|
3035
4903
|
logs: hermesLog.slice(-80),
|
|
@@ -3037,10 +4905,18 @@ async function startHermes() {
|
|
|
3037
4905
|
}
|
|
3038
4906
|
}
|
|
3039
4907
|
|
|
3040
|
-
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
|
3041
|
-
const port = await pickPort()
|
|
3042
4908
|
const token = crypto.randomBytes(32).toString('base64url')
|
|
3043
|
-
|
|
4909
|
+
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
|
4910
|
+
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
|
4911
|
+
// Pin the desktop's chosen profile via the global --profile flag. This is
|
|
4912
|
+
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
|
4913
|
+
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
|
4914
|
+
// unset preference keeps the legacy launch so existing installs are
|
|
4915
|
+
// unaffected.
|
|
4916
|
+
const activeProfile = readActiveDesktopProfile()
|
|
4917
|
+
if (activeProfile) {
|
|
4918
|
+
dashboardArgs.unshift('--profile', activeProfile)
|
|
4919
|
+
}
|
|
3044
4920
|
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
|
|
3045
4921
|
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
|
3046
4922
|
const hermesCwd = resolveHermesCwd()
|
|
@@ -3049,27 +4925,34 @@ async function startHermes() {
|
|
|
3049
4925
|
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
|
3050
4926
|
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
|
3051
4927
|
|
|
3052
|
-
hermesProcess = spawn(
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
4928
|
+
hermesProcess = spawn(
|
|
4929
|
+
backend.command,
|
|
4930
|
+
backend.args,
|
|
4931
|
+
hiddenWindowsChildOptions({
|
|
4932
|
+
cwd: hermesCwd,
|
|
4933
|
+
env: {
|
|
4934
|
+
...process.env,
|
|
4935
|
+
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
|
4936
|
+
// resolves to the SAME location our resolveHermesHome() picked. Without
|
|
4937
|
+
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
|
4938
|
+
// mac/linux (where our default matches), but on Windows our default is
|
|
4939
|
+
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
|
4940
|
+
// Mismatch would split config / sessions / .env / logs across two
|
|
4941
|
+
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
|
4942
|
+
// can't reliably do that, so we set it inline for every spawn.
|
|
4943
|
+
HERMES_HOME,
|
|
4944
|
+
...backend.env,
|
|
4945
|
+
TERMINAL_CWD: hermesCwd,
|
|
4946
|
+
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
|
4947
|
+
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
|
4948
|
+
// scheduler tick loop (the gateway isn't running under the app).
|
|
4949
|
+
HERMES_DESKTOP: '1',
|
|
4950
|
+
HERMES_WEB_DIST: webDist
|
|
4951
|
+
},
|
|
4952
|
+
shell: backend.shell,
|
|
4953
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
4954
|
+
})
|
|
4955
|
+
)
|
|
3073
4956
|
|
|
3074
4957
|
hermesProcess.stdout.on('data', rememberLog)
|
|
3075
4958
|
hermesProcess.stderr.on('data', rememberLog)
|
|
@@ -3118,10 +5001,19 @@ async function startHermes() {
|
|
|
3118
5001
|
}
|
|
3119
5002
|
})
|
|
3120
5003
|
|
|
5004
|
+
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
|
|
5005
|
+
// Discover the ephemeral port the child bound to
|
|
5006
|
+
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
|
|
5007
|
+
|
|
3121
5008
|
const baseUrl = `http://127.0.0.1:${port}`
|
|
3122
5009
|
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
|
3123
5010
|
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
|
3124
5011
|
backendReady = true
|
|
5012
|
+
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
|
5013
|
+
// The exit/error handlers null hermesProcess when the child dies.
|
|
5014
|
+
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
|
5015
|
+
rememberLog
|
|
5016
|
+
})
|
|
3125
5017
|
updateBootProgress({
|
|
3126
5018
|
phase: 'backend.ready',
|
|
3127
5019
|
message: 'Hermes backend is ready. Finalizing desktop startup',
|
|
@@ -3134,8 +5026,9 @@ async function startHermes() {
|
|
|
3134
5026
|
baseUrl,
|
|
3135
5027
|
mode: 'local',
|
|
3136
5028
|
source: 'local',
|
|
3137
|
-
token,
|
|
3138
|
-
|
|
5029
|
+
authMode: 'token',
|
|
5030
|
+
token: authToken,
|
|
5031
|
+
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
|
3139
5032
|
logs: hermesLog.slice(-80),
|
|
3140
5033
|
...getWindowState()
|
|
3141
5034
|
}
|
|
@@ -3157,12 +5050,116 @@ async function startHermes() {
|
|
|
3157
5050
|
return connectionPromise
|
|
3158
5051
|
}
|
|
3159
5052
|
|
|
5053
|
+
// Shared navigation guards + window chrome wiring applied to every window
|
|
5054
|
+
// (the primary plus any secondary session windows). Factored out of
|
|
5055
|
+
// createWindow() so secondary windows can't drift from the main window's
|
|
5056
|
+
// security posture: external links open in the OS browser, in-app navigation
|
|
5057
|
+
// stays confined to the dev server / packaged file URL, and the preview /
|
|
5058
|
+
// devtools / zoom / context-menu affordances behave identically everywhere.
|
|
5059
|
+
function wireCommonWindowHandlers(win) {
|
|
5060
|
+
installPreviewShortcut(win)
|
|
5061
|
+
installDevToolsShortcut(win)
|
|
5062
|
+
installZoomShortcuts(win)
|
|
5063
|
+
installContextMenu(win)
|
|
5064
|
+
win.webContents.setWindowOpenHandler(details => {
|
|
5065
|
+
openExternalUrl(details.url)
|
|
5066
|
+
|
|
5067
|
+
return { action: 'deny' }
|
|
5068
|
+
})
|
|
5069
|
+
win.webContents.on('will-navigate', (event, url) => {
|
|
5070
|
+
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
|
|
5071
|
+
return
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
event.preventDefault()
|
|
5075
|
+
openExternalUrl(url)
|
|
5076
|
+
})
|
|
5077
|
+
}
|
|
5078
|
+
|
|
5079
|
+
// Secondary "session windows" — one extra OS window per chat so a user can
|
|
5080
|
+
// work with multiple chats side by side. The registry guarantees one window
|
|
5081
|
+
// per sessionId (re-opening focuses the existing window) and self-cleans on
|
|
5082
|
+
// close. The primary mainWindow is never tracked here. Pure logic + the URL
|
|
5083
|
+
// builder live in session-windows.cjs so they stay unit-testable.
|
|
5084
|
+
const sessionWindows = createSessionWindowRegistry()
|
|
5085
|
+
|
|
5086
|
+
function focusWindow(win) {
|
|
5087
|
+
if (!win || win.isDestroyed()) return
|
|
5088
|
+
if (win.isMinimized()) win.restore()
|
|
5089
|
+
if (!win.isVisible()) win.show()
|
|
5090
|
+
win.focus()
|
|
5091
|
+
}
|
|
5092
|
+
|
|
5093
|
+
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
|
|
5094
|
+
const icon = getAppIconPath()
|
|
5095
|
+
const win = new BrowserWindow({
|
|
5096
|
+
width: SESSION_WINDOW_MIN_WIDTH,
|
|
5097
|
+
height: SESSION_WINDOW_MIN_HEIGHT,
|
|
5098
|
+
minWidth: SESSION_WINDOW_MIN_WIDTH,
|
|
5099
|
+
minHeight: SESSION_WINDOW_MIN_HEIGHT,
|
|
5100
|
+
title: 'Hermes',
|
|
5101
|
+
titleBarStyle: 'hidden',
|
|
5102
|
+
titleBarOverlay: getTitleBarOverlayOptions(),
|
|
5103
|
+
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
|
5104
|
+
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
|
5105
|
+
opacity: windowOpacity(),
|
|
5106
|
+
icon,
|
|
5107
|
+
// Don't show until the renderer's first themed paint is ready. macOS
|
|
5108
|
+
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
|
|
5109
|
+
// material (which follows the OS appearance, not the app theme), so a
|
|
5110
|
+
// dark-themed app on a light-mode Mac flashes white until the renderer
|
|
5111
|
+
// covers it. ready-to-show fires after the boot-time paint in
|
|
5112
|
+
// themes/context.tsx, so the window appears already themed.
|
|
5113
|
+
show: false,
|
|
5114
|
+
backgroundColor: getWindowBackgroundColor(),
|
|
5115
|
+
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
|
5116
|
+
})
|
|
5117
|
+
|
|
5118
|
+
if (IS_MAC) {
|
|
5119
|
+
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
|
5120
|
+
}
|
|
5121
|
+
|
|
5122
|
+
win.once('ready-to-show', () => {
|
|
5123
|
+
if (!win.isDestroyed()) win.show()
|
|
5124
|
+
})
|
|
5125
|
+
|
|
5126
|
+
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
|
5127
|
+
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
|
5128
|
+
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
|
5129
|
+
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
|
5130
|
+
|
|
5131
|
+
wireCommonWindowHandlers(win)
|
|
5132
|
+
|
|
5133
|
+
win.loadURL(
|
|
5134
|
+
buildSessionWindowUrl(sessionId, {
|
|
5135
|
+
devServer: DEV_SERVER,
|
|
5136
|
+
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
|
5137
|
+
watch,
|
|
5138
|
+
newSession
|
|
5139
|
+
})
|
|
5140
|
+
)
|
|
5141
|
+
|
|
5142
|
+
return win
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
// Open (or focus) a standalone window for a single chat session.
|
|
5146
|
+
function createSessionWindow(sessionId, { watch = false } = {}) {
|
|
5147
|
+
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
|
|
5151
|
+
// like ⌘N in a browser, every press opens a new window — and a draft window that
|
|
5152
|
+
// later converts to a real session must not get refocused as if it were blank.
|
|
5153
|
+
function createNewSessionWindow() {
|
|
5154
|
+
return spawnSecondaryWindow({ newSession: true })
|
|
5155
|
+
}
|
|
5156
|
+
|
|
3160
5157
|
function createWindow() {
|
|
3161
5158
|
const icon = getAppIconPath()
|
|
3162
5159
|
mainWindow = new BrowserWindow({
|
|
3163
5160
|
width: 1220,
|
|
3164
5161
|
height: 800,
|
|
3165
|
-
minWidth:
|
|
5162
|
+
minWidth: 400,
|
|
3166
5163
|
minHeight: 620,
|
|
3167
5164
|
title: 'Hermes',
|
|
3168
5165
|
// Frameless title bar on every platform so the renderer can paint the
|
|
@@ -3175,16 +5172,18 @@ function createWindow() {
|
|
|
3175
5172
|
titleBarOverlay: getTitleBarOverlayOptions(),
|
|
3176
5173
|
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
|
3177
5174
|
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
|
5175
|
+
opacity: windowOpacity(),
|
|
3178
5176
|
icon,
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
5177
|
+
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
|
|
5178
|
+
// `backgroundColor` and follows the OS appearance) can't flash a light
|
|
5179
|
+
// material before the renderer paints the app theme. See createSessionWindow.
|
|
5180
|
+
show: false,
|
|
5181
|
+
backgroundColor: getWindowBackgroundColor(),
|
|
5182
|
+
// Shared with the secondary session windows (chatWindowWebPreferences) so
|
|
5183
|
+
// both keep `backgroundThrottling: false` — the chat transcript streams via
|
|
5184
|
+
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
|
|
5185
|
+
// windows, stalling the live answer until refocus. See session-windows.cjs.
|
|
5186
|
+
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
|
3188
5187
|
})
|
|
3189
5188
|
|
|
3190
5189
|
if (IS_MAC) {
|
|
@@ -3195,31 +5194,68 @@ function createWindow() {
|
|
|
3195
5194
|
}
|
|
3196
5195
|
|
|
3197
5196
|
if (!IS_MAC) {
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
5197
|
+
if (!nativeThemeListenerInstalled) {
|
|
5198
|
+
nativeThemeListenerInstalled = true
|
|
5199
|
+
nativeTheme.on('updated', () => {
|
|
5200
|
+
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
|
5201
|
+
})
|
|
5202
|
+
}
|
|
3201
5203
|
}
|
|
3202
5204
|
|
|
5205
|
+
mainWindow.once('ready-to-show', () => {
|
|
5206
|
+
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
|
|
5207
|
+
})
|
|
5208
|
+
|
|
3203
5209
|
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
|
3204
5210
|
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
|
3205
5211
|
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
|
3206
5212
|
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
|
3207
5213
|
|
|
3208
|
-
|
|
3209
|
-
installDevToolsShortcut(mainWindow)
|
|
3210
|
-
installContextMenu(mainWindow)
|
|
3211
|
-
mainWindow.webContents.setWindowOpenHandler(details => {
|
|
3212
|
-
openExternalUrl(details.url)
|
|
5214
|
+
wireCommonWindowHandlers(mainWindow)
|
|
3213
5215
|
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
if (
|
|
3218
|
-
|
|
5216
|
+
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
|
5217
|
+
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
|
5218
|
+
|
|
5219
|
+
if (details?.reason === 'crashed' || details?.reason === 'oom') {
|
|
5220
|
+
const now = Date.now()
|
|
5221
|
+
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)
|
|
5222
|
+
|
|
5223
|
+
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
|
5224
|
+
rememberLog(
|
|
5225
|
+
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
|
|
5226
|
+
)
|
|
5227
|
+
|
|
5228
|
+
return
|
|
5229
|
+
}
|
|
5230
|
+
|
|
5231
|
+
rendererReloadTimes.push(now)
|
|
5232
|
+
setImmediate(() => {
|
|
5233
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
5234
|
+
try {
|
|
5235
|
+
mainWindow.webContents.reload()
|
|
5236
|
+
} catch (err) {
|
|
5237
|
+
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
|
|
5238
|
+
}
|
|
5239
|
+
})
|
|
3219
5240
|
}
|
|
5241
|
+
})
|
|
3220
5242
|
|
|
3221
|
-
|
|
3222
|
-
|
|
5243
|
+
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
|
|
5244
|
+
|
|
5245
|
+
// Electron always passes the event first. The canonical (Electron 36+) shape
|
|
5246
|
+
// is (event, messageDetails); the deprecated positional shape is
|
|
5247
|
+
// (event, level, message, line, sourceId). Handle both. `level` is numeric
|
|
5248
|
+
// (0..3), where 3 === error.
|
|
5249
|
+
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
|
|
5250
|
+
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
|
|
5251
|
+
const level = details ? details.level : detailsOrLevel
|
|
5252
|
+
|
|
5253
|
+
if (level !== 3) return
|
|
5254
|
+
|
|
5255
|
+
const text = details ? details.message : message
|
|
5256
|
+
const src = details ? details.sourceUrl : sourceId
|
|
5257
|
+
const lineNo = details ? details.lineNumber : line
|
|
5258
|
+
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
|
|
3223
5259
|
})
|
|
3224
5260
|
|
|
3225
5261
|
if (DEV_SERVER) {
|
|
@@ -3229,20 +5265,79 @@ function createWindow() {
|
|
|
3229
5265
|
}
|
|
3230
5266
|
|
|
3231
5267
|
mainWindow.webContents.once('did-finish-load', () => {
|
|
5268
|
+
restorePersistedZoomLevel(mainWindow)
|
|
3232
5269
|
broadcastBootProgress()
|
|
3233
5270
|
sendWindowStateChanged()
|
|
3234
5271
|
startHermes().catch(error => rememberLog(error.stack || error.message))
|
|
3235
5272
|
})
|
|
3236
5273
|
}
|
|
3237
5274
|
|
|
3238
|
-
ipcMain.handle('hermes:connection', async () =>
|
|
5275
|
+
ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile))
|
|
5276
|
+
// Reconnect-after-wake recovery. A REMOTE primary backend has no child process,
|
|
5277
|
+
// so the 'exit'/'error' handlers that would clear a dead connectionPromise never
|
|
5278
|
+
// fire — once the remote becomes unreachable across a sleep/wake the renderer
|
|
5279
|
+
// re-dials the same dead descriptor forever and the composer stays stuck on
|
|
5280
|
+
// "Starting Hermes…". Before the renderer's backoff loop reconnects, it asks us
|
|
5281
|
+
// to confirm the cached PRIMARY backend is still reachable; if a remote one is
|
|
5282
|
+
// not, we drop the cache so the next getConnection() rebuilds it. Local backends
|
|
5283
|
+
// self-heal via their child 'exit' handler, so we never touch them here.
|
|
5284
|
+
ipcMain.handle('hermes:connection:revalidate', async () => {
|
|
5285
|
+
if (!connectionPromise) {
|
|
5286
|
+
return { ok: true, rebuilt: false }
|
|
5287
|
+
}
|
|
5288
|
+
|
|
5289
|
+
let conn = null
|
|
5290
|
+
try {
|
|
5291
|
+
conn = await connectionPromise
|
|
5292
|
+
} catch {
|
|
5293
|
+
// The cached boot already rejected (its own catch nulls connectionPromise);
|
|
5294
|
+
// nothing to revalidate — the next getConnection() builds fresh.
|
|
5295
|
+
return { ok: true, rebuilt: false }
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
if (!conn || conn.mode !== 'remote' || !conn.baseUrl) {
|
|
5299
|
+
return { ok: true, rebuilt: false }
|
|
5300
|
+
}
|
|
5301
|
+
|
|
5302
|
+
const base = conn.baseUrl.replace(/\/+$/, '')
|
|
5303
|
+
try {
|
|
5304
|
+
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 })
|
|
5305
|
+
return { ok: true, rebuilt: false }
|
|
5306
|
+
} catch {
|
|
5307
|
+
// Unreachable remote: drop the stale cache so the renderer's next reconnect
|
|
5308
|
+
// tick rebuilds a fresh, reachable descriptor. resetHermesConnection only
|
|
5309
|
+
// nulls connectionPromise for a remote (no child to SIGTERM).
|
|
5310
|
+
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
|
|
5311
|
+
resetHermesConnection()
|
|
5312
|
+
return { ok: true, rebuilt: true }
|
|
5313
|
+
}
|
|
5314
|
+
})
|
|
5315
|
+
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
|
5316
|
+
touchPoolBackend(profile)
|
|
5317
|
+
return { ok: true }
|
|
5318
|
+
})
|
|
5319
|
+
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
|
5320
|
+
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
|
5321
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
|
5322
|
+
return { ok: false, error: 'invalid-session-id' }
|
|
5323
|
+
}
|
|
5324
|
+
|
|
5325
|
+
createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
|
|
5326
|
+
|
|
5327
|
+
return { ok: true }
|
|
5328
|
+
})
|
|
5329
|
+
ipcMain.handle('hermes:window:openNewSession', async () => {
|
|
5330
|
+
createNewSessionWindow()
|
|
5331
|
+
|
|
5332
|
+
return { ok: true }
|
|
5333
|
+
})
|
|
3239
5334
|
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
|
3240
5335
|
// Renderer's "Reload and retry" path. Clear the latched failure and
|
|
3241
5336
|
// reset connection state so the next startHermes() call restarts the
|
|
3242
5337
|
// full backend flow (including a fresh runBootstrap pass).
|
|
3243
5338
|
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
|
5339
|
+
await teardownPrimaryBackendAndWait()
|
|
3244
5340
|
bootstrapFailure = null
|
|
3245
|
-
connectionPromise = null
|
|
3246
5341
|
bootstrapState = {
|
|
3247
5342
|
active: false,
|
|
3248
5343
|
manifest: null,
|
|
@@ -3279,28 +5374,75 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
|
|
3279
5374
|
if (bootstrapAbortController) {
|
|
3280
5375
|
try {
|
|
3281
5376
|
bootstrapAbortController.abort()
|
|
3282
|
-
} catch {
|
|
5377
|
+
} catch {
|
|
5378
|
+
void 0
|
|
5379
|
+
}
|
|
3283
5380
|
return { ok: true, cancelled: true }
|
|
3284
5381
|
}
|
|
3285
5382
|
return { ok: false, cancelled: false }
|
|
3286
5383
|
})
|
|
3287
5384
|
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
|
3288
5385
|
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
|
3289
|
-
ipcMain.handle('hermes:connection-config:get', async () =>
|
|
5386
|
+
ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
|
5387
|
+
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
|
|
5388
|
+
)
|
|
3290
5389
|
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
|
5390
|
+
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
|
5391
|
+
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
|
5392
|
+
// Open the gateway's OAuth login window and wait for the session cookie to
|
|
5393
|
+
// land in the OAuth partition. The caller (settings UI) typically saves the
|
|
5394
|
+
// remote config with authMode='oauth' first, then calls this. We normalize
|
|
5395
|
+
// the URL defensively so a login can be driven from a raw URL too.
|
|
5396
|
+
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
|
5397
|
+
await openOauthLoginWindow(baseUrl)
|
|
5398
|
+
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
|
|
5399
|
+
})
|
|
5400
|
+
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
|
5401
|
+
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
|
5402
|
+
await clearOauthSession(baseUrl || undefined)
|
|
5403
|
+
// Report against the SAME liveness notion the Settings indicator uses
|
|
5404
|
+
// (AT-or-RT) so a logout that left any session cookie behind is reflected
|
|
5405
|
+
// as still-connected rather than silently signed-out.
|
|
5406
|
+
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
|
|
5407
|
+
})
|
|
3291
5408
|
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
|
3292
5409
|
const config = coerceDesktopConnectionConfig(payload)
|
|
3293
5410
|
writeDesktopConnectionConfig(config)
|
|
3294
5411
|
|
|
3295
|
-
return sanitizeDesktopConnectionConfig(config)
|
|
3296
|
-
})
|
|
3297
|
-
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
|
3298
|
-
const config = coerceDesktopConnectionConfig(payload)
|
|
3299
|
-
writeDesktopConnectionConfig(config)
|
|
3300
|
-
|
|
3301
|
-
|
|
5412
|
+
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
|
5413
|
+
})
|
|
5414
|
+
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
|
5415
|
+
const config = coerceDesktopConnectionConfig(payload)
|
|
5416
|
+
writeDesktopConnectionConfig(config)
|
|
5417
|
+
|
|
5418
|
+
const key = connectionScopeKey(payload?.profile)
|
|
5419
|
+
|
|
5420
|
+
if (key && key !== primaryProfileKey()) {
|
|
5421
|
+
// Editing a NON-primary profile's connection: don't disturb the window's
|
|
5422
|
+
// primary backend. Drop the profile's pooled backend so the next switch
|
|
5423
|
+
// re-resolves against the new remote/local target.
|
|
5424
|
+
stopPoolBackend(key)
|
|
5425
|
+
} else {
|
|
5426
|
+
// Global connection, or the primary profile's connection: re-home the
|
|
5427
|
+
// window backend by tearing it down and reloading the renderer.
|
|
5428
|
+
await teardownPrimaryBackendAndWait()
|
|
5429
|
+
mainWindow?.reload()
|
|
5430
|
+
}
|
|
5431
|
+
|
|
5432
|
+
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
|
5433
|
+
})
|
|
5434
|
+
|
|
5435
|
+
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
|
|
5436
|
+
ipcMain.handle('hermes:profile:set', async (_event, name) => {
|
|
5437
|
+
const next = writeActiveDesktopProfile(name)
|
|
5438
|
+
|
|
5439
|
+
// Switching profiles is a backend re-home: relaunch the dashboard under the
|
|
5440
|
+
// new HERMES_HOME. Pool backends keep their own homes, so only the primary
|
|
5441
|
+
// is torn down.
|
|
5442
|
+
await teardownPrimaryBackendAndWait()
|
|
5443
|
+
mainWindow?.reload()
|
|
3302
5444
|
|
|
3303
|
-
return
|
|
5445
|
+
return { profile: next }
|
|
3304
5446
|
})
|
|
3305
5447
|
|
|
3306
5448
|
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
|
|
@@ -3315,10 +5457,169 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
|
|
3315
5457
|
return systemPreferences.askForMediaAccess('microphone')
|
|
3316
5458
|
})
|
|
3317
5459
|
|
|
5460
|
+
// Re-route remote-profile session requests to the owning remote backend. Returns
|
|
5461
|
+
// `undefined` when not interceptable (caller takes the normal local path), else
|
|
5462
|
+
// the response. Reads tag the profile as ?profile=<name>; mutations carry it in
|
|
5463
|
+
// request.profile. Either way, a remote profile's session lives only on its
|
|
5464
|
+
// remote host, so the request must go there (where it serves its own state.db).
|
|
5465
|
+
// GET /api/profiles/sessions → splice each remote profile's rows in
|
|
5466
|
+
// GET /api/sessions/{id}[/messages] → read from remote
|
|
5467
|
+
// DELETE /api/sessions/{id} → delete on remote
|
|
5468
|
+
// PATCH /api/sessions/{id} → rename/archive on remote
|
|
5469
|
+
async function interceptSessionRequestForRemote(request) {
|
|
5470
|
+
if (typeof request?.path !== 'string') {
|
|
5471
|
+
return undefined
|
|
5472
|
+
}
|
|
5473
|
+
const method = (request.method || 'GET').toUpperCase()
|
|
5474
|
+
|
|
5475
|
+
let parsed
|
|
5476
|
+
try {
|
|
5477
|
+
parsed = new URL(request.path, 'http://x')
|
|
5478
|
+
} catch {
|
|
5479
|
+
return undefined
|
|
5480
|
+
}
|
|
5481
|
+
const { pathname, searchParams } = parsed
|
|
5482
|
+
|
|
5483
|
+
if (method === 'GET' && pathname === '/api/profiles/sessions') {
|
|
5484
|
+
const remoteProfiles = configuredRemoteProfileNames()
|
|
5485
|
+
if (remoteProfiles.length === 0) {
|
|
5486
|
+
return undefined // no remote profiles → local fast path
|
|
5487
|
+
}
|
|
5488
|
+
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
|
|
5489
|
+
if (requested !== 'all') {
|
|
5490
|
+
return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined
|
|
5491
|
+
}
|
|
5492
|
+
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
|
|
5496
|
+
// (mutations). Two remote shapes:
|
|
5497
|
+
// - per-profile override: route to that profile's own remote, sans profile
|
|
5498
|
+
// param (it serves its own state.db natively).
|
|
5499
|
+
// - global remote mode: ONE backend serves every profile via ?profile=, so
|
|
5500
|
+
// route there and KEEP the profile param so it opens the right state.db.
|
|
5501
|
+
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
|
|
5502
|
+
const profile = (searchParams.get('profile') || request.profile || '').trim()
|
|
5503
|
+
if (!profile) {
|
|
5504
|
+
return undefined
|
|
5505
|
+
}
|
|
5506
|
+
if (profileHasRemoteOverride(profile)) {
|
|
5507
|
+
if (method === 'GET') {
|
|
5508
|
+
return fetchJsonForProfile(profile, pathname)
|
|
5509
|
+
}
|
|
5510
|
+
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
|
|
5511
|
+
if (body) delete body.profile
|
|
5512
|
+
return requestJsonForProfile(profile, pathname, method, body)
|
|
5513
|
+
}
|
|
5514
|
+
if (globalRemoteActive()) {
|
|
5515
|
+
// Single global backend: keep ?profile= so it opens the right state.db.
|
|
5516
|
+
const sep = pathname.includes('?') ? '&' : '?'
|
|
5517
|
+
const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}`
|
|
5518
|
+
if (method === 'GET') {
|
|
5519
|
+
return fetchJsonForProfile(null, path)
|
|
5520
|
+
}
|
|
5521
|
+
const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile }
|
|
5522
|
+
return requestJsonForProfile(null, path, method, body)
|
|
5523
|
+
}
|
|
5524
|
+
return undefined
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
return undefined
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : [])
|
|
5531
|
+
|
|
5532
|
+
// A remote profile's session list, read from its remote host and tagged with the
|
|
5533
|
+
// desktop-facing profile name (the remote's /api/sessions doesn't know it).
|
|
5534
|
+
async function remoteSessionList(profile, searchParams) {
|
|
5535
|
+
const qs = new URLSearchParams(searchParams)
|
|
5536
|
+
qs.delete('profile') // remote serves its own db; no cross-profile read there
|
|
5537
|
+
const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`)
|
|
5538
|
+
for (const s of rowsOf(data)) {
|
|
5539
|
+
s.profile = profile
|
|
5540
|
+
s.is_default_profile = false
|
|
5541
|
+
}
|
|
5542
|
+
return { ...data, sessions: rowsOf(data) }
|
|
5543
|
+
}
|
|
5544
|
+
|
|
5545
|
+
// Unified list: primary's local aggregate, with each remote profile's stale local
|
|
5546
|
+
// rows/totals swapped for the remote's real ones, re-sorted by recency and
|
|
5547
|
+
// re-windowed to the requested page. A dead remote contributes nothing rather
|
|
5548
|
+
// than breaking the sidebar.
|
|
5549
|
+
async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
|
5550
|
+
const limit = Math.max(1, Number(searchParams.get('limit')) || 20)
|
|
5551
|
+
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
|
|
5552
|
+
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'
|
|
5553
|
+
|
|
5554
|
+
const primary = await ensureBackend(null)
|
|
5555
|
+
const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
|
|
5556
|
+
method: 'GET',
|
|
5557
|
+
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
|
|
5558
|
+
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
|
|
5559
|
+
|
|
5560
|
+
// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
|
|
5561
|
+
// is correct for this page — mirrors the primary's per-profile over-fetch.
|
|
5562
|
+
const remoteParams = new URLSearchParams(searchParams)
|
|
5563
|
+
remoteParams.set('limit', String(limit + offset))
|
|
5564
|
+
remoteParams.set('offset', '0')
|
|
5565
|
+
|
|
5566
|
+
const remoteSet = new Set(remoteProfiles)
|
|
5567
|
+
const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile))
|
|
5568
|
+
const profileTotals = { ...(base.profile_totals || {}) }
|
|
5569
|
+
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
|
5570
|
+
|
|
5571
|
+
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
|
5572
|
+
await Promise.all(
|
|
5573
|
+
remoteProfiles.map(async name => {
|
|
5574
|
+
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
|
5575
|
+
if (!list) {
|
|
5576
|
+
delete profileTotals[name] // dead remote → drop its stale local total too
|
|
5577
|
+
return
|
|
5578
|
+
}
|
|
5579
|
+
const rows = rowsOf(list)
|
|
5580
|
+
merged.push(...rows)
|
|
5581
|
+
profileTotals[name] = Number(list.total) || rows.length
|
|
5582
|
+
total += profileTotals[name]
|
|
5583
|
+
})
|
|
5584
|
+
)
|
|
5585
|
+
|
|
5586
|
+
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
|
5587
|
+
merged.sort((a, b) => recency(b) - recency(a))
|
|
5588
|
+
return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals }
|
|
5589
|
+
}
|
|
5590
|
+
|
|
3318
5591
|
ipcMain.handle('hermes:api', async (_event, request) => {
|
|
3319
|
-
|
|
5592
|
+
// Remote-profile session requests would otherwise hit the local primary off
|
|
5593
|
+
// each profile's on-disk state.db — fine for local profiles, but a remote
|
|
5594
|
+
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
|
|
5595
|
+
// no-op) the moment they run there. Route reads + mutations to the remote.
|
|
5596
|
+
const rerouted = await interceptSessionRequestForRemote(request)
|
|
5597
|
+
if (rerouted !== undefined) {
|
|
5598
|
+
return rerouted
|
|
5599
|
+
}
|
|
5600
|
+
|
|
5601
|
+
await prepareProfileDeleteRequest(request)
|
|
5602
|
+
|
|
5603
|
+
const profile = request?.profile
|
|
5604
|
+
const connection = await ensureBackend(profile)
|
|
3320
5605
|
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
|
3321
|
-
|
|
5606
|
+
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
|
|
5607
|
+
globalRemote: globalRemoteActive(),
|
|
5608
|
+
profileRemoteOverride: profileHasRemoteOverride(profile)
|
|
5609
|
+
})
|
|
5610
|
+
const url = `${connection.baseUrl}${requestPath}`
|
|
5611
|
+
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
|
|
5612
|
+
// the OAuth partition — route through Electron's net stack bound to that
|
|
5613
|
+
// session so the cookie attaches automatically. Token/local modes keep using
|
|
5614
|
+
// the static session-token header.
|
|
5615
|
+
if (connection.authMode === 'oauth') {
|
|
5616
|
+
return fetchJsonViaOauthSession(url, {
|
|
5617
|
+
method: request?.method,
|
|
5618
|
+
body: request?.body,
|
|
5619
|
+
timeoutMs
|
|
5620
|
+
})
|
|
5621
|
+
}
|
|
5622
|
+
return fetchJson(url, connection.token, {
|
|
3322
5623
|
method: request?.method,
|
|
3323
5624
|
body: request?.body,
|
|
3324
5625
|
timeoutMs
|
|
@@ -3327,11 +5628,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|
|
3327
5628
|
|
|
3328
5629
|
ipcMain.handle('hermes:notify', (_event, payload) => {
|
|
3329
5630
|
if (!Notification.isSupported()) return false
|
|
3330
|
-
|
|
5631
|
+
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
|
5632
|
+
// and the body click still works.
|
|
5633
|
+
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
|
5634
|
+
const notification = new Notification({
|
|
3331
5635
|
title: payload?.title || 'Hermes',
|
|
3332
5636
|
body: payload?.body || '',
|
|
3333
|
-
silent: Boolean(payload?.silent)
|
|
3334
|
-
|
|
5637
|
+
silent: Boolean(payload?.silent),
|
|
5638
|
+
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
|
|
5639
|
+
})
|
|
5640
|
+
notification.on('click', () => {
|
|
5641
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
5642
|
+
focusWindow(mainWindow)
|
|
5643
|
+
if (payload?.sessionId) {
|
|
5644
|
+
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
|
|
5645
|
+
}
|
|
5646
|
+
})
|
|
5647
|
+
notification.on('action', (_actionEvent, index) => {
|
|
5648
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
5649
|
+
const action = actions[index]
|
|
5650
|
+
if (action?.id) {
|
|
5651
|
+
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
|
|
5652
|
+
}
|
|
5653
|
+
})
|
|
5654
|
+
notification.show()
|
|
3335
5655
|
return true
|
|
3336
5656
|
})
|
|
3337
5657
|
|
|
@@ -3372,13 +5692,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
|
|
3372
5692
|
})
|
|
3373
5693
|
|
|
3374
5694
|
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
|
3375
|
-
const properties = ['openFile']
|
|
3376
|
-
if (options?.directories) properties.push('openDirectory')
|
|
5695
|
+
const properties = options?.directories ? ['openDirectory'] : ['openFile']
|
|
3377
5696
|
if (options?.multiple !== false) properties.push('multiSelections')
|
|
3378
5697
|
|
|
5698
|
+
let resolvedDefaultPath
|
|
5699
|
+
if (options?.defaultPath) {
|
|
5700
|
+
try {
|
|
5701
|
+
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
|
5702
|
+
} catch {
|
|
5703
|
+
resolvedDefaultPath = undefined
|
|
5704
|
+
}
|
|
5705
|
+
}
|
|
5706
|
+
|
|
3379
5707
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
3380
5708
|
title: options?.title || 'Add context',
|
|
3381
|
-
defaultPath:
|
|
5709
|
+
defaultPath: resolvedDefaultPath,
|
|
3382
5710
|
properties,
|
|
3383
5711
|
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
|
3384
5712
|
})
|
|
@@ -3431,12 +5759,83 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
|
|
3431
5759
|
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
|
3432
5760
|
})
|
|
3433
5761
|
|
|
5762
|
+
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
|
5763
|
+
ipcMain.on('hermes:native-theme', (_event, mode) => {
|
|
5764
|
+
if (!THEME_SOURCES.has(mode)) {
|
|
5765
|
+
return
|
|
5766
|
+
}
|
|
5767
|
+
|
|
5768
|
+
if (nativeTheme.themeSource !== mode) {
|
|
5769
|
+
nativeTheme.themeSource = mode
|
|
5770
|
+
writePersistedThemeSource(mode)
|
|
5771
|
+
}
|
|
5772
|
+
})
|
|
5773
|
+
|
|
5774
|
+
// See-through window translucency. Persist + re-apply opacity to every open
|
|
5775
|
+
// window at runtime (no recreation, so caching/sessions are untouched).
|
|
5776
|
+
ipcMain.on('hermes:translucency', (_event, payload) => {
|
|
5777
|
+
const next = clampIntensity(payload && payload.intensity)
|
|
5778
|
+
|
|
5779
|
+
if (next === translucencyIntensity) {
|
|
5780
|
+
return
|
|
5781
|
+
}
|
|
5782
|
+
|
|
5783
|
+
translucencyIntensity = next
|
|
5784
|
+
writePersistedTranslucency(next)
|
|
5785
|
+
|
|
5786
|
+
for (const win of BrowserWindow.getAllWindows()) {
|
|
5787
|
+
applyWindowTranslucency(win)
|
|
5788
|
+
}
|
|
5789
|
+
})
|
|
5790
|
+
|
|
3434
5791
|
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
|
3435
5792
|
if (!openExternalUrl(url)) {
|
|
3436
5793
|
throw new Error('Invalid external URL')
|
|
3437
5794
|
}
|
|
3438
5795
|
})
|
|
3439
5796
|
|
|
5797
|
+
// User-configurable default project directory. The renderer reads this on
|
|
5798
|
+
// settings mount and seeds the value into the picker; writing back persists
|
|
5799
|
+
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
|
5800
|
+
// session spawn (no app restart needed).
|
|
5801
|
+
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
|
5802
|
+
dir: readDefaultProjectDir(),
|
|
5803
|
+
defaultLabel: app.getPath('home'),
|
|
5804
|
+
resolvedCwd: resolveHermesCwd()
|
|
5805
|
+
}))
|
|
5806
|
+
|
|
5807
|
+
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
|
|
5808
|
+
|
|
5809
|
+
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
|
5810
|
+
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
|
5811
|
+
|
|
5812
|
+
if (next) {
|
|
5813
|
+
try {
|
|
5814
|
+
fs.mkdirSync(next, { recursive: true })
|
|
5815
|
+
} catch (error) {
|
|
5816
|
+
throw new Error(`Could not create directory: ${error.message}`)
|
|
5817
|
+
}
|
|
5818
|
+
}
|
|
5819
|
+
|
|
5820
|
+
writeDefaultProjectDir(next)
|
|
5821
|
+
|
|
5822
|
+
return { dir: next }
|
|
5823
|
+
})
|
|
5824
|
+
|
|
5825
|
+
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
|
5826
|
+
const result = await dialog.showOpenDialog({
|
|
5827
|
+
title: 'Choose default project directory',
|
|
5828
|
+
properties: ['openDirectory', 'createDirectory'],
|
|
5829
|
+
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
|
5830
|
+
})
|
|
5831
|
+
|
|
5832
|
+
if (result.canceled || result.filePaths.length === 0) {
|
|
5833
|
+
return { canceled: true, dir: null }
|
|
5834
|
+
}
|
|
5835
|
+
|
|
5836
|
+
return { canceled: false, dir: result.filePaths[0] }
|
|
5837
|
+
})
|
|
5838
|
+
|
|
3440
5839
|
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
|
3441
5840
|
|
|
3442
5841
|
ipcMain.handle('hermes:logs:reveal', async () => {
|
|
@@ -3454,62 +5853,119 @@ ipcMain.handle('hermes:logs:reveal', async () => {
|
|
|
3454
5853
|
|
|
3455
5854
|
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
|
3456
5855
|
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
'
|
|
3473
|
-
|
|
5856
|
+
function isExecutableFile(filePath) {
|
|
5857
|
+
if (!filePath || !path.isAbsolute(filePath)) {
|
|
5858
|
+
return false
|
|
5859
|
+
}
|
|
5860
|
+
|
|
5861
|
+
try {
|
|
5862
|
+
fs.accessSync(filePath, fs.constants.X_OK)
|
|
5863
|
+
return true
|
|
5864
|
+
} catch {
|
|
5865
|
+
return false
|
|
5866
|
+
}
|
|
5867
|
+
}
|
|
5868
|
+
|
|
5869
|
+
function posixShellSpec(shellPath) {
|
|
5870
|
+
const shellName = path.basename(shellPath)
|
|
5871
|
+
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
|
5872
|
+
|
|
5873
|
+
return { args: interactiveArgs, command: shellPath, name: shellName }
|
|
5874
|
+
}
|
|
5875
|
+
|
|
5876
|
+
let spawnHelperChecked = false
|
|
5877
|
+
|
|
5878
|
+
// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a
|
|
5879
|
+
// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the
|
|
5880
|
+
// staged copy under resources/native-deps) loses its execute bit through npm
|
|
5881
|
+
// pack / electron-builder file collection, so every nodePty.spawn() dies with
|
|
5882
|
+
// "posix_spawnp failed". Restore +x once, lazily, before the first spawn.
|
|
5883
|
+
function ensureSpawnHelperExecutable() {
|
|
5884
|
+
if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) {
|
|
5885
|
+
return
|
|
5886
|
+
}
|
|
5887
|
+
|
|
5888
|
+
spawnHelperChecked = true
|
|
3474
5889
|
|
|
3475
|
-
|
|
3476
|
-
|
|
5890
|
+
const arch = process.arch
|
|
5891
|
+
const candidates = [
|
|
5892
|
+
path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'),
|
|
5893
|
+
path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper')
|
|
5894
|
+
]
|
|
3477
5895
|
|
|
3478
|
-
for (
|
|
5896
|
+
for (const helper of candidates) {
|
|
3479
5897
|
try {
|
|
3480
|
-
|
|
3481
|
-
|
|
5898
|
+
const mode = fs.statSync(helper).mode
|
|
5899
|
+
|
|
5900
|
+
if ((mode & 0o111) !== 0o111) {
|
|
5901
|
+
fs.chmodSync(helper, mode | 0o755)
|
|
3482
5902
|
}
|
|
3483
5903
|
} catch {
|
|
3484
|
-
|
|
5904
|
+
// Not present in this layout (e.g. compiled build vs prebuild); skip.
|
|
3485
5905
|
}
|
|
5906
|
+
}
|
|
5907
|
+
}
|
|
3486
5908
|
|
|
3487
|
-
|
|
5909
|
+
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
|
|
5910
|
+
// prefer it only after PowerShell 7+ (`pwsh`).
|
|
5911
|
+
function windowsPowerShellPath() {
|
|
5912
|
+
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
|
|
5913
|
+
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
3488
5914
|
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
5915
|
+
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
|
|
5916
|
+
}
|
|
5917
|
+
|
|
5918
|
+
// Map a resolved shell path to its spawn spec, picking interactive flags by
|
|
5919
|
+
// family: PowerShell drops its logo banner (so the prompt sits flush like the
|
|
5920
|
+
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
|
|
5921
|
+
// gets POSIX interactive-login flags.
|
|
5922
|
+
function shellSpecFor(shellPath) {
|
|
5923
|
+
const name = path.basename(shellPath).toLowerCase()
|
|
3492
5924
|
|
|
3493
|
-
|
|
5925
|
+
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
|
|
5926
|
+
return { args: ['-NoLogo'], command: shellPath, name }
|
|
3494
5927
|
}
|
|
3495
5928
|
|
|
3496
|
-
|
|
5929
|
+
if (name.startsWith('cmd')) {
|
|
5930
|
+
return { args: [], command: shellPath, name }
|
|
5931
|
+
}
|
|
5932
|
+
|
|
5933
|
+
return posixShellSpec(shellPath)
|
|
5934
|
+
}
|
|
5935
|
+
|
|
5936
|
+
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
|
|
5937
|
+
// 5.1, then comspec/cmd.exe as the universal fallback.
|
|
5938
|
+
function windowsShellSpec() {
|
|
5939
|
+
const command =
|
|
5940
|
+
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
|
|
5941
|
+
|
|
5942
|
+
return shellSpecFor(command)
|
|
3497
5943
|
}
|
|
3498
5944
|
|
|
5945
|
+
// Resolve the interactive shell for the embedded terminal: an explicit user
|
|
5946
|
+
// override wins, otherwise auto-detect the best one installed for the platform.
|
|
3499
5947
|
function terminalShellCommand() {
|
|
5948
|
+
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
|
|
5949
|
+
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
|
|
5950
|
+
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
|
|
5951
|
+
// node-pty can't spawn natively.
|
|
5952
|
+
const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim()
|
|
5953
|
+
|
|
5954
|
+
if (override) {
|
|
5955
|
+
const resolved = isExecutableFile(override) ? override : findOnPath(override)
|
|
5956
|
+
|
|
5957
|
+
if (resolved) {
|
|
5958
|
+
return shellSpecFor(resolved)
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5961
|
+
|
|
3500
5962
|
if (IS_WINDOWS) {
|
|
3501
|
-
return
|
|
5963
|
+
return windowsShellSpec()
|
|
3502
5964
|
}
|
|
3503
5965
|
|
|
3504
|
-
const
|
|
3505
|
-
const shellPath =
|
|
3506
|
-
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
|
|
3507
|
-
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
|
|
3508
|
-
'/bin/sh'
|
|
3509
|
-
const shellName = path.basename(shellPath)
|
|
3510
|
-
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
|
5966
|
+
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
|
|
3511
5967
|
|
|
3512
|
-
return
|
|
5968
|
+
return posixShellSpec(shellPath || '/bin/sh')
|
|
3513
5969
|
}
|
|
3514
5970
|
|
|
3515
5971
|
function safeTerminalCwd(cwd) {
|
|
@@ -3549,6 +6005,11 @@ function terminalShellEnv() {
|
|
|
3549
6005
|
env.TERM_PROGRAM = 'Hermes'
|
|
3550
6006
|
env.TERM_PROGRAM_VERSION = app.getVersion()
|
|
3551
6007
|
|
|
6008
|
+
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
|
|
6009
|
+
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
|
|
6010
|
+
// which marks the agent *backend* and gates cron/gateway behavior.
|
|
6011
|
+
env.HERMES_DESKTOP_TERMINAL = '1'
|
|
6012
|
+
|
|
3552
6013
|
return env
|
|
3553
6014
|
}
|
|
3554
6015
|
|
|
@@ -3574,52 +6035,19 @@ function disposeTerminalSession(id) {
|
|
|
3574
6035
|
return true
|
|
3575
6036
|
}
|
|
3576
6037
|
|
|
3577
|
-
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) =>
|
|
3578
|
-
const resolved = path.resolve(String(dirPath || ''))
|
|
3579
|
-
|
|
3580
|
-
if (!resolved) {
|
|
3581
|
-
return { entries: [], error: 'invalid-path' }
|
|
3582
|
-
}
|
|
3583
|
-
|
|
3584
|
-
try {
|
|
3585
|
-
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
|
|
3586
|
-
|
|
3587
|
-
const entries = dirents
|
|
3588
|
-
.filter(d => {
|
|
3589
|
-
if (FS_READDIR_HIDDEN.has(d.name)) {
|
|
3590
|
-
return false
|
|
3591
|
-
}
|
|
3592
|
-
|
|
3593
|
-
return true
|
|
3594
|
-
})
|
|
3595
|
-
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
|
|
3596
|
-
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
|
3597
|
-
|
|
3598
|
-
return { entries }
|
|
3599
|
-
} catch (error) {
|
|
3600
|
-
return { entries: [], error: error?.code || 'read-error' }
|
|
3601
|
-
}
|
|
3602
|
-
})
|
|
3603
|
-
|
|
3604
|
-
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
|
3605
|
-
const input = String(startPath || '')
|
|
3606
|
-
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
|
6038
|
+
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
|
3607
6039
|
|
|
3608
|
-
|
|
3609
|
-
const stat = await fs.promises.stat(resolved)
|
|
3610
|
-
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
|
6040
|
+
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
|
3611
6041
|
|
|
3612
|
-
|
|
3613
|
-
} catch {
|
|
3614
|
-
return findGitRoot(resolved)
|
|
3615
|
-
}
|
|
3616
|
-
})
|
|
6042
|
+
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
|
|
3617
6043
|
|
|
3618
6044
|
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
|
3619
6045
|
if (!nodePty) {
|
|
3620
6046
|
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
|
3621
6047
|
}
|
|
3622
6048
|
|
|
6049
|
+
ensureSpawnHelperExecutable()
|
|
6050
|
+
|
|
3623
6051
|
const id = crypto.randomUUID()
|
|
3624
6052
|
const { args, command, name } = terminalShellCommand()
|
|
3625
6053
|
const cwd = safeTerminalCwd(payload?.cwd)
|
|
@@ -3729,6 +6157,19 @@ function resolveHermesVersion() {
|
|
|
3729
6157
|
return app.getVersion()
|
|
3730
6158
|
}
|
|
3731
6159
|
|
|
6160
|
+
// Re-resolve the live Hermes version and push it into the native About panel
|
|
6161
|
+
// just before showing it, so an in-place `hermes update` is reflected without
|
|
6162
|
+
// an app restart. macOS only — `showAboutPanel()` is a no-op elsewhere, and the
|
|
6163
|
+
// other platforms don't use this menu item.
|
|
6164
|
+
function showAboutPanelFresh() {
|
|
6165
|
+
app.setAboutPanelOptions({
|
|
6166
|
+
applicationName: APP_NAME,
|
|
6167
|
+
applicationVersion: resolveHermesVersion(),
|
|
6168
|
+
copyright: 'Copyright © 2026 Nous Research'
|
|
6169
|
+
})
|
|
6170
|
+
app.showAboutPanel()
|
|
6171
|
+
}
|
|
6172
|
+
|
|
3732
6173
|
ipcMain.handle('hermes:version', async () => ({
|
|
3733
6174
|
appVersion: resolveHermesVersion(),
|
|
3734
6175
|
electronVersion: process.versions.electron,
|
|
@@ -3737,6 +6178,309 @@ ipcMain.handle('hermes:version', async () => ({
|
|
|
3737
6178
|
hermesRoot: resolveUpdateRoot()
|
|
3738
6179
|
}))
|
|
3739
6180
|
|
|
6181
|
+
// ===========================================================================
|
|
6182
|
+
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
|
|
6183
|
+
// ===========================================================================
|
|
6184
|
+
//
|
|
6185
|
+
// The renderer's About → Danger Zone surfaces three options that mirror the
|
|
6186
|
+
// CLI exactly: GUI only, Lite (keep user data), Full. We ask the agent to do
|
|
6187
|
+
// the actual removal via `hermes uninstall …` so the cross-platform PATH /
|
|
6188
|
+
// registry / service / node-symlink cleanup all lives in one place
|
|
6189
|
+
// (hermes_cli/uninstall.py + hermes_cli/gui_uninstall.py).
|
|
6190
|
+
//
|
|
6191
|
+
// getUninstallSummary() shells out to `--gui-summary` (a fast, no-side-effect
|
|
6192
|
+
// JSON probe) so the UI can gate options on what's actually installed — and
|
|
6193
|
+
// detect a missing agent (a future "lite client" that ships without the
|
|
6194
|
+
// bundled agent), hiding the agent/full options when there's nothing to remove.
|
|
6195
|
+
|
|
6196
|
+
function uninstallVenvPython() {
|
|
6197
|
+
return getVenvPython(VENV_ROOT)
|
|
6198
|
+
}
|
|
6199
|
+
|
|
6200
|
+
async function getUninstallSummary() {
|
|
6201
|
+
const py = uninstallVenvPython()
|
|
6202
|
+
const agentRoot = ACTIVE_HERMES_ROOT
|
|
6203
|
+
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
|
|
6204
|
+
// probe fails — the renderer still needs *something* to render options from.
|
|
6205
|
+
const fallback = () => ({
|
|
6206
|
+
hermes_home: HERMES_HOME,
|
|
6207
|
+
agent_installed: isHermesSourceRoot(agentRoot) && fileExists(py),
|
|
6208
|
+
gui_installed: true,
|
|
6209
|
+
source_built_artifacts: [],
|
|
6210
|
+
packaged_app_paths: [],
|
|
6211
|
+
userdata_dir: app.getPath('userData'),
|
|
6212
|
+
userdata_exists: true,
|
|
6213
|
+
platform: process.platform,
|
|
6214
|
+
probe: 'fallback'
|
|
6215
|
+
})
|
|
6216
|
+
|
|
6217
|
+
if (!fileExists(py)) {
|
|
6218
|
+
return fallback()
|
|
6219
|
+
}
|
|
6220
|
+
|
|
6221
|
+
return new Promise(resolve => {
|
|
6222
|
+
let stdout = ''
|
|
6223
|
+
let settled = false
|
|
6224
|
+
const done = value => {
|
|
6225
|
+
if (settled) return
|
|
6226
|
+
settled = true
|
|
6227
|
+
resolve(value)
|
|
6228
|
+
}
|
|
6229
|
+
try {
|
|
6230
|
+
const child = spawn(
|
|
6231
|
+
py,
|
|
6232
|
+
['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'],
|
|
6233
|
+
hiddenWindowsChildOptions({
|
|
6234
|
+
cwd: agentRoot,
|
|
6235
|
+
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
|
6236
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
6237
|
+
})
|
|
6238
|
+
)
|
|
6239
|
+
child.stdout.on('data', chunk => {
|
|
6240
|
+
stdout += chunk.toString()
|
|
6241
|
+
})
|
|
6242
|
+
child.on('error', () => done(fallback()))
|
|
6243
|
+
child.on('exit', code => {
|
|
6244
|
+
if (code !== 0) return done(fallback())
|
|
6245
|
+
try {
|
|
6246
|
+
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'
|
|
6247
|
+
const parsed = JSON.parse(line)
|
|
6248
|
+
// The app bundle the renderer would be removing on *this* machine,
|
|
6249
|
+
// resolved from the running exe (the Python probe only knows the
|
|
6250
|
+
// standard locations, not where THIS build actually runs from).
|
|
6251
|
+
parsed.running_app_path = resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
|
6252
|
+
done(parsed)
|
|
6253
|
+
} catch {
|
|
6254
|
+
done(fallback())
|
|
6255
|
+
}
|
|
6256
|
+
})
|
|
6257
|
+
setTimeout(() => done(fallback()), 8000)
|
|
6258
|
+
} catch {
|
|
6259
|
+
done(fallback())
|
|
6260
|
+
}
|
|
6261
|
+
})
|
|
6262
|
+
}
|
|
6263
|
+
|
|
6264
|
+
async function runDesktopUninstall(mode) {
|
|
6265
|
+
let uninstallArgs
|
|
6266
|
+
try {
|
|
6267
|
+
uninstallArgs = uninstallArgsForMode(mode)
|
|
6268
|
+
} catch (error) {
|
|
6269
|
+
return { ok: false, error: 'invalid-mode', message: error.message }
|
|
6270
|
+
}
|
|
6271
|
+
|
|
6272
|
+
const venvPy = uninstallVenvPython()
|
|
6273
|
+
if (!fileExists(venvPy)) {
|
|
6274
|
+
return {
|
|
6275
|
+
ok: false,
|
|
6276
|
+
error: 'agent-missing',
|
|
6277
|
+
message: `Can't run the uninstaller: no Hermes agent venv at ${VENV_ROOT}.`
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
|
|
6281
|
+
// Interpreter choice (Finding 3): lite/full rmtree the venv that holds the
|
|
6282
|
+
// running python.exe. On Windows a running .exe is mandatory-locked, so the
|
|
6283
|
+
// rmtree must NOT be driven by the venv's own interpreter — use a system
|
|
6284
|
+
// Python with PYTHONPATH=<agentRoot> so `import hermes_cli` resolves from
|
|
6285
|
+
// source while the venv is torn down. gui-only doesn't touch the venv, so the
|
|
6286
|
+
// venv python is fine there. If no system Python exists (the Windows edge
|
|
6287
|
+
// case), fall back to the venv python — gui-only is unaffected; lite/full may
|
|
6288
|
+
// leave venv remnants the user can delete, which we log.
|
|
6289
|
+
let py = venvPy
|
|
6290
|
+
let pythonPath = null
|
|
6291
|
+
if (modeRemovesAgent(mode)) {
|
|
6292
|
+
const sysPy = findSystemPython()
|
|
6293
|
+
if (sysPy) {
|
|
6294
|
+
py = sysPy
|
|
6295
|
+
pythonPath = ACTIVE_HERMES_ROOT
|
|
6296
|
+
} else if (IS_WINDOWS) {
|
|
6297
|
+
rememberLog(
|
|
6298
|
+
'[uninstall] no system Python found for lite/full on Windows; falling back ' +
|
|
6299
|
+
'to the venv python — venv files locked by the running interpreter may ' +
|
|
6300
|
+
'remain and need manual deletion.'
|
|
6301
|
+
)
|
|
6302
|
+
}
|
|
6303
|
+
}
|
|
6304
|
+
|
|
6305
|
+
const appPath = resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
|
6306
|
+
const removeBundle = shouldRemoveAppBundle(IS_PACKAGED, appPath) ? appPath : null
|
|
6307
|
+
|
|
6308
|
+
// CRITICAL (Windows): tear down every backend the desktop owns and wait for
|
|
6309
|
+
// the venv shim to unlock BEFORE the cleanup script runs. lite/full delete
|
|
6310
|
+
// the venv, and even gui-only removes the install tree's GUI artifacts — a
|
|
6311
|
+
// live backend grandchild (gateway / pty / REPL) holding a mandatory file
|
|
6312
|
+
// lock would make the script's rmdir half-fail (#37532 for the update path).
|
|
6313
|
+
// Reuses the incident-hardened update teardown; no-op on macOS/Linux.
|
|
6314
|
+
try {
|
|
6315
|
+
await releaseBackendLock(ACTIVE_HERMES_ROOT, 'uninstall')
|
|
6316
|
+
} catch (error) {
|
|
6317
|
+
rememberLog(`[uninstall] backend teardown errored (continuing): ${error.message}`)
|
|
6318
|
+
}
|
|
6319
|
+
|
|
6320
|
+
const scriptArgs = {
|
|
6321
|
+
desktopPid: process.pid,
|
|
6322
|
+
pythonExe: py,
|
|
6323
|
+
pythonPath,
|
|
6324
|
+
agentRoot: ACTIVE_HERMES_ROOT,
|
|
6325
|
+
uninstallArgs,
|
|
6326
|
+
appPath: removeBundle,
|
|
6327
|
+
hermesHome: HERMES_HOME
|
|
6328
|
+
}
|
|
6329
|
+
|
|
6330
|
+
let scriptPath
|
|
6331
|
+
let runner
|
|
6332
|
+
let runnerArgs
|
|
6333
|
+
try {
|
|
6334
|
+
if (IS_WINDOWS) {
|
|
6335
|
+
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`)
|
|
6336
|
+
fs.writeFileSync(scriptPath, buildWindowsCleanupScript(scriptArgs))
|
|
6337
|
+
runner = process.env.ComSpec || 'cmd.exe'
|
|
6338
|
+
runnerArgs = ['/c', scriptPath]
|
|
6339
|
+
} else {
|
|
6340
|
+
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.sh`)
|
|
6341
|
+
fs.writeFileSync(scriptPath, buildPosixCleanupScript(scriptArgs), { mode: 0o755 })
|
|
6342
|
+
runner = '/bin/bash'
|
|
6343
|
+
runnerArgs = [scriptPath]
|
|
6344
|
+
}
|
|
6345
|
+
} catch (error) {
|
|
6346
|
+
return { ok: false, error: 'script-write-failed', message: error.message }
|
|
6347
|
+
}
|
|
6348
|
+
|
|
6349
|
+
try {
|
|
6350
|
+
const child = spawn(runner, runnerArgs, {
|
|
6351
|
+
detached: true,
|
|
6352
|
+
stdio: 'ignore',
|
|
6353
|
+
windowsHide: true
|
|
6354
|
+
})
|
|
6355
|
+
child.unref()
|
|
6356
|
+
} catch (error) {
|
|
6357
|
+
return { ok: false, error: 'spawn-failed', message: error.message }
|
|
6358
|
+
}
|
|
6359
|
+
|
|
6360
|
+
rememberLog(
|
|
6361
|
+
`[uninstall] launched detached cleanup (${mode}): ${scriptPath} ` +
|
|
6362
|
+
`(removesAgent=${modeRemovesAgent(mode)} removesUserData=${modeRemovesUserData(mode)} bundle=${removeBundle || 'none'})`
|
|
6363
|
+
)
|
|
6364
|
+
|
|
6365
|
+
// Give the renderer a beat to show its "uninstalling…" state, then quit so
|
|
6366
|
+
// the venv python shim + app bundle unlock and the cleanup script can run.
|
|
6367
|
+
setTimeout(() => app.quit(), 800)
|
|
6368
|
+
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
|
|
6369
|
+
}
|
|
6370
|
+
|
|
6371
|
+
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
|
6372
|
+
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
|
6373
|
+
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
|
6374
|
+
return runDesktopUninstall(String(mode || ''))
|
|
6375
|
+
})
|
|
6376
|
+
|
|
6377
|
+
// Download a VS Code Marketplace extension and return the raw color-theme JSON
|
|
6378
|
+
// it contributes. No theme code is executed — we only read JSON from the .vsix.
|
|
6379
|
+
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
|
|
6380
|
+
|
|
6381
|
+
// Search the Marketplace for color-theme extensions (empty query = top installs).
|
|
6382
|
+
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
|
|
6383
|
+
|
|
6384
|
+
// ---------------------------------------------------------------------------
|
|
6385
|
+
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
|
|
6386
|
+
// A docs/dashboard "Send to App" button opens this URL; we route it into the
|
|
6387
|
+
// running app's chat composer. Three delivery paths: macOS 'open-url',
|
|
6388
|
+
// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
|
|
6389
|
+
// ---------------------------------------------------------------------------
|
|
6390
|
+
const HERMES_PROTOCOL = 'hermes'
|
|
6391
|
+
let _pendingDeepLink = null
|
|
6392
|
+
let _rendererReadyForDeepLink = false
|
|
6393
|
+
|
|
6394
|
+
function _extractDeepLink(argv) {
|
|
6395
|
+
if (!Array.isArray(argv)) return null
|
|
6396
|
+
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
function handleDeepLink(url) {
|
|
6400
|
+
if (!url || typeof url !== 'string') return
|
|
6401
|
+
let parsed
|
|
6402
|
+
try {
|
|
6403
|
+
parsed = new URL(url)
|
|
6404
|
+
} catch {
|
|
6405
|
+
rememberLog(`[deeplink] ignoring malformed url: ${url}`)
|
|
6406
|
+
return
|
|
6407
|
+
}
|
|
6408
|
+
// hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
|
|
6409
|
+
const kind = parsed.hostname || ''
|
|
6410
|
+
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
|
|
6411
|
+
const params = {}
|
|
6412
|
+
parsed.searchParams.forEach((v, k) => {
|
|
6413
|
+
params[k] = v
|
|
6414
|
+
})
|
|
6415
|
+
const payload = { kind, name, params }
|
|
6416
|
+
|
|
6417
|
+
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
|
|
6418
|
+
_pendingDeepLink = payload
|
|
6419
|
+
return
|
|
6420
|
+
}
|
|
6421
|
+
try {
|
|
6422
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
6423
|
+
mainWindow.focus()
|
|
6424
|
+
mainWindow.webContents.send('hermes:deep-link', payload)
|
|
6425
|
+
rememberLog(`[deeplink] delivered ${kind}/${name}`)
|
|
6426
|
+
} catch (err) {
|
|
6427
|
+
rememberLog(`[deeplink] delivery failed: ${err.message}`)
|
|
6428
|
+
}
|
|
6429
|
+
}
|
|
6430
|
+
|
|
6431
|
+
// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
|
|
6432
|
+
// a link that arrived during boot/install is flushed exactly once.
|
|
6433
|
+
ipcMain.handle('hermes:deep-link-ready', () => {
|
|
6434
|
+
_rendererReadyForDeepLink = true
|
|
6435
|
+
if (_pendingDeepLink) {
|
|
6436
|
+
const queued = _pendingDeepLink
|
|
6437
|
+
_pendingDeepLink = null
|
|
6438
|
+
handleDeepLink(
|
|
6439
|
+
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
|
|
6440
|
+
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
|
|
6441
|
+
)
|
|
6442
|
+
}
|
|
6443
|
+
return { ok: true }
|
|
6444
|
+
})
|
|
6445
|
+
|
|
6446
|
+
function registerDeepLinkProtocol() {
|
|
6447
|
+
try {
|
|
6448
|
+
if (process.defaultApp && process.argv.length >= 2) {
|
|
6449
|
+
// Dev: register with the electron exec path + entry script so the OS can
|
|
6450
|
+
// relaunch us with the URL.
|
|
6451
|
+
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
|
|
6452
|
+
} else {
|
|
6453
|
+
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
|
|
6454
|
+
}
|
|
6455
|
+
} catch (err) {
|
|
6456
|
+
rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
|
|
6460
|
+
// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
|
|
6461
|
+
// second-instance argv. Without the lock a second `hermes://` launch spawns a
|
|
6462
|
+
// whole new app instead of routing into the running one.
|
|
6463
|
+
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
|
|
6464
|
+
if (!_gotSingleInstanceLock) {
|
|
6465
|
+
app.quit()
|
|
6466
|
+
} else {
|
|
6467
|
+
app.on('second-instance', (_event, argv) => {
|
|
6468
|
+
const url = _extractDeepLink(argv)
|
|
6469
|
+
if (url) handleDeepLink(url)
|
|
6470
|
+
else if (mainWindow) {
|
|
6471
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
6472
|
+
mainWindow.focus()
|
|
6473
|
+
}
|
|
6474
|
+
})
|
|
6475
|
+
}
|
|
6476
|
+
|
|
6477
|
+
// macOS delivers deep links via 'open-url' — register early (can fire before
|
|
6478
|
+
// whenReady; handleDeepLink queues until the renderer is ready).
|
|
6479
|
+
app.on('open-url', (event, url) => {
|
|
6480
|
+
event.preventDefault()
|
|
6481
|
+
handleDeepLink(url)
|
|
6482
|
+
})
|
|
6483
|
+
|
|
3740
6484
|
app.whenReady().then(() => {
|
|
3741
6485
|
if (IS_MAC) {
|
|
3742
6486
|
Menu.setApplicationMenu(buildApplicationMenu())
|
|
@@ -3745,20 +6489,59 @@ app.whenReady().then(() => {
|
|
|
3745
6489
|
}
|
|
3746
6490
|
installMediaPermissions()
|
|
3747
6491
|
registerMediaProtocol()
|
|
6492
|
+
registerDeepLinkProtocol()
|
|
3748
6493
|
ensureWslWindowsFonts()
|
|
6494
|
+
configureSpellChecker()
|
|
6495
|
+
registerPowerResumeListeners()
|
|
3749
6496
|
createWindow()
|
|
3750
6497
|
|
|
6498
|
+
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
|
6499
|
+
const _coldStartLink = _extractDeepLink(process.argv)
|
|
6500
|
+
if (_coldStartLink) handleDeepLink(_coldStartLink)
|
|
6501
|
+
|
|
3751
6502
|
app.on('activate', () => {
|
|
3752
|
-
if
|
|
6503
|
+
// Recreate the primary window if it's gone. Guard on mainWindow directly
|
|
6504
|
+
// (not just total window count) so a dock click still restores the main
|
|
6505
|
+
// window when only secondary session windows remain open.
|
|
6506
|
+
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
6507
|
+
createWindow()
|
|
6508
|
+
} else {
|
|
6509
|
+
focusWindow(mainWindow)
|
|
6510
|
+
}
|
|
3753
6511
|
})
|
|
3754
6512
|
})
|
|
3755
6513
|
|
|
6514
|
+
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
|
|
6515
|
+
// On macOS Electron uses the native spellchecker which ignores this list, but
|
|
6516
|
+
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
|
|
6517
|
+
// won't enable any without an explicit language.
|
|
6518
|
+
function configureSpellChecker() {
|
|
6519
|
+
try {
|
|
6520
|
+
const defaultSession = session.defaultSession
|
|
6521
|
+
|
|
6522
|
+
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
|
|
6523
|
+
return
|
|
6524
|
+
}
|
|
6525
|
+
|
|
6526
|
+
const available = defaultSession.availableSpellCheckerLanguages || []
|
|
6527
|
+
const locale = (app.getLocale && app.getLocale()) || 'en-US'
|
|
6528
|
+
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
|
|
6529
|
+
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
|
|
6530
|
+
|
|
6531
|
+
defaultSession.setSpellCheckerLanguages([chosen])
|
|
6532
|
+
} catch (error) {
|
|
6533
|
+
rememberLog(`Spellchecker setup failed: ${error.message}`)
|
|
6534
|
+
}
|
|
6535
|
+
}
|
|
6536
|
+
|
|
3756
6537
|
app.on('before-quit', () => {
|
|
3757
6538
|
// Quitting mid-install should stop the installer, not orphan it.
|
|
3758
6539
|
if (bootstrapAbortController) {
|
|
3759
6540
|
try {
|
|
3760
6541
|
bootstrapAbortController.abort()
|
|
3761
|
-
} catch {
|
|
6542
|
+
} catch {
|
|
6543
|
+
void 0
|
|
6544
|
+
}
|
|
3762
6545
|
}
|
|
3763
6546
|
|
|
3764
6547
|
if (desktopLogFlushTimer) {
|
|
@@ -3768,9 +6551,16 @@ app.on('before-quit', () => {
|
|
|
3768
6551
|
flushDesktopLogBufferSync()
|
|
3769
6552
|
closePreviewWatchers()
|
|
3770
6553
|
|
|
6554
|
+
// Kill open PTYs before environment teardown to avoid the node-pty#904
|
|
6555
|
+
// ThreadSafeFunction SIGABRT race.
|
|
6556
|
+
for (const id of [...terminalSessions.keys()]) {
|
|
6557
|
+
disposeTerminalSession(id)
|
|
6558
|
+
}
|
|
6559
|
+
|
|
3771
6560
|
if (hermesProcess && !hermesProcess.killed) {
|
|
3772
6561
|
hermesProcess.kill('SIGTERM')
|
|
3773
6562
|
}
|
|
6563
|
+
stopAllPoolBackends()
|
|
3774
6564
|
})
|
|
3775
6565
|
|
|
3776
6566
|
app.on('window-all-closed', () => {
|