@clawpump/claw-agent 0.1.5 → 0.1.6

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.
Files changed (1212) hide show
  1. package/agent/.dockerignore +67 -0
  2. package/agent/.envrc +1 -1
  3. package/agent/.gitattributes +8 -0
  4. package/agent/AGENTS.md +216 -4
  5. package/agent/CONTRIBUTING.md +46 -8
  6. package/agent/Dockerfile +78 -35
  7. package/agent/MANIFEST.in +2 -0
  8. package/agent/README.md +12 -5
  9. package/agent/README.ur-pk.md +261 -0
  10. package/agent/README.zh-CN.md +11 -8
  11. package/agent/SECURITY.md +5 -4
  12. package/agent/acp_adapter/provenance.py +127 -0
  13. package/agent/acp_adapter/server.py +112 -5
  14. package/agent/acp_adapter/session.py +1 -6
  15. package/agent/acp_registry/agent.json +2 -2
  16. package/agent/agent/account_usage.py +313 -1
  17. package/agent/agent/agent_init.py +140 -37
  18. package/agent/agent/agent_runtime_helpers.py +342 -83
  19. package/agent/agent/anthropic_adapter.py +320 -33
  20. package/agent/agent/auxiliary_client.py +525 -105
  21. package/agent/agent/background_review.py +157 -19
  22. package/agent/agent/bedrock_adapter.py +71 -6
  23. package/agent/agent/billing_view.py +295 -0
  24. package/agent/agent/chat_completion_helpers.py +229 -4
  25. package/agent/agent/codex_responses_adapter.py +86 -10
  26. package/agent/agent/codex_runtime.py +153 -1
  27. package/agent/agent/coding_context.py +738 -0
  28. package/agent/agent/context_compressor.py +392 -44
  29. package/agent/agent/context_references.py +34 -1
  30. package/agent/agent/conversation_compression.py +159 -22
  31. package/agent/agent/conversation_loop.py +643 -908
  32. package/agent/agent/copilot_acp_client.py +4 -11
  33. package/agent/agent/credential_pool.py +5 -3
  34. package/agent/agent/credits_tracker.py +794 -0
  35. package/agent/agent/curator.py +91 -18
  36. package/agent/agent/curator_backup.py +26 -10
  37. package/agent/agent/display.py +42 -1
  38. package/agent/agent/error_classifier.py +52 -3
  39. package/agent/agent/errors.py +3 -0
  40. package/agent/agent/file_safety.py +0 -17
  41. package/agent/agent/gemini_native_adapter.py +31 -1
  42. package/agent/agent/i18n.py +48 -4
  43. package/agent/agent/image_gen_provider.py +74 -5
  44. package/agent/agent/image_routing.py +29 -0
  45. package/agent/agent/insights.py +8 -17
  46. package/agent/agent/lsp/install.py +3 -0
  47. package/agent/agent/memory_manager.py +326 -31
  48. package/agent/agent/message_content.py +50 -0
  49. package/agent/agent/model_metadata.py +214 -3
  50. package/agent/agent/moonshot_schema.py +8 -1
  51. package/agent/agent/onboarding.py +60 -0
  52. package/agent/agent/prompt_builder.py +327 -37
  53. package/agent/agent/redact.py +1 -0
  54. package/agent/agent/runtime_cwd.py +34 -5
  55. package/agent/agent/secret_scope.py +205 -0
  56. package/agent/agent/secret_sources/bitwarden.py +34 -2
  57. package/agent/agent/skill_commands.py +90 -1
  58. package/agent/agent/skill_preprocessing.py +1 -0
  59. package/agent/agent/skill_utils.py +209 -36
  60. package/agent/agent/ssl_guard.py +94 -0
  61. package/agent/agent/system_prompt.py +133 -5
  62. package/agent/agent/tool_executor.py +496 -70
  63. package/agent/agent/transports/anthropic.py +83 -21
  64. package/agent/agent/transports/chat_completions.py +94 -5
  65. package/agent/agent/transports/codex.py +67 -2
  66. package/agent/agent/transports/codex_app_server.py +1 -0
  67. package/agent/agent/transports/codex_app_server_session.py +30 -0
  68. package/agent/agent/transports/types.py +12 -0
  69. package/agent/agent/turn_context.py +408 -0
  70. package/agent/agent/turn_finalizer.py +428 -0
  71. package/agent/agent/turn_retry_state.py +68 -0
  72. package/agent/agent/usage_pricing.py +3 -0
  73. package/agent/apps/bootstrap-installer/package.json +6 -5
  74. package/agent/apps/bootstrap-installer/src/routes/failure.tsx +12 -5
  75. package/agent/apps/bootstrap-installer/src/routes/progress.tsx +1 -3
  76. package/agent/apps/bootstrap-installer/src/store.ts +3 -2
  77. package/agent/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +172 -7
  78. package/agent/apps/bootstrap-installer/src-tauri/src/events.rs +14 -1
  79. package/agent/apps/bootstrap-installer/src-tauri/src/paths.rs +29 -0
  80. package/agent/apps/bootstrap-installer/src-tauri/src/powershell.rs +93 -3
  81. package/agent/apps/bootstrap-installer/src-tauri/src/update.rs +695 -39
  82. package/agent/apps/bootstrap-installer/tsconfig.json +3 -4
  83. package/agent/apps/desktop/DESIGN.md +167 -0
  84. package/agent/apps/desktop/README.md +20 -16
  85. package/agent/apps/desktop/assets/icon.icns +0 -0
  86. package/agent/apps/desktop/assets/icon.ico +0 -0
  87. package/agent/apps/desktop/assets/icon.png +0 -0
  88. package/agent/apps/desktop/electron/backend-env.cjs +112 -0
  89. package/agent/apps/desktop/electron/backend-env.test.cjs +111 -0
  90. package/agent/apps/desktop/electron/backend-probes.test.cjs +3 -1
  91. package/agent/apps/desktop/electron/backend-ready.cjs +66 -0
  92. package/agent/apps/desktop/electron/bootstrap-platform.cjs +52 -0
  93. package/agent/apps/desktop/electron/bootstrap-platform.test.cjs +59 -1
  94. package/agent/apps/desktop/electron/bootstrap-runner.cjs +176 -38
  95. package/agent/apps/desktop/electron/bootstrap-runner.test.cjs +112 -1
  96. package/agent/apps/desktop/electron/connection-config.cjs +288 -0
  97. package/agent/apps/desktop/electron/connection-config.test.cjs +396 -0
  98. package/agent/apps/desktop/electron/dashboard-token.cjs +99 -0
  99. package/agent/apps/desktop/electron/dashboard-token.test.cjs +142 -0
  100. package/agent/apps/desktop/electron/desktop-uninstall.cjs +232 -0
  101. package/agent/apps/desktop/electron/desktop-uninstall.test.cjs +246 -0
  102. package/agent/apps/desktop/electron/entitlements.mac.inherit.plist +2 -0
  103. package/agent/apps/desktop/electron/fs-read-dir.cjs +109 -0
  104. package/agent/apps/desktop/electron/fs-read-dir.test.cjs +364 -0
  105. package/agent/apps/desktop/electron/gateway-ws-probe.cjs +188 -0
  106. package/agent/apps/desktop/electron/gateway-ws-probe.test.cjs +122 -0
  107. package/agent/apps/desktop/electron/git-root.cjs +54 -0
  108. package/agent/apps/desktop/electron/git-root.test.cjs +40 -0
  109. package/agent/apps/desktop/electron/git-worktrees.cjs +174 -0
  110. package/agent/apps/desktop/electron/hardening.cjs +123 -28
  111. package/agent/apps/desktop/electron/hardening.test.cjs +163 -0
  112. package/agent/apps/desktop/electron/main.cjs +3121 -331
  113. package/agent/apps/desktop/electron/oauth-net-request.cjs +20 -0
  114. package/agent/apps/desktop/electron/oauth-net-request.test.cjs +34 -0
  115. package/agent/apps/desktop/electron/preload.cjs +52 -2
  116. package/agent/apps/desktop/electron/session-windows.cjs +124 -0
  117. package/agent/apps/desktop/electron/session-windows.test.cjs +199 -0
  118. package/agent/apps/desktop/electron/update-rebuild.cjs +29 -0
  119. package/agent/apps/desktop/electron/update-rebuild.test.cjs +55 -0
  120. package/agent/apps/desktop/electron/update-remote.cjs +56 -0
  121. package/agent/apps/desktop/electron/update-remote.test.cjs +78 -0
  122. package/agent/apps/desktop/electron/vscode-marketplace.cjs +331 -0
  123. package/agent/apps/desktop/electron/vscode-marketplace.test.cjs +113 -0
  124. package/agent/apps/desktop/electron/windows-child-process.test.cjs +57 -0
  125. package/agent/apps/desktop/electron/windows-user-env.cjs +76 -0
  126. package/agent/apps/desktop/electron/windows-user-env.test.cjs +90 -0
  127. package/agent/apps/desktop/electron/workspace-cwd.cjs +38 -0
  128. package/agent/apps/desktop/electron/workspace-cwd.test.cjs +45 -0
  129. package/agent/apps/desktop/eslint.config.mjs +0 -3
  130. package/agent/apps/desktop/index.html +27 -2
  131. package/agent/apps/desktop/package.json +31 -11
  132. package/agent/apps/desktop/pr-assets/session-source-folders.png +0 -0
  133. package/agent/apps/desktop/public/apple-touch-icon.png +0 -0
  134. package/agent/apps/desktop/public/nous-girl.jpg +0 -0
  135. package/agent/apps/desktop/scripts/assert-dist-built.cjs +70 -0
  136. package/agent/apps/desktop/scripts/assert-dist-built.test.cjs +84 -0
  137. package/agent/apps/desktop/scripts/before-pack.cjs +78 -0
  138. package/agent/apps/desktop/scripts/before-pack.test.cjs +53 -0
  139. package/agent/apps/desktop/scripts/diag-scroll-reset.mjs +229 -0
  140. package/agent/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs +64 -0
  141. package/agent/apps/desktop/scripts/run-electron-builder.cjs +57 -0
  142. package/agent/apps/desktop/src/app/agents/index.tsx +53 -45
  143. package/agent/apps/desktop/src/app/artifacts/index.tsx +102 -83
  144. package/agent/apps/desktop/src/app/chat/chat-drop-overlay.tsx +29 -8
  145. package/agent/apps/desktop/src/app/chat/chat-swap-overlay.tsx +47 -0
  146. package/agent/apps/desktop/src/app/chat/composer/attachments.tsx +81 -45
  147. package/agent/apps/desktop/src/app/chat/composer/completion-drawer.tsx +13 -24
  148. package/agent/apps/desktop/src/app/chat/composer/context-menu.tsx +138 -88
  149. package/agent/apps/desktop/src/app/chat/composer/controls.tsx +138 -90
  150. package/agent/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
  151. package/agent/apps/desktop/src/app/chat/composer/focus.ts +32 -0
  152. package/agent/apps/desktop/src/app/chat/composer/help-hint.tsx +38 -25
  153. package/agent/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +7 -0
  154. package/agent/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +22 -12
  155. package/agent/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +142 -14
  156. package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +14 -11
  157. package/agent/apps/desktop/src/app/chat/composer/hooks/use-voice-recorder.ts +9 -6
  158. package/agent/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
  159. package/agent/apps/desktop/src/app/chat/composer/index.tsx +930 -180
  160. package/agent/apps/desktop/src/app/chat/composer/inline-refs.ts +136 -32
  161. package/agent/apps/desktop/src/app/chat/composer/model-pill.tsx +86 -0
  162. package/agent/apps/desktop/src/app/chat/composer/queue-panel.tsx +54 -75
  163. package/agent/apps/desktop/src/app/chat/composer/rich-editor.test.ts +117 -1
  164. package/agent/apps/desktop/src/app/chat/composer/rich-editor.ts +117 -6
  165. package/agent/apps/desktop/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
  166. package/agent/apps/desktop/src/app/chat/composer/status-stack/index.tsx +202 -0
  167. package/agent/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +155 -0
  168. package/agent/apps/desktop/src/app/chat/composer/text-utils.test.ts +104 -0
  169. package/agent/apps/desktop/src/app/chat/composer/text-utils.ts +37 -9
  170. package/agent/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +50 -0
  171. package/agent/apps/desktop/src/app/chat/composer/trigger-popover.tsx +105 -40
  172. package/agent/apps/desktop/src/app/chat/composer/types.ts +5 -0
  173. package/agent/apps/desktop/src/app/chat/composer/url-dialog.tsx +11 -15
  174. package/agent/apps/desktop/src/app/chat/composer/voice-activity.tsx +8 -4
  175. package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
  176. package/agent/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +70 -16
  177. package/agent/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +52 -16
  178. package/agent/apps/desktop/src/app/chat/index.tsx +234 -81
  179. package/agent/apps/desktop/src/app/chat/perf-probe.tsx +69 -21
  180. package/agent/apps/desktop/src/app/chat/right-rail/preview-console.tsx +44 -40
  181. package/agent/apps/desktop/src/app/chat/right-rail/preview-file.tsx +71 -25
  182. package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +40 -1
  183. package/agent/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +55 -53
  184. package/agent/apps/desktop/src/app/chat/right-rail/preview.tsx +35 -17
  185. package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
  186. package/agent/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +74 -0
  187. package/agent/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +356 -0
  188. package/agent/apps/desktop/src/app/chat/sidebar/index.tsx +1189 -364
  189. package/agent/apps/desktop/src/app/chat/sidebar/load-more-row.tsx +30 -0
  190. package/agent/apps/desktop/src/app/chat/sidebar/order.test.ts +21 -0
  191. package/agent/apps/desktop/src/app/chat/sidebar/order.ts +17 -0
  192. package/agent/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +524 -0
  193. package/agent/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +80 -45
  194. package/agent/apps/desktop/src/app/chat/sidebar/session-row.tsx +120 -25
  195. package/agent/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +7 -13
  196. package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
  197. package/agent/apps/desktop/src/app/chat/sidebar/workspace-groups.ts +326 -0
  198. package/agent/apps/desktop/src/app/chat/thread-loading.ts +7 -2
  199. package/agent/apps/desktop/src/app/command-center/index.tsx +320 -581
  200. package/agent/apps/desktop/src/app/command-palette/index.tsx +681 -0
  201. package/agent/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +157 -0
  202. package/agent/apps/desktop/src/app/cron/index.tsx +392 -324
  203. package/agent/apps/desktop/src/app/cron/job-state.ts +29 -0
  204. package/agent/apps/desktop/src/app/desktop-controller.tsx +618 -123
  205. package/agent/apps/desktop/src/app/floating-hud.ts +22 -0
  206. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
  207. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +260 -14
  208. package/agent/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +48 -4
  209. package/agent/apps/desktop/src/app/hooks/use-keybinds.ts +270 -0
  210. package/agent/apps/desktop/src/app/hooks/use-refresh-hotkey.ts +45 -0
  211. package/agent/apps/desktop/src/app/layout-constants.ts +19 -0
  212. package/agent/apps/desktop/src/app/messaging/index.tsx +136 -241
  213. package/agent/apps/desktop/src/app/messaging/platform-icon.tsx +95 -0
  214. package/agent/apps/desktop/src/app/model-visibility-overlay.tsx +31 -0
  215. package/agent/apps/desktop/src/app/overlays/overlay-search-input.tsx +18 -62
  216. package/agent/apps/desktop/src/app/overlays/overlay-split-layout.tsx +59 -7
  217. package/agent/apps/desktop/src/app/overlays/overlay-view.tsx +9 -5
  218. package/agent/apps/desktop/src/app/page-search-shell.tsx +42 -20
  219. package/agent/apps/desktop/src/app/profiles/create-profile-dialog.tsx +165 -0
  220. package/agent/apps/desktop/src/app/profiles/delete-profile-dialog.tsx +65 -0
  221. package/agent/apps/desktop/src/app/profiles/index.tsx +174 -199
  222. package/agent/apps/desktop/src/app/profiles/rename-profile-dialog.tsx +125 -0
  223. package/agent/apps/desktop/src/app/right-sidebar/files/dnd-manager.ts +27 -0
  224. package/agent/apps/desktop/src/app/right-sidebar/files/ipc.test.ts +100 -0
  225. package/agent/apps/desktop/src/app/right-sidebar/files/ipc.ts +12 -18
  226. package/agent/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +177 -0
  227. package/agent/apps/desktop/src/app/right-sidebar/files/tree.tsx +35 -21
  228. package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +75 -3
  229. package/agent/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +152 -5
  230. package/agent/apps/desktop/src/app/right-sidebar/index.test.tsx +75 -0
  231. package/agent/apps/desktop/src/app/right-sidebar/index.tsx +166 -129
  232. package/agent/apps/desktop/src/app/right-sidebar/store.ts +19 -4
  233. package/agent/apps/desktop/src/app/right-sidebar/terminal/buffer.ts +65 -0
  234. package/agent/apps/desktop/src/app/right-sidebar/terminal/index.tsx +29 -34
  235. package/agent/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +18 -6
  236. package/agent/apps/desktop/src/app/right-sidebar/terminal/selection.ts +93 -32
  237. package/agent/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +381 -119
  238. package/agent/apps/desktop/src/app/routes.ts +9 -0
  239. package/agent/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +17 -7
  240. package/agent/apps/desktop/src/app/session/hooks/use-message-stream.ts +365 -47
  241. package/agent/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +198 -0
  242. package/agent/apps/desktop/src/app/session/hooks/use-model-controls.ts +70 -34
  243. package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +1061 -0
  244. package/agent/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +1143 -165
  245. package/agent/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +341 -2
  246. package/agent/apps/desktop/src/app/session/hooks/use-route-resume.ts +176 -5
  247. package/agent/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +259 -0
  248. package/agent/apps/desktop/src/app/session/hooks/use-session-actions.ts +452 -149
  249. package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +327 -0
  250. package/agent/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +133 -4
  251. package/agent/apps/desktop/src/app/session-picker-overlay.tsx +32 -0
  252. package/agent/apps/desktop/src/app/session-switcher.tsx +107 -0
  253. package/agent/apps/desktop/src/app/settings/about-settings.tsx +45 -36
  254. package/agent/apps/desktop/src/app/settings/appearance-settings.tsx +243 -162
  255. package/agent/apps/desktop/src/app/settings/config-settings.tsx +86 -66
  256. package/agent/apps/desktop/src/app/settings/constants.ts +459 -122
  257. package/agent/apps/desktop/src/app/settings/credential-key-ui.tsx +373 -0
  258. package/agent/apps/desktop/src/app/settings/env-credentials.tsx +198 -0
  259. package/agent/apps/desktop/src/app/settings/env-var-actions-menu.tsx +136 -0
  260. package/agent/apps/desktop/src/app/settings/field-copy.ts +56 -0
  261. package/agent/apps/desktop/src/app/settings/gateway-settings.tsx +385 -72
  262. package/agent/apps/desktop/src/app/settings/helpers.test.ts +156 -1
  263. package/agent/apps/desktop/src/app/settings/helpers.ts +30 -2
  264. package/agent/apps/desktop/src/app/settings/index.tsx +118 -84
  265. package/agent/apps/desktop/src/app/settings/keys-settings.tsx +62 -419
  266. package/agent/apps/desktop/src/app/settings/mcp-settings.tsx +65 -60
  267. package/agent/apps/desktop/src/app/settings/model-settings.test.tsx +129 -5
  268. package/agent/apps/desktop/src/app/settings/model-settings.tsx +370 -65
  269. package/agent/apps/desktop/src/app/settings/notifications-settings.tsx +150 -0
  270. package/agent/apps/desktop/src/app/settings/primitives.tsx +5 -11
  271. package/agent/apps/desktop/src/app/settings/provider-config-panel.test.tsx +142 -0
  272. package/agent/apps/desktop/src/app/settings/provider-config-panel.tsx +182 -0
  273. package/agent/apps/desktop/src/app/settings/providers-settings.test.tsx +171 -0
  274. package/agent/apps/desktop/src/app/settings/providers-settings.tsx +471 -0
  275. package/agent/apps/desktop/src/app/settings/sessions-settings.tsx +183 -71
  276. package/agent/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +135 -1
  277. package/agent/apps/desktop/src/app/settings/toolset-config-panel.tsx +180 -57
  278. package/agent/apps/desktop/src/app/settings/types.ts +9 -6
  279. package/agent/apps/desktop/src/app/settings/uninstall-section.tsx +185 -0
  280. package/agent/apps/desktop/src/app/settings/use-deep-link-highlight.ts +60 -0
  281. package/agent/apps/desktop/src/app/shell/app-shell.tsx +59 -13
  282. package/agent/apps/desktop/src/app/shell/gateway-menu-panel.tsx +37 -32
  283. package/agent/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +6 -3
  284. package/agent/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +212 -53
  285. package/agent/apps/desktop/src/app/shell/keybind-panel.tsx +215 -0
  286. package/agent/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +84 -0
  287. package/agent/apps/desktop/src/app/shell/model-edit-submenu.tsx +244 -0
  288. package/agent/apps/desktop/src/app/shell/model-menu-panel.tsx +392 -0
  289. package/agent/apps/desktop/src/app/shell/statusbar-controls.tsx +23 -33
  290. package/agent/apps/desktop/src/app/shell/titlebar-controls.tsx +79 -95
  291. package/agent/apps/desktop/src/app/shell/titlebar.ts +8 -2
  292. package/agent/apps/desktop/src/app/skills/index.test.tsx +11 -0
  293. package/agent/apps/desktop/src/app/skills/index.tsx +79 -64
  294. package/agent/apps/desktop/src/app/types.ts +85 -0
  295. package/agent/apps/desktop/src/app/updates-overlay.tsx +110 -105
  296. package/agent/apps/desktop/src/components/assistant-ui/ansi-text.tsx +34 -0
  297. package/agent/apps/desktop/src/components/assistant-ui/block-direction.test.tsx +129 -0
  298. package/agent/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +102 -81
  299. package/agent/apps/desktop/src/components/assistant-ui/directive-text.tsx +92 -15
  300. package/agent/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +38 -0
  301. package/agent/apps/desktop/src/components/assistant-ui/markdown-text.tsx +304 -45
  302. package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
  303. package/agent/apps/desktop/src/components/assistant-ui/message-render-boundary.tsx +48 -0
  304. package/agent/apps/desktop/src/components/assistant-ui/streaming.test.tsx +142 -90
  305. package/agent/apps/desktop/src/components/assistant-ui/thread-list.tsx +337 -0
  306. package/agent/apps/desktop/src/components/assistant-ui/thread.tsx +667 -190
  307. package/agent/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +299 -0
  308. package/agent/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx +133 -0
  309. package/agent/apps/desktop/src/components/assistant-ui/tool-approval.tsx +239 -0
  310. package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +31 -0
  311. package/agent/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +152 -134
  312. package/agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +142 -150
  313. package/agent/apps/desktop/src/components/assistant-ui/tooltip-icon-button.tsx +14 -12
  314. package/agent/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
  315. package/agent/apps/desktop/src/components/assistant-ui/user-message-text.tsx +152 -0
  316. package/agent/apps/desktop/src/components/boot-failure-overlay.tsx +150 -33
  317. package/agent/apps/desktop/src/components/boot-failure-reauth.test.ts +100 -0
  318. package/agent/apps/desktop/src/components/boot-failure-reauth.ts +81 -0
  319. package/agent/apps/desktop/src/components/brand-mark.tsx +19 -0
  320. package/agent/apps/desktop/src/components/chat/code-card.tsx +1 -1
  321. package/agent/apps/desktop/src/components/chat/composer-dock.ts +31 -0
  322. package/agent/apps/desktop/src/components/chat/diff-lines.tsx +1 -1
  323. package/agent/apps/desktop/src/components/chat/disclosure-row.tsx +13 -3
  324. package/agent/apps/desktop/src/components/chat/expandable-block.tsx +52 -0
  325. package/agent/apps/desktop/src/components/chat/generated-image-result.tsx +174 -0
  326. package/agent/apps/desktop/src/components/chat/image-generation-placeholder.tsx +70 -37
  327. package/agent/apps/desktop/src/components/chat/intro.tsx +8 -7
  328. package/agent/apps/desktop/src/components/chat/preview-attachment.tsx +4 -2
  329. package/agent/apps/desktop/src/components/chat/shiki-highlighter.test.ts +37 -0
  330. package/agent/apps/desktop/src/components/chat/shiki-highlighter.tsx +96 -22
  331. package/agent/apps/desktop/src/components/chat/status-row.tsx +70 -0
  332. package/agent/apps/desktop/src/components/chat/status-section.tsx +42 -0
  333. package/agent/apps/desktop/src/components/chat/terminal-output.tsx +54 -0
  334. package/agent/apps/desktop/src/components/chat/zoomable-image.tsx +70 -109
  335. package/agent/apps/desktop/src/components/desktop-install-overlay.tsx +154 -84
  336. package/agent/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +38 -8
  337. package/agent/apps/desktop/src/components/desktop-onboarding-overlay.tsx +789 -233
  338. package/agent/apps/desktop/src/components/error-boundary.tsx +77 -0
  339. package/agent/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +144 -0
  340. package/agent/apps/desktop/src/components/gateway-connecting-overlay.tsx +7 -1
  341. package/agent/apps/desktop/src/components/haptics-provider.tsx +24 -0
  342. package/agent/apps/desktop/src/components/language-switcher.test.tsx +53 -0
  343. package/agent/apps/desktop/src/components/language-switcher.tsx +175 -0
  344. package/agent/apps/desktop/src/components/model-picker.tsx +42 -40
  345. package/agent/apps/desktop/src/components/model-visibility-dialog.tsx +166 -0
  346. package/agent/apps/desktop/src/components/notifications.tsx +48 -27
  347. package/agent/apps/desktop/src/components/pane-shell/index.ts +1 -1
  348. package/agent/apps/desktop/src/components/pane-shell/pane-shell.tsx +146 -9
  349. package/agent/apps/desktop/src/components/prompt-overlays.tsx +234 -0
  350. package/agent/apps/desktop/src/components/session-picker.tsx +108 -0
  351. package/agent/apps/desktop/src/components/ui/action-status.tsx +25 -0
  352. package/agent/apps/desktop/src/components/ui/badge.tsx +35 -0
  353. package/agent/apps/desktop/src/components/ui/button.tsx +37 -13
  354. package/agent/apps/desktop/src/components/ui/confirm-dialog.tsx +109 -0
  355. package/agent/apps/desktop/src/components/ui/control.ts +25 -0
  356. package/agent/apps/desktop/src/components/ui/copy-button.test.tsx +36 -0
  357. package/agent/apps/desktop/src/components/ui/copy-button.tsx +38 -27
  358. package/agent/apps/desktop/src/components/ui/dialog.tsx +39 -11
  359. package/agent/apps/desktop/src/components/ui/dropdown-menu.tsx +98 -24
  360. package/agent/apps/desktop/src/components/ui/error-state.tsx +50 -0
  361. package/agent/apps/desktop/src/components/ui/fade-text.tsx +9 -2
  362. package/agent/apps/desktop/src/components/ui/{braille-spinner.tsx → glyph-spinner.tsx} +15 -13
  363. package/agent/apps/desktop/src/components/ui/input.tsx +5 -2
  364. package/agent/apps/desktop/src/components/ui/kbd.tsx +83 -12
  365. package/agent/apps/desktop/src/components/ui/log-view.tsx +19 -0
  366. package/agent/apps/desktop/src/components/ui/pagination.tsx +12 -5
  367. package/agent/apps/desktop/src/components/ui/popover.tsx +44 -0
  368. package/agent/apps/desktop/src/components/ui/search-field.tsx +80 -0
  369. package/agent/apps/desktop/src/components/ui/segmented-control.tsx +51 -0
  370. package/agent/apps/desktop/src/components/ui/select.tsx +10 -3
  371. package/agent/apps/desktop/src/components/ui/sheet.tsx +8 -2
  372. package/agent/apps/desktop/src/components/ui/sidebar.tsx +18 -25
  373. package/agent/apps/desktop/src/components/ui/switch.tsx +38 -15
  374. package/agent/apps/desktop/src/components/ui/textarea.tsx +4 -11
  375. package/agent/apps/desktop/src/components/ui/tool-icon.tsx +65 -0
  376. package/agent/apps/desktop/src/components/ui/tooltip.tsx +31 -4
  377. package/agent/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
  378. package/agent/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
  379. package/agent/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
  380. package/agent/apps/desktop/src/global.d.ts +181 -4
  381. package/agent/apps/desktop/src/hermes.test.ts +60 -0
  382. package/agent/apps/desktop/src/hermes.ts +190 -13
  383. package/agent/apps/desktop/src/hooks/use-image-download.ts +85 -0
  384. package/agent/apps/desktop/src/hooks/use-resize-observer.ts +13 -4
  385. package/agent/apps/desktop/src/hooks/use-worktree-info.ts +68 -0
  386. package/agent/apps/desktop/src/i18n/catalog.ts +12 -0
  387. package/agent/apps/desktop/src/i18n/context.test.tsx +232 -0
  388. package/agent/apps/desktop/src/i18n/context.tsx +183 -0
  389. package/agent/apps/desktop/src/i18n/define-locale.ts +41 -0
  390. package/agent/apps/desktop/src/i18n/en.ts +1921 -0
  391. package/agent/apps/desktop/src/i18n/index.ts +20 -0
  392. package/agent/apps/desktop/src/i18n/ja.ts +2053 -0
  393. package/agent/apps/desktop/src/i18n/languages.test.ts +43 -0
  394. package/agent/apps/desktop/src/i18n/languages.ts +86 -0
  395. package/agent/apps/desktop/src/i18n/runtime.test.ts +75 -0
  396. package/agent/apps/desktop/src/i18n/runtime.ts +53 -0
  397. package/agent/apps/desktop/src/i18n/types.ts +1559 -0
  398. package/agent/apps/desktop/src/i18n/zh-hant.ts +1992 -0
  399. package/agent/apps/desktop/src/i18n/zh.ts +2099 -0
  400. package/agent/apps/desktop/src/lib/ansi.test.ts +123 -0
  401. package/agent/apps/desktop/src/lib/ansi.ts +186 -0
  402. package/agent/apps/desktop/src/lib/chat-messages.test.ts +79 -0
  403. package/agent/apps/desktop/src/lib/chat-messages.ts +68 -29
  404. package/agent/apps/desktop/src/lib/chat-runtime.test.ts +65 -1
  405. package/agent/apps/desktop/src/lib/chat-runtime.ts +39 -3
  406. package/agent/apps/desktop/src/lib/completion-sound.ts +519 -0
  407. package/agent/apps/desktop/src/lib/desktop-fs.test.ts +116 -0
  408. package/agent/apps/desktop/src/lib/desktop-fs.ts +113 -0
  409. package/agent/apps/desktop/src/lib/desktop-slash-commands.test.ts +89 -6
  410. package/agent/apps/desktop/src/lib/desktop-slash-commands.ts +270 -131
  411. package/agent/apps/desktop/src/lib/external-link.test.tsx +27 -0
  412. package/agent/apps/desktop/src/lib/external-link.tsx +9 -2
  413. package/agent/apps/desktop/src/lib/gateway-events.test.ts +27 -0
  414. package/agent/apps/desktop/src/lib/gateway-events.ts +16 -0
  415. package/agent/apps/desktop/src/lib/gateway-ws-url.test.ts +78 -0
  416. package/agent/apps/desktop/src/lib/gateway-ws-url.ts +91 -0
  417. package/agent/apps/desktop/src/lib/generated-images.test.ts +97 -0
  418. package/agent/apps/desktop/src/lib/generated-images.ts +116 -0
  419. package/agent/apps/desktop/src/lib/haptics.ts +17 -0
  420. package/agent/apps/desktop/src/lib/icons.ts +10 -2
  421. package/agent/apps/desktop/src/lib/keybinds/actions.ts +137 -0
  422. package/agent/apps/desktop/src/lib/keybinds/combo.test.ts +86 -0
  423. package/agent/apps/desktop/src/lib/keybinds/combo.ts +195 -0
  424. package/agent/apps/desktop/src/lib/local-preview.ts +23 -2
  425. package/agent/apps/desktop/src/lib/markdown-preprocess.ts +20 -7
  426. package/agent/apps/desktop/src/lib/media.remote.test.ts +90 -0
  427. package/agent/apps/desktop/src/lib/media.ts +40 -1
  428. package/agent/apps/desktop/src/lib/model-status-label.test.ts +59 -0
  429. package/agent/apps/desktop/src/lib/model-status-label.ts +122 -0
  430. package/agent/apps/desktop/src/lib/mutable-ref.ts +6 -0
  431. package/agent/apps/desktop/src/lib/profile-color.ts +58 -0
  432. package/agent/apps/desktop/src/lib/query-client.ts +13 -0
  433. package/agent/apps/desktop/src/lib/remend-tail.test.ts +105 -0
  434. package/agent/apps/desktop/src/lib/remend-tail.ts +108 -0
  435. package/agent/apps/desktop/src/lib/session-export.ts +6 -3
  436. package/agent/apps/desktop/src/lib/session-ids.test.ts +44 -0
  437. package/agent/apps/desktop/src/lib/session-ids.ts +26 -0
  438. package/agent/apps/desktop/src/lib/session-search.test.ts +66 -0
  439. package/agent/apps/desktop/src/lib/session-search.ts +21 -0
  440. package/agent/apps/desktop/src/lib/session-source.ts +126 -0
  441. package/agent/apps/desktop/src/lib/storage.test.ts +25 -0
  442. package/agent/apps/desktop/src/lib/storage.ts +35 -1
  443. package/agent/apps/desktop/src/lib/todos.test.ts +46 -1
  444. package/agent/apps/desktop/src/lib/todos.ts +37 -0
  445. package/agent/apps/desktop/src/lib/tool-result-summary.ts +5 -1
  446. package/agent/apps/desktop/src/lib/update-copy.test.ts +38 -0
  447. package/agent/apps/desktop/src/lib/update-copy.ts +44 -0
  448. package/agent/apps/desktop/src/lib/use-enter-animation.ts +2 -2
  449. package/agent/apps/desktop/src/lib/yolo-session.ts +50 -0
  450. package/agent/apps/desktop/src/main.tsx +19 -19
  451. package/agent/apps/desktop/src/store/boot.ts +4 -3
  452. package/agent/apps/desktop/src/store/clarify.test.ts +81 -0
  453. package/agent/apps/desktop/src/store/clarify.ts +50 -13
  454. package/agent/apps/desktop/src/store/command-palette.ts +20 -0
  455. package/agent/apps/desktop/src/store/compaction.test.ts +53 -0
  456. package/agent/apps/desktop/src/store/compaction.ts +38 -0
  457. package/agent/apps/desktop/src/store/completion-sound.ts +32 -0
  458. package/agent/apps/desktop/src/store/composer-input-history.test.ts +147 -0
  459. package/agent/apps/desktop/src/store/composer-input-history.ts +158 -0
  460. package/agent/apps/desktop/src/store/composer-queue.test.ts +68 -0
  461. package/agent/apps/desktop/src/store/composer-queue.ts +76 -0
  462. package/agent/apps/desktop/src/store/composer-status.test.ts +99 -0
  463. package/agent/apps/desktop/src/store/composer-status.ts +277 -0
  464. package/agent/apps/desktop/src/store/composer.test.ts +106 -0
  465. package/agent/apps/desktop/src/store/composer.ts +116 -0
  466. package/agent/apps/desktop/src/store/cron.ts +19 -0
  467. package/agent/apps/desktop/src/store/gateway.ts +280 -6
  468. package/agent/apps/desktop/src/store/keybinds.ts +143 -0
  469. package/agent/apps/desktop/src/store/layout.ts +107 -9
  470. package/agent/apps/desktop/src/store/model-presets.test.ts +51 -0
  471. package/agent/apps/desktop/src/store/model-presets.ts +86 -0
  472. package/agent/apps/desktop/src/store/model-visibility.test.ts +99 -0
  473. package/agent/apps/desktop/src/store/model-visibility.ts +161 -0
  474. package/agent/apps/desktop/src/store/native-notifications.test.ts +192 -0
  475. package/agent/apps/desktop/src/store/native-notifications.ts +203 -0
  476. package/agent/apps/desktop/src/store/notifications.ts +10 -7
  477. package/agent/apps/desktop/src/store/onboarding.test.ts +271 -1
  478. package/agent/apps/desktop/src/store/onboarding.ts +268 -38
  479. package/agent/apps/desktop/src/store/preview.ts +10 -1
  480. package/agent/apps/desktop/src/store/profile.test.ts +89 -0
  481. package/agent/apps/desktop/src/store/profile.ts +395 -0
  482. package/agent/apps/desktop/src/store/prompts.test.ts +127 -0
  483. package/agent/apps/desktop/src/store/prompts.ts +117 -0
  484. package/agent/apps/desktop/src/store/session-switcher.test.ts +115 -0
  485. package/agent/apps/desktop/src/store/session-switcher.ts +128 -0
  486. package/agent/apps/desktop/src/store/session-sync.ts +25 -0
  487. package/agent/apps/desktop/src/store/session.test.ts +268 -2
  488. package/agent/apps/desktop/src/store/session.ts +392 -18
  489. package/agent/apps/desktop/src/store/subagents.ts +3 -0
  490. package/agent/apps/desktop/src/store/system-actions.ts +48 -0
  491. package/agent/apps/desktop/src/store/thread-scroll.ts +58 -5
  492. package/agent/apps/desktop/src/store/todos.test.ts +47 -0
  493. package/agent/apps/desktop/src/store/todos.ts +64 -0
  494. package/agent/apps/desktop/src/store/tool-dismiss.ts +45 -0
  495. package/agent/apps/desktop/src/store/translucency.ts +38 -0
  496. package/agent/apps/desktop/src/store/updates.test.ts +187 -2
  497. package/agent/apps/desktop/src/store/updates.ts +268 -18
  498. package/agent/apps/desktop/src/store/windows.test.ts +143 -0
  499. package/agent/apps/desktop/src/store/windows.ts +115 -0
  500. package/agent/apps/desktop/src/styles.css +510 -119
  501. package/agent/apps/desktop/src/themes/color.ts +142 -0
  502. package/agent/apps/desktop/src/themes/context.tsx +128 -75
  503. package/agent/apps/desktop/src/themes/install.test.ts +119 -0
  504. package/agent/apps/desktop/src/themes/install.ts +95 -0
  505. package/agent/apps/desktop/src/themes/presets.test.ts +33 -0
  506. package/agent/apps/desktop/src/themes/presets.ts +13 -4
  507. package/agent/apps/desktop/src/themes/profile-theme.test.ts +41 -0
  508. package/agent/apps/desktop/src/themes/types.ts +35 -0
  509. package/agent/apps/desktop/src/themes/user-themes.test.ts +63 -0
  510. package/agent/apps/desktop/src/themes/user-themes.ts +122 -0
  511. package/agent/apps/desktop/src/themes/vscode.test.ts +171 -0
  512. package/agent/apps/desktop/src/themes/vscode.ts +343 -0
  513. package/agent/apps/desktop/src/types/hermes.ts +138 -1
  514. package/agent/apps/desktop/tsconfig.json +2 -2
  515. package/agent/apps/desktop/vite.config.ts +18 -0
  516. package/agent/apps/shared/package.json +1 -1
  517. package/agent/apps/shared/src/json-rpc-gateway.ts +63 -2
  518. package/agent/apps/shared/tsconfig.json +2 -2
  519. package/agent/cli-config.yaml.example +78 -1
  520. package/agent/cli.py +2177 -3162
  521. package/agent/cron/blueprint_catalog.py +713 -0
  522. package/agent/cron/jobs.py +226 -110
  523. package/agent/cron/scheduler.py +468 -193
  524. package/agent/cron/scheduler_provider.py +177 -0
  525. package/agent/cron/scripts/__init__.py +1 -0
  526. package/agent/cron/scripts/classify_items.py +226 -0
  527. package/agent/cron/suggestion_catalog.py +154 -0
  528. package/agent/cron/suggestions.py +257 -0
  529. package/agent/docs/chronos-managed-cron-contract.md +196 -0
  530. package/agent/docs/design/profile-builder.md +146 -0
  531. package/agent/docs/middleware/README.md +260 -0
  532. package/agent/docs/observability/README.md +316 -0
  533. package/agent/docs/plans/2026-06-09-003-fix-telegram-stream-overflow-continuations-plan.md +240 -0
  534. package/agent/docs/rca-ssl-cacert-post-git-pull.md +54 -0
  535. package/agent/docs/relay-connector-contract.md +285 -0
  536. package/agent/gateway/authz_mixin.py +536 -0
  537. package/agent/gateway/channel_directory.py +65 -3
  538. package/agent/gateway/config.py +222 -12
  539. package/agent/gateway/display_config.py +10 -0
  540. package/agent/gateway/hooks.py +17 -0
  541. package/agent/gateway/kanban_watchers.py +1146 -0
  542. package/agent/gateway/message_timestamps.py +166 -0
  543. package/agent/gateway/platforms/ADDING_A_PLATFORM.md +29 -0
  544. package/agent/gateway/platforms/api_server.py +216 -38
  545. package/agent/gateway/platforms/base.py +210 -58
  546. package/agent/gateway/platforms/email.py +122 -12
  547. package/agent/gateway/platforms/feishu.py +80 -11
  548. package/agent/gateway/platforms/feishu_meeting_invite.py +212 -0
  549. package/agent/gateway/platforms/matrix.py +1498 -297
  550. package/agent/gateway/platforms/qqbot/adapter.py +6 -0
  551. package/agent/gateway/platforms/signal.py +8 -0
  552. package/agent/gateway/platforms/slack.py +308 -12
  553. package/agent/gateway/platforms/telegram.py +831 -24
  554. package/agent/gateway/platforms/webhook.py +109 -21
  555. package/agent/gateway/platforms/weixin.py +113 -2
  556. package/agent/gateway/platforms/whatsapp.py +94 -288
  557. package/agent/gateway/platforms/whatsapp_cloud.py +1956 -0
  558. package/agent/gateway/platforms/whatsapp_common.py +367 -0
  559. package/agent/gateway/platforms/yuanbao.py +608 -191
  560. package/agent/gateway/platforms/yuanbao_proto.py +232 -23
  561. package/agent/gateway/relay/__init__.py +375 -0
  562. package/agent/gateway/relay/adapter.py +222 -0
  563. package/agent/gateway/relay/auth.py +168 -0
  564. package/agent/gateway/relay/descriptor.py +118 -0
  565. package/agent/gateway/relay/transport.py +101 -0
  566. package/agent/gateway/relay/ws_transport.py +327 -0
  567. package/agent/gateway/response_filters.py +53 -0
  568. package/agent/gateway/rich_sent_store.py +80 -0
  569. package/agent/gateway/run.py +2940 -5001
  570. package/agent/gateway/session.py +109 -8
  571. package/agent/gateway/session_context.py +22 -4
  572. package/agent/gateway/slash_commands.py +3854 -0
  573. package/agent/gateway/status.py +141 -21
  574. package/agent/gateway/stream_consumer.py +288 -31
  575. package/agent/hermes-already-has-routines.md +1 -1
  576. package/agent/hermes_cli/__init__.py +62 -17
  577. package/agent/hermes_cli/_parser.py +30 -0
  578. package/agent/hermes_cli/_subprocess_compat.py +61 -0
  579. package/agent/hermes_cli/active_sessions.py +320 -0
  580. package/agent/hermes_cli/auth.py +707 -59
  581. package/agent/hermes_cli/auth_commands.py +39 -22
  582. package/agent/hermes_cli/backup.py +109 -7
  583. package/agent/hermes_cli/banner.py +88 -0
  584. package/agent/hermes_cli/blueprint_cmd.py +318 -0
  585. package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
  586. package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
  587. package/agent/hermes_cli/commands.py +215 -91
  588. package/agent/hermes_cli/config.py +967 -130
  589. package/agent/hermes_cli/container_boot.py +76 -11
  590. package/agent/hermes_cli/cron.py +5 -11
  591. package/agent/hermes_cli/curator.py +21 -0
  592. package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
  593. package/agent/hermes_cli/dashboard_auth/base.py +62 -0
  594. package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
  595. package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
  596. package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
  597. package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
  598. package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
  599. package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
  600. package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
  601. package/agent/hermes_cli/dashboard_register.py +427 -0
  602. package/agent/hermes_cli/debug.py +155 -50
  603. package/agent/hermes_cli/doctor.py +255 -14
  604. package/agent/hermes_cli/dump.py +60 -6
  605. package/agent/hermes_cli/env_loader.py +33 -0
  606. package/agent/hermes_cli/gateway.py +755 -103
  607. package/agent/hermes_cli/gateway_enroll.py +250 -0
  608. package/agent/hermes_cli/gateway_windows.py +254 -11
  609. package/agent/hermes_cli/gui_uninstall.py +285 -0
  610. package/agent/hermes_cli/inventory.py +105 -4
  611. package/agent/hermes_cli/kanban.py +58 -71
  612. package/agent/hermes_cli/kanban_db.py +391 -14
  613. package/agent/hermes_cli/kanban_decompose.py +2 -2
  614. package/agent/hermes_cli/kanban_specify.py +3 -1
  615. package/agent/hermes_cli/logs.py +2 -0
  616. package/agent/hermes_cli/main.py +2889 -5287
  617. package/agent/hermes_cli/managed_scope.py +214 -0
  618. package/agent/hermes_cli/managed_uv.py +254 -0
  619. package/agent/hermes_cli/mcp_catalog.py +6 -3
  620. package/agent/hermes_cli/mcp_config.py +145 -21
  621. package/agent/hermes_cli/mcp_security.py +96 -0
  622. package/agent/hermes_cli/mcp_startup.py +32 -3
  623. package/agent/hermes_cli/memory_providers.py +149 -0
  624. package/agent/hermes_cli/memory_setup.py +97 -42
  625. package/agent/hermes_cli/middleware.py +313 -0
  626. package/agent/hermes_cli/model_catalog.py +31 -0
  627. package/agent/hermes_cli/model_cost_guard.py +134 -0
  628. package/agent/hermes_cli/model_normalize.py +2 -1
  629. package/agent/hermes_cli/model_setup_flows.py +2759 -0
  630. package/agent/hermes_cli/model_switch.py +242 -27
  631. package/agent/hermes_cli/models.py +284 -44
  632. package/agent/hermes_cli/nous_account.py +33 -6
  633. package/agent/hermes_cli/nous_billing.py +406 -0
  634. package/agent/hermes_cli/nous_subscription.py +202 -5
  635. package/agent/hermes_cli/platforms.py +1 -0
  636. package/agent/hermes_cli/plugins.py +218 -18
  637. package/agent/hermes_cli/plugins_cmd.py +249 -105
  638. package/agent/hermes_cli/portal_cli.py +56 -16
  639. package/agent/hermes_cli/profile_distribution.py +6 -1
  640. package/agent/hermes_cli/profiles.py +283 -32
  641. package/agent/hermes_cli/provider_catalog.py +170 -0
  642. package/agent/hermes_cli/providers.py +4 -1
  643. package/agent/hermes_cli/pty_bridge.py +53 -4
  644. package/agent/hermes_cli/runtime_provider.py +216 -34
  645. package/agent/hermes_cli/secret_prompt.py +4 -4
  646. package/agent/hermes_cli/secrets_cli.py +24 -0
  647. package/agent/hermes_cli/send_cmd.py +28 -2
  648. package/agent/hermes_cli/service_manager.py +166 -19
  649. package/agent/hermes_cli/session_listing.py +97 -0
  650. package/agent/hermes_cli/setup.py +158 -94
  651. package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
  652. package/agent/hermes_cli/skills_config.py +8 -2
  653. package/agent/hermes_cli/skills_hub.py +149 -7
  654. package/agent/hermes_cli/status.py +2 -2
  655. package/agent/hermes_cli/subcommands/__init__.py +18 -0
  656. package/agent/hermes_cli/subcommands/_shared.py +29 -0
  657. package/agent/hermes_cli/subcommands/acp.py +52 -0
  658. package/agent/hermes_cli/subcommands/auth.py +109 -0
  659. package/agent/hermes_cli/subcommands/backup.py +38 -0
  660. package/agent/hermes_cli/subcommands/claw.py +92 -0
  661. package/agent/hermes_cli/subcommands/config.py +49 -0
  662. package/agent/hermes_cli/subcommands/cron.py +163 -0
  663. package/agent/hermes_cli/subcommands/dashboard.py +143 -0
  664. package/agent/hermes_cli/subcommands/debug.py +77 -0
  665. package/agent/hermes_cli/subcommands/doctor.py +35 -0
  666. package/agent/hermes_cli/subcommands/dump.py +28 -0
  667. package/agent/hermes_cli/subcommands/gateway.py +332 -0
  668. package/agent/hermes_cli/subcommands/gui.py +63 -0
  669. package/agent/hermes_cli/subcommands/hooks.py +77 -0
  670. package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
  671. package/agent/hermes_cli/subcommands/insights.py +25 -0
  672. package/agent/hermes_cli/subcommands/login.py +78 -0
  673. package/agent/hermes_cli/subcommands/logout.py +28 -0
  674. package/agent/hermes_cli/subcommands/logs.py +78 -0
  675. package/agent/hermes_cli/subcommands/mcp.py +108 -0
  676. package/agent/hermes_cli/subcommands/memory.py +53 -0
  677. package/agent/hermes_cli/subcommands/model.py +72 -0
  678. package/agent/hermes_cli/subcommands/pairing.py +36 -0
  679. package/agent/hermes_cli/subcommands/plugins.py +94 -0
  680. package/agent/hermes_cli/subcommands/postinstall.py +23 -0
  681. package/agent/hermes_cli/subcommands/profile.py +203 -0
  682. package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
  683. package/agent/hermes_cli/subcommands/security.py +62 -0
  684. package/agent/hermes_cli/subcommands/setup.py +58 -0
  685. package/agent/hermes_cli/subcommands/skills.py +298 -0
  686. package/agent/hermes_cli/subcommands/slack.py +60 -0
  687. package/agent/hermes_cli/subcommands/status.py +28 -0
  688. package/agent/hermes_cli/subcommands/tools.py +95 -0
  689. package/agent/hermes_cli/subcommands/uninstall.py +41 -0
  690. package/agent/hermes_cli/subcommands/update.py +70 -0
  691. package/agent/hermes_cli/subcommands/version.py +18 -0
  692. package/agent/hermes_cli/subcommands/webhook.py +76 -0
  693. package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
  694. package/agent/hermes_cli/suggestions_cmd.py +153 -0
  695. package/agent/hermes_cli/telegram_managed_bot.py +358 -0
  696. package/agent/hermes_cli/tips.py +3 -4
  697. package/agent/hermes_cli/tools_config.py +155 -28
  698. package/agent/hermes_cli/uninstall.py +231 -35
  699. package/agent/hermes_cli/web_server.py +6188 -975
  700. package/agent/hermes_cli/win_pty_bridge.py +179 -0
  701. package/agent/hermes_cli/write_approval_commands.py +209 -0
  702. package/agent/hermes_constants.py +164 -33
  703. package/agent/hermes_logging.py +74 -2
  704. package/agent/hermes_state.py +919 -106
  705. package/agent/hermes_time.py +20 -0
  706. package/agent/locales/af.yaml +23 -0
  707. package/agent/locales/de.yaml +23 -0
  708. package/agent/locales/en.yaml +20 -0
  709. package/agent/locales/es.yaml +23 -0
  710. package/agent/locales/fr.yaml +23 -0
  711. package/agent/locales/ga.yaml +23 -0
  712. package/agent/locales/hu.yaml +23 -0
  713. package/agent/locales/it.yaml +23 -0
  714. package/agent/locales/ja.yaml +23 -0
  715. package/agent/locales/ko.yaml +23 -0
  716. package/agent/locales/pt.yaml +23 -0
  717. package/agent/locales/ru.yaml +23 -0
  718. package/agent/locales/tr.yaml +23 -0
  719. package/agent/locales/uk.yaml +23 -0
  720. package/agent/locales/zh-hant.yaml +23 -0
  721. package/agent/locales/zh.yaml +23 -0
  722. package/agent/model_tools.py +204 -40
  723. package/agent/optional-mcps/clawpump/manifest.yaml +4 -2
  724. package/agent/optional-mcps/clawpump-stdio/manifest.yaml +2 -0
  725. package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
  726. package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
  727. package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
  728. package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
  729. package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
  730. package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
  731. package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
  732. package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
  733. package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
  734. package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
  735. package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
  736. package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
  737. package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
  738. package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
  739. package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
  740. package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
  741. package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
  742. package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
  743. package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
  744. package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
  745. package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
  746. package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
  747. package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
  748. package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
  749. package/agent/optional-skills/security/1password/SKILL.md +1 -1
  750. package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
  751. package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
  752. package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
  753. package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
  754. package/agent/package-lock.json +4082 -7907
  755. package/agent/package.json +18 -3
  756. package/agent/plugins/browser/firecrawl/provider.py +4 -1
  757. package/agent/plugins/cron/__init__.py +344 -0
  758. package/agent/plugins/cron/chronos/__init__.py +241 -0
  759. package/agent/plugins/cron/chronos/_nas_client.py +123 -0
  760. package/agent/plugins/cron/chronos/plugin.yaml +9 -0
  761. package/agent/plugins/cron/chronos/verify.py +103 -0
  762. package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
  763. package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
  764. package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
  765. package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
  766. package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
  767. package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
  768. package/agent/plugins/google_meet/audio_bridge.py +4 -0
  769. package/agent/plugins/google_meet/meet_bot.py +7 -1
  770. package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
  771. package/agent/plugins/image_gen/fal/__init__.py +35 -6
  772. package/agent/plugins/image_gen/krea/__init__.py +56 -13
  773. package/agent/plugins/image_gen/openai/__init__.py +122 -24
  774. package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
  775. package/agent/plugins/image_gen/xai/__init__.py +92 -12
  776. package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
  777. package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
  778. package/agent/plugins/memory/__init__.py +48 -5
  779. package/agent/plugins/memory/byterover/__init__.py +1 -0
  780. package/agent/plugins/memory/hindsight/README.md +1 -1
  781. package/agent/plugins/memory/hindsight/__init__.py +138 -24
  782. package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
  783. package/agent/plugins/memory/honcho/README.md +13 -10
  784. package/agent/plugins/memory/honcho/cli.py +247 -122
  785. package/agent/plugins/memory/honcho/client.py +112 -102
  786. package/agent/plugins/memory/openviking/README.md +12 -1
  787. package/agent/plugins/memory/openviking/__init__.py +2281 -107
  788. package/agent/plugins/memory/openviking/plugin.yaml +1 -2
  789. package/agent/plugins/memory/supermemory/README.md +22 -10
  790. package/agent/plugins/memory/supermemory/__init__.py +142 -37
  791. package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
  792. package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
  793. package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
  794. package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
  795. package/agent/plugins/model-providers/custom/__init__.py +8 -2
  796. package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
  797. package/agent/plugins/model-providers/minimax/__init__.py +60 -8
  798. package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
  799. package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
  800. package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
  801. package/agent/plugins/model-providers/zai/__init__.py +1 -0
  802. package/agent/plugins/observability/langfuse/__init__.py +147 -14
  803. package/agent/plugins/observability/nemo_relay/README.md +559 -0
  804. package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
  805. package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
  806. package/agent/plugins/platforms/discord/adapter.py +932 -61
  807. package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
  808. package/agent/plugins/platforms/google_chat/adapter.py +9 -3
  809. package/agent/plugins/platforms/google_chat/oauth.py +1 -1
  810. package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
  811. package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
  812. package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
  813. package/agent/plugins/platforms/irc/adapter.py +4 -1
  814. package/agent/plugins/platforms/line/adapter.py +16 -1
  815. package/agent/plugins/platforms/mattermost/adapter.py +100 -24
  816. package/agent/plugins/platforms/photon/README.md +179 -0
  817. package/agent/plugins/platforms/photon/__init__.py +4 -0
  818. package/agent/plugins/platforms/photon/adapter.py +1586 -0
  819. package/agent/plugins/platforms/photon/auth.py +1046 -0
  820. package/agent/plugins/platforms/photon/cli.py +439 -0
  821. package/agent/plugins/platforms/photon/plugin.yaml +88 -0
  822. package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
  823. package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
  824. package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
  825. package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
  826. package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
  827. package/agent/plugins/platforms/raft/__init__.py +3 -0
  828. package/agent/plugins/platforms/raft/adapter.py +774 -0
  829. package/agent/plugins/platforms/raft/plugin.yaml +19 -0
  830. package/agent/plugins/platforms/simplex/adapter.py +777 -220
  831. package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
  832. package/agent/plugins/platforms/teams/adapter.py +175 -5
  833. package/agent/plugins/plugin_utils.py +135 -0
  834. package/agent/plugins/video_gen/fal/__init__.py +10 -3
  835. package/agent/plugins/web/searxng/provider.py +15 -2
  836. package/agent/plugins/web/xai/provider.py +2 -2
  837. package/agent/providers/base.py +22 -3
  838. package/agent/pyproject.toml +115 -21
  839. package/agent/run_agent.py +733 -39
  840. package/agent/scripts/build_skills_index.py +51 -19
  841. package/agent/scripts/check_subprocess_stdin.py +177 -0
  842. package/agent/scripts/contributor_audit.py +2 -0
  843. package/agent/scripts/docker_config_migrate.py +67 -0
  844. package/agent/scripts/install.cmd +3 -3
  845. package/agent/scripts/install.ps1 +580 -154
  846. package/agent/scripts/install.sh +402 -185
  847. package/agent/scripts/lib/node-bootstrap.sh +39 -4
  848. package/agent/scripts/release.py +183 -0
  849. package/agent/scripts/run_tests.sh +1 -0
  850. package/agent/scripts/run_tests_parallel.py +18 -23
  851. package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
  852. package/agent/setup.py +59 -0
  853. package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
  854. package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
  855. package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
  856. package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
  857. package/agent/skills/clawpump/SKILL.md +3 -1
  858. package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
  859. package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
  860. package/agent/skills/github/github-auth/SKILL.md +2 -2
  861. package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
  862. package/agent/skills/github/github-code-review/SKILL.md +2 -2
  863. package/agent/skills/github/github-issues/SKILL.md +2 -2
  864. package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
  865. package/agent/skills/github/github-repo-management/SKILL.md +2 -2
  866. package/agent/skills/media/gif-search/SKILL.md +1 -1
  867. package/agent/skills/media/youtube-content/SKILL.md +10 -7
  868. package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
  869. package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
  870. package/agent/skills/productivity/airtable/SKILL.md +2 -2
  871. package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
  872. package/agent/skills/productivity/notion/SKILL.md +2 -2
  873. package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
  874. package/agent/skills/research/llm-wiki/SKILL.md +1 -1
  875. package/agent/skills/social-media/xurl/SKILL.md +9 -0
  876. package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
  877. package/agent/skills/software-development/plan/SKILL.md +285 -5
  878. package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
  879. package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
  880. package/agent/skills/software-development/spike/SKILL.md +2 -2
  881. package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
  882. package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
  883. package/agent/tools/approval.py +302 -4
  884. package/agent/tools/async_delegation.py +386 -0
  885. package/agent/tools/blueprints.py +325 -0
  886. package/agent/tools/browser_cdp_tool.py +3 -3
  887. package/agent/tools/browser_tool.py +34 -6
  888. package/agent/tools/checkpoint_manager.py +31 -1
  889. package/agent/tools/clarify_tool.py +55 -5
  890. package/agent/tools/code_execution_tool.py +31 -14
  891. package/agent/tools/computer_use/cua_backend.py +81 -3
  892. package/agent/tools/computer_use/tool.py +79 -5
  893. package/agent/tools/computer_use/vision_routing.py +55 -3
  894. package/agent/tools/credential_files.py +31 -12
  895. package/agent/tools/cronjob_tools.py +30 -20
  896. package/agent/tools/delegate_tool.py +356 -31
  897. package/agent/tools/env_probe.py +1 -0
  898. package/agent/tools/environments/docker.py +163 -8
  899. package/agent/tools/environments/file_sync.py +2 -1
  900. package/agent/tools/environments/local.py +74 -23
  901. package/agent/tools/environments/singularity.py +4 -1
  902. package/agent/tools/environments/ssh.py +78 -11
  903. package/agent/tools/file_operations.py +277 -41
  904. package/agent/tools/file_tools.py +166 -28
  905. package/agent/tools/image_generation_tool.py +515 -29
  906. package/agent/tools/kanban_tools.py +99 -0
  907. package/agent/tools/lazy_deps.py +33 -2
  908. package/agent/tools/mcp_oauth.py +5 -5
  909. package/agent/tools/mcp_oauth_manager.py +7 -5
  910. package/agent/tools/mcp_tool.py +840 -33
  911. package/agent/tools/memory_tool.py +335 -38
  912. package/agent/tools/osv_check.py +15 -1
  913. package/agent/tools/process_registry.py +155 -11
  914. package/agent/tools/read_extract.py +248 -0
  915. package/agent/tools/read_terminal_tool.py +93 -0
  916. package/agent/tools/schema_sanitizer.py +38 -0
  917. package/agent/tools/send_message_tool.py +163 -49
  918. package/agent/tools/session_search_tool.py +189 -7
  919. package/agent/tools/skill_manager_tool.py +202 -3
  920. package/agent/tools/skill_usage.py +52 -4
  921. package/agent/tools/skills_hub.py +184 -44
  922. package/agent/tools/skills_sync.py +232 -5
  923. package/agent/tools/skills_tool.py +125 -11
  924. package/agent/tools/terminal_tool.py +148 -26
  925. package/agent/tools/tirith_security.py +2 -0
  926. package/agent/tools/todo_tool.py +32 -1
  927. package/agent/tools/transcription_tools.py +13 -5
  928. package/agent/tools/tts_tool.py +332 -38
  929. package/agent/tools/url_safety.py +52 -1
  930. package/agent/tools/vision_tools.py +124 -39
  931. package/agent/tools/voice_mode.py +4 -3
  932. package/agent/tools/web_tools.py +45 -15
  933. package/agent/tools/write_approval.py +493 -0
  934. package/agent/toolsets.py +34 -10
  935. package/agent/trajectory_compressor.py +81 -10
  936. package/agent/tui_gateway/entry.py +43 -6
  937. package/agent/tui_gateway/server.py +3335 -330
  938. package/agent/tui_gateway/slash_worker.py +61 -0
  939. package/agent/tui_gateway/ws.py +67 -9
  940. package/agent/ui-tui/eslint.config.mjs +0 -4
  941. package/agent/ui-tui/package.json +6 -6
  942. package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
  943. package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
  944. package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
  945. package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
  946. package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
  947. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
  948. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
  949. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
  950. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
  951. package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
  952. package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
  953. package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
  954. package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
  955. package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
  956. package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
  957. package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
  958. package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
  959. package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
  960. package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
  961. package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
  962. package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
  963. package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
  964. package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
  965. package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
  966. package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
  967. package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
  968. package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
  969. package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
  970. package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
  971. package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
  972. package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
  973. package/agent/ui-tui/src/app/interfaces.ts +64 -1
  974. package/agent/ui-tui/src/app/overlayStore.ts +18 -2
  975. package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
  976. package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
  977. package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
  978. package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
  979. package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
  980. package/agent/ui-tui/src/app/slash/registry.ts +4 -0
  981. package/agent/ui-tui/src/app/turnController.ts +145 -2
  982. package/agent/ui-tui/src/app/uiStore.ts +2 -0
  983. package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
  984. package/agent/ui-tui/src/app/useMainApp.ts +54 -8
  985. package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
  986. package/agent/ui-tui/src/app/useSubmission.ts +23 -31
  987. package/agent/ui-tui/src/components/appChrome.tsx +112 -5
  988. package/agent/ui-tui/src/components/appLayout.tsx +9 -0
  989. package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
  990. package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
  991. package/agent/ui-tui/src/components/branding.tsx +15 -3
  992. package/agent/ui-tui/src/components/messageLine.tsx +25 -3
  993. package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
  994. package/agent/ui-tui/src/components/prompts.tsx +31 -17
  995. package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
  996. package/agent/ui-tui/src/components/textInput.tsx +16 -0
  997. package/agent/ui-tui/src/config/env.ts +12 -0
  998. package/agent/ui-tui/src/config/limits.ts +13 -0
  999. package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
  1000. package/agent/ui-tui/src/domain/paths.ts +24 -0
  1001. package/agent/ui-tui/src/domain/slash.ts +40 -0
  1002. package/agent/ui-tui/src/entry.tsx +35 -4
  1003. package/agent/ui-tui/src/gatewayClient.ts +22 -10
  1004. package/agent/ui-tui/src/gatewayTypes.ts +130 -1
  1005. package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
  1006. package/agent/ui-tui/src/lib/memory.test.ts +162 -0
  1007. package/agent/ui-tui/src/lib/memory.ts +60 -1
  1008. package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
  1009. package/agent/ui-tui/src/lib/osc52.ts +1 -1
  1010. package/agent/ui-tui/src/lib/text.test.ts +32 -1
  1011. package/agent/ui-tui/src/lib/text.ts +29 -2
  1012. package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
  1013. package/agent/ui-tui/src/types.ts +5 -0
  1014. package/agent/ui-tui/tsconfig.build.json +0 -1
  1015. package/agent/ui-tui/tsconfig.json +2 -1
  1016. package/agent/utils.py +66 -2
  1017. package/agent/uv.lock +300 -684
  1018. package/agent/web/index.html +2 -2
  1019. package/agent/web/package.json +11 -6
  1020. package/agent/web/public/claw-bg.webp +0 -0
  1021. package/agent/web/public/claw-logo.webp +0 -0
  1022. package/agent/web/src/App.tsx +138 -48
  1023. package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
  1024. package/agent/web/src/components/Backdrop.tsx +15 -0
  1025. package/agent/web/src/components/ChatSessionList.tsx +260 -0
  1026. package/agent/web/src/components/ChatSidebar.tsx +262 -78
  1027. package/agent/web/src/components/ConfirmDialog.tsx +122 -0
  1028. package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
  1029. package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
  1030. package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
  1031. package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
  1032. package/agent/web/src/components/ReasoningPicker.tsx +167 -0
  1033. package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
  1034. package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
  1035. package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
  1036. package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
  1037. package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
  1038. package/agent/web/src/contexts/SystemActions.tsx +6 -8
  1039. package/agent/web/src/contexts/profile-context.ts +19 -0
  1040. package/agent/web/src/contexts/useProfileScope.ts +6 -0
  1041. package/agent/web/src/i18n/af.ts +5 -4
  1042. package/agent/web/src/i18n/de.ts +5 -4
  1043. package/agent/web/src/i18n/en.ts +58 -4
  1044. package/agent/web/src/i18n/es.ts +5 -3
  1045. package/agent/web/src/i18n/fr.ts +5 -3
  1046. package/agent/web/src/i18n/ga.ts +5 -4
  1047. package/agent/web/src/i18n/hu.ts +5 -4
  1048. package/agent/web/src/i18n/it.ts +5 -4
  1049. package/agent/web/src/i18n/ja.ts +5 -4
  1050. package/agent/web/src/i18n/ko.ts +5 -4
  1051. package/agent/web/src/i18n/pt.ts +5 -3
  1052. package/agent/web/src/i18n/ru.ts +5 -4
  1053. package/agent/web/src/i18n/tr.ts +5 -4
  1054. package/agent/web/src/i18n/types.ts +59 -1
  1055. package/agent/web/src/i18n/uk.ts +5 -3
  1056. package/agent/web/src/i18n/zh-hant.ts +5 -4
  1057. package/agent/web/src/i18n/zh.ts +5 -4
  1058. package/agent/web/src/index.css +2 -2
  1059. package/agent/web/src/lib/api.ts +819 -52
  1060. package/agent/web/src/lib/dashboard-flags.ts +16 -7
  1061. package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
  1062. package/agent/web/src/lib/reasoning-effort.ts +36 -0
  1063. package/agent/web/src/lib/session-refresh.test.ts +21 -0
  1064. package/agent/web/src/lib/session-refresh.ts +26 -0
  1065. package/agent/web/src/pages/ChannelsPage.tsx +529 -68
  1066. package/agent/web/src/pages/ChatPage.tsx +249 -56
  1067. package/agent/web/src/pages/ConfigPage.tsx +11 -1
  1068. package/agent/web/src/pages/CronPage.tsx +219 -31
  1069. package/agent/web/src/pages/EnvPage.tsx +25 -6
  1070. package/agent/web/src/pages/FilesPage.tsx +525 -0
  1071. package/agent/web/src/pages/McpPage.tsx +80 -3
  1072. package/agent/web/src/pages/ModelsPage.tsx +97 -12
  1073. package/agent/web/src/pages/PluginsPage.tsx +1 -1
  1074. package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
  1075. package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
  1076. package/agent/web/src/pages/SessionsPage.tsx +144 -13
  1077. package/agent/web/src/pages/SkillsPage.tsx +851 -70
  1078. package/agent/web/src/pages/SystemPage.tsx +340 -4
  1079. package/agent/web/src/pages/WalletPage.tsx +401 -0
  1080. package/agent/web/src/pages/WebhooksPage.tsx +145 -15
  1081. package/agent/web/src/pages/X402Page.tsx +207 -0
  1082. package/agent/web/src/plugins/registry.ts +28 -11
  1083. package/agent/web/src/plugins/sdk.d.ts +160 -0
  1084. package/agent/web/src/themes/context.tsx +112 -5
  1085. package/agent/web/src/themes/fonts.ts +167 -0
  1086. package/agent/web/src/themes/index.ts +7 -0
  1087. package/agent/web/tsconfig.app.json +0 -1
  1088. package/agent/web/vite.config.ts +1 -8
  1089. package/agent/web/vitest.config.ts +16 -0
  1090. package/package.json +1 -1
  1091. package/agent/apps/desktop/package-lock.json +0 -18363
  1092. package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
  1093. package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
  1094. package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
  1095. package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
  1096. package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
  1097. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
  1098. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
  1099. package/agent/skills/diagramming/DESCRIPTION.md +0 -3
  1100. package/agent/skills/domain/DESCRIPTION.md +0 -24
  1101. package/agent/skills/gifs/DESCRIPTION.md +0 -3
  1102. package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
  1103. package/agent/skills/mcp/DESCRIPTION.md +0 -3
  1104. package/agent/skills/media/spotify/SKILL.md +0 -135
  1105. package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
  1106. package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
  1107. package/agent/skills/productivity/linear/SKILL.md +0 -380
  1108. package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
  1109. package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
  1110. package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
  1111. package/agent/ui-tui/package-lock.json +0 -7449
  1112. package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
  1113. package/agent/web/package-lock.json +0 -8887
  1114. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
  1115. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
  1116. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
  1117. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
  1118. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
  1119. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
  1120. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
  1121. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
  1122. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
  1123. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
  1124. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
  1125. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
  1126. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
  1127. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
  1128. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
  1129. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
  1130. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
  1131. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
  1132. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
  1133. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
  1134. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
  1135. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
  1136. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
  1137. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
  1138. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
  1139. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
  1140. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
  1141. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
  1142. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
  1143. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
  1144. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
  1145. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
  1146. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
  1147. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
  1148. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
  1149. /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
  1150. /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
  1151. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
  1152. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
  1153. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
  1154. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
  1155. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
  1156. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
  1157. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
  1158. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
  1159. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
  1160. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
  1161. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
  1162. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
  1163. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
  1164. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
  1165. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
  1166. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
  1167. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
  1168. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
  1169. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
  1170. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
  1171. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
  1172. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
  1173. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
  1174. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
  1175. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
  1176. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
  1177. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
  1178. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
  1179. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
  1180. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
  1181. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
  1182. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
  1183. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
  1184. /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
  1185. /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
  1186. /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
  1187. /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
  1188. /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
  1189. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
  1190. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
  1191. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
  1192. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
  1193. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
  1194. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
  1195. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
  1196. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
  1197. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
  1198. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
  1199. /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
  1200. /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
  1201. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
  1202. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
  1203. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
  1204. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
  1205. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
  1206. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
  1207. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
  1208. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
  1209. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
  1210. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
  1211. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
  1212. /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 { fileURLToPath, pathToFileURL } = require('node:url')
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
- nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
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 path.resolve(process.env.HERMES_HOME)
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(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
930
- encoding: 'utf8',
931
- stdio: ['ignore', 'pipe', 'ignore']
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
- fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
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(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
1060
- cwd: options.cwd,
1061
- env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
1062
- stdio: ['ignore', 'pipe', 'pipe']
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 probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
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, ['--update'], {
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} --update; exiting desktop to release venv shim`)
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(command, args, {
1295
- cwd,
1296
- env: { ...process.env, ...(env || {}) },
1297
- stdio: ['ignore', 'pipe', 'pipe']
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(opts = {}) {
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
- const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
1377
- cwd: updateRoot,
1378
- env,
1379
- stage: 'rebuild'
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
- fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
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
- return path.join(APP_ROOT, 'dist')
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
- return candidates.find(fileExists) || candidates[0]
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
- PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
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
- PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
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 (rawUrl.startsWith('file:')) {
2221
- const filePath = fileURLToPath(rawUrl)
2222
- const buffer = await fs.promises.readFile(filePath)
2223
- return { buffer, mimeType: mimeTypeForPath(filePath) }
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
- const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw))
2304
- let resolved = filePath
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 filePath = fileURLToPath(String(rawUrl || ''))
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
- { role: 'about', label: `About ${APP_NAME}` },
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
- { role: 'resetZoom' },
2586
- { role: 'zoomIn' },
2587
- { role: 'zoomOut' },
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
- function normalizeRemoteBaseUrl(rawUrl) {
2773
- const value = String(rawUrl || '').trim()
2774
-
2775
- if (!value) {
2776
- throw new Error('Remote gateway URL is required.')
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
- let parsed
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
- parsed = new URL(value)
2782
- } catch (error) {
2783
- throw new Error(`Remote gateway URL is not valid: ${error.message}`)
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
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
2787
- throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
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
- parsed.hash = ''
2791
- parsed.search = ''
2792
- parsed.pathname = parsed.pathname.replace(/\/+$/, '')
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
- return parsed.toString().replace(/\/+$/, '')
2795
- }
3852
+ let settled = false
3853
+ let win = null
3854
+ let pollTimer = null
2796
3855
 
2797
- function buildGatewayWsUrl(baseUrl, token) {
2798
- const parsed = new URL(baseUrl)
2799
- const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
2800
- const prefix = parsed.pathname.replace(/\/+$/, '')
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
- return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
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
- function tokenPreview(value) {
2806
- const raw = String(value || '')
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
- if (!raw) {
2809
- return null
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
- return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
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 (connectionConfigCache) {
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: parsed.remote && typeof parsed.remote === 'object' ? parsed.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
- fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
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
- function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
2874
- const remoteToken = decryptDesktopSecret(config.remote?.token)
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
- return {
2877
- mode: config.mode === 'remote' ? 'remote' : 'local',
2878
- remoteUrl: String(config.remote?.url || ''),
2879
- remoteTokenPreview: tokenPreview(remoteToken),
2880
- remoteTokenSet: Boolean(remoteToken),
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
- function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
2886
- const persistToken = options.persistToken !== false
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
- if (mode === 'remote') {
2901
- nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
4167
+ function writeActiveDesktopProfile(name) {
4168
+ const value = typeof name === 'string' ? name.trim() : ''
2902
4169
 
2903
- if (!decryptDesktopSecret(nextRemote.token)) {
2904
- throw new Error('Remote gateway session token is required.')
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
- return { mode, remote: nextRemote }
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
- function resolveRemoteBackend() {
2914
- const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
2915
- const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
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
- if (rawEnvUrl) {
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
- const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
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
- return {
2928
- baseUrl,
2929
- mode: 'remote',
2930
- source: 'env',
2931
- token: rawEnvToken,
2932
- wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
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
- const config = readDesktopConnectionConfig()
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
- if (config.mode !== 'remote') {
2939
- return null
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
- const token = decryptDesktopSecret(config.remote?.token)
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: 'settings',
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 remote =
2965
- config.mode === 'remote'
2966
- ? {
2967
- baseUrl: normalizeRemoteBaseUrl(config.remote.url),
2968
- token: decryptDesktopSecret(config.remote.token)
2969
- }
2970
- : resolveRemoteBackend() || (await startHermes())
2971
- const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
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: remote.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
- const remote = resolveRemoteBackend()
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
- const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)]
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(backend.command, backend.args, {
3053
- cwd: hermesCwd,
3054
- env: {
3055
- ...process.env,
3056
- // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
3057
- // resolves to the SAME location our resolveHermesHome() picked. Without
3058
- // this pin, Python falls back to ~/.hermes on every platform — fine on
3059
- // mac/linux (where our default matches), but on Windows our default is
3060
- // %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
3061
- // Mismatch would split config / sessions / .env / logs across two
3062
- // directories. install.ps1 sets HERMES_HOME via setx; the desktop
3063
- // can't reliably do that, so we set it inline for every spawn.
3064
- HERMES_HOME,
3065
- ...backend.env,
3066
- HERMES_DASHBOARD_SESSION_TOKEN: token,
3067
- HERMES_DASHBOARD_TUI: '1',
3068
- HERMES_WEB_DIST: webDist
3069
- },
3070
- shell: backend.shell,
3071
- stdio: ['ignore', 'pipe', 'pipe']
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
- wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
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: 900,
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
- backgroundColor: '#f7f7f7',
3180
- webPreferences: {
3181
- preload: path.join(__dirname, 'preload.cjs'),
3182
- contextIsolation: true,
3183
- webviewTag: true,
3184
- sandbox: true,
3185
- nodeIntegration: false,
3186
- devTools: true
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
- nativeTheme.on('updated', () => {
3199
- mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
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
- installPreviewShortcut(mainWindow)
3209
- installDevToolsShortcut(mainWindow)
3210
- installContextMenu(mainWindow)
3211
- mainWindow.webContents.setWindowOpenHandler(details => {
3212
- openExternalUrl(details.url)
5214
+ wireCommonWindowHandlers(mainWindow)
3213
5215
 
3214
- return { action: 'deny' }
3215
- })
3216
- mainWindow.webContents.on('will-navigate', (event, url) => {
3217
- if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
3218
- return
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
- event.preventDefault()
3222
- openExternalUrl(url)
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 () => startHermes())
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 () => sanitizeDesktopConnectionConfig())
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
- resetHermesConnection()
3301
- setTimeout(() => mainWindow?.reload(), 150)
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 sanitizeDesktopConnectionConfig(config)
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
- const connection = await startHermes()
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
- return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
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
- new Notification({
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
- }).show()
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: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
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
- // Always-hidden noise (covers non-git projects too — gitignore would catch
3458
- // these anyway when present, but we want the same hygiene without one).
3459
- const FS_READDIR_HIDDEN = new Set([
3460
- '.git',
3461
- '.hg',
3462
- '.svn',
3463
- '.cache',
3464
- '.next',
3465
- '.turbo',
3466
- '.venv',
3467
- '__pycache__',
3468
- 'build',
3469
- 'dist',
3470
- 'node_modules',
3471
- 'target',
3472
- 'venv'
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
- function findGitRoot(start) {
3476
- let dir = start
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 (let i = 0; i < 50; i += 1) {
5896
+ for (const helper of candidates) {
3479
5897
  try {
3480
- if (fs.existsSync(path.join(dir, '.git'))) {
3481
- return dir
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
- return null
5904
+ // Not present in this layout (e.g. compiled build vs prebuild); skip.
3485
5905
  }
5906
+ }
5907
+ }
3486
5908
 
3487
- const parent = path.dirname(dir)
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
- if (parent === dir) {
3490
- return null
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
- dir = parent
5925
+ if (name.startsWith('pwsh') || name.startsWith('powershell')) {
5926
+ return { args: ['-NoLogo'], command: shellPath, name }
3494
5927
  }
3495
5928
 
3496
- return null
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 { args: [], command: process.env.COMSPEC || 'cmd.exe' }
5963
+ return windowsShellSpec()
3502
5964
  }
3503
5965
 
3504
- const configuredShell = process.env.SHELL || ''
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 { args: interactiveArgs, command: shellPath, name: shellName }
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
- try {
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
- return findGitRoot(start)
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 (BrowserWindow.getAllWindows().length === 0) createWindow()
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', () => {