@clawpump/claw-agent 0.1.4 → 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 (1214) 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 +2294 -3146
  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/clawpump_cli.py +3 -3
  586. package/agent/hermes_cli/cli_agent_setup_mixin.py +684 -0
  587. package/agent/hermes_cli/cli_commands_mixin.py +2293 -0
  588. package/agent/hermes_cli/commands.py +216 -91
  589. package/agent/hermes_cli/config.py +967 -130
  590. package/agent/hermes_cli/container_boot.py +76 -11
  591. package/agent/hermes_cli/cron.py +5 -11
  592. package/agent/hermes_cli/curator.py +21 -0
  593. package/agent/hermes_cli/dashboard_auth/__init__.py +2 -0
  594. package/agent/hermes_cli/dashboard_auth/base.py +62 -0
  595. package/agent/hermes_cli/dashboard_auth/cookies.py +32 -19
  596. package/agent/hermes_cli/dashboard_auth/login_page.py +156 -6
  597. package/agent/hermes_cli/dashboard_auth/middleware.py +28 -4
  598. package/agent/hermes_cli/dashboard_auth/prefix.py +46 -2
  599. package/agent/hermes_cli/dashboard_auth/public_paths.py +6 -0
  600. package/agent/hermes_cli/dashboard_auth/routes.py +158 -2
  601. package/agent/hermes_cli/dashboard_auth/ws_tickets.py +85 -11
  602. package/agent/hermes_cli/dashboard_register.py +427 -0
  603. package/agent/hermes_cli/debug.py +155 -50
  604. package/agent/hermes_cli/distribution.py +227 -0
  605. package/agent/hermes_cli/doctor.py +255 -14
  606. package/agent/hermes_cli/dump.py +60 -6
  607. package/agent/hermes_cli/env_loader.py +33 -0
  608. package/agent/hermes_cli/gateway.py +755 -103
  609. package/agent/hermes_cli/gateway_enroll.py +250 -0
  610. package/agent/hermes_cli/gateway_windows.py +254 -11
  611. package/agent/hermes_cli/gui_uninstall.py +285 -0
  612. package/agent/hermes_cli/inventory.py +105 -4
  613. package/agent/hermes_cli/kanban.py +58 -71
  614. package/agent/hermes_cli/kanban_db.py +391 -14
  615. package/agent/hermes_cli/kanban_decompose.py +2 -2
  616. package/agent/hermes_cli/kanban_specify.py +3 -1
  617. package/agent/hermes_cli/logs.py +2 -0
  618. package/agent/hermes_cli/main.py +2889 -5287
  619. package/agent/hermes_cli/managed_scope.py +214 -0
  620. package/agent/hermes_cli/managed_uv.py +254 -0
  621. package/agent/hermes_cli/mcp_catalog.py +6 -3
  622. package/agent/hermes_cli/mcp_config.py +145 -21
  623. package/agent/hermes_cli/mcp_security.py +96 -0
  624. package/agent/hermes_cli/mcp_startup.py +32 -3
  625. package/agent/hermes_cli/memory_providers.py +149 -0
  626. package/agent/hermes_cli/memory_setup.py +97 -42
  627. package/agent/hermes_cli/middleware.py +313 -0
  628. package/agent/hermes_cli/model_catalog.py +31 -0
  629. package/agent/hermes_cli/model_cost_guard.py +134 -0
  630. package/agent/hermes_cli/model_normalize.py +2 -1
  631. package/agent/hermes_cli/model_setup_flows.py +2759 -0
  632. package/agent/hermes_cli/model_switch.py +242 -27
  633. package/agent/hermes_cli/models.py +284 -44
  634. package/agent/hermes_cli/nous_account.py +33 -6
  635. package/agent/hermes_cli/nous_billing.py +406 -0
  636. package/agent/hermes_cli/nous_subscription.py +202 -5
  637. package/agent/hermes_cli/platforms.py +1 -0
  638. package/agent/hermes_cli/plugins.py +218 -18
  639. package/agent/hermes_cli/plugins_cmd.py +249 -105
  640. package/agent/hermes_cli/portal_cli.py +56 -16
  641. package/agent/hermes_cli/profile_distribution.py +6 -1
  642. package/agent/hermes_cli/profiles.py +283 -32
  643. package/agent/hermes_cli/provider_catalog.py +170 -0
  644. package/agent/hermes_cli/providers.py +4 -1
  645. package/agent/hermes_cli/pty_bridge.py +53 -4
  646. package/agent/hermes_cli/runtime_provider.py +216 -34
  647. package/agent/hermes_cli/secret_prompt.py +4 -4
  648. package/agent/hermes_cli/secrets_cli.py +24 -0
  649. package/agent/hermes_cli/send_cmd.py +28 -2
  650. package/agent/hermes_cli/service_manager.py +166 -19
  651. package/agent/hermes_cli/session_listing.py +97 -0
  652. package/agent/hermes_cli/setup.py +158 -94
  653. package/agent/hermes_cli/setup_whatsapp_cloud.py +541 -0
  654. package/agent/hermes_cli/skills_config.py +8 -2
  655. package/agent/hermes_cli/skills_hub.py +149 -7
  656. package/agent/hermes_cli/status.py +2 -2
  657. package/agent/hermes_cli/subcommands/__init__.py +18 -0
  658. package/agent/hermes_cli/subcommands/_shared.py +29 -0
  659. package/agent/hermes_cli/subcommands/acp.py +52 -0
  660. package/agent/hermes_cli/subcommands/auth.py +109 -0
  661. package/agent/hermes_cli/subcommands/backup.py +38 -0
  662. package/agent/hermes_cli/subcommands/claw.py +92 -0
  663. package/agent/hermes_cli/subcommands/config.py +49 -0
  664. package/agent/hermes_cli/subcommands/cron.py +163 -0
  665. package/agent/hermes_cli/subcommands/dashboard.py +143 -0
  666. package/agent/hermes_cli/subcommands/debug.py +77 -0
  667. package/agent/hermes_cli/subcommands/doctor.py +35 -0
  668. package/agent/hermes_cli/subcommands/dump.py +28 -0
  669. package/agent/hermes_cli/subcommands/gateway.py +332 -0
  670. package/agent/hermes_cli/subcommands/gui.py +63 -0
  671. package/agent/hermes_cli/subcommands/hooks.py +77 -0
  672. package/agent/hermes_cli/subcommands/import_cmd.py +31 -0
  673. package/agent/hermes_cli/subcommands/insights.py +25 -0
  674. package/agent/hermes_cli/subcommands/login.py +78 -0
  675. package/agent/hermes_cli/subcommands/logout.py +28 -0
  676. package/agent/hermes_cli/subcommands/logs.py +78 -0
  677. package/agent/hermes_cli/subcommands/mcp.py +108 -0
  678. package/agent/hermes_cli/subcommands/memory.py +53 -0
  679. package/agent/hermes_cli/subcommands/model.py +72 -0
  680. package/agent/hermes_cli/subcommands/pairing.py +36 -0
  681. package/agent/hermes_cli/subcommands/plugins.py +94 -0
  682. package/agent/hermes_cli/subcommands/postinstall.py +23 -0
  683. package/agent/hermes_cli/subcommands/profile.py +203 -0
  684. package/agent/hermes_cli/subcommands/prompt_size.py +36 -0
  685. package/agent/hermes_cli/subcommands/security.py +62 -0
  686. package/agent/hermes_cli/subcommands/setup.py +58 -0
  687. package/agent/hermes_cli/subcommands/skills.py +298 -0
  688. package/agent/hermes_cli/subcommands/slack.py +60 -0
  689. package/agent/hermes_cli/subcommands/status.py +28 -0
  690. package/agent/hermes_cli/subcommands/tools.py +95 -0
  691. package/agent/hermes_cli/subcommands/uninstall.py +41 -0
  692. package/agent/hermes_cli/subcommands/update.py +70 -0
  693. package/agent/hermes_cli/subcommands/version.py +18 -0
  694. package/agent/hermes_cli/subcommands/webhook.py +76 -0
  695. package/agent/hermes_cli/subcommands/whatsapp.py +22 -0
  696. package/agent/hermes_cli/suggestions_cmd.py +153 -0
  697. package/agent/hermes_cli/telegram_managed_bot.py +358 -0
  698. package/agent/hermes_cli/tips.py +3 -4
  699. package/agent/hermes_cli/tools_config.py +155 -28
  700. package/agent/hermes_cli/uninstall.py +231 -35
  701. package/agent/hermes_cli/web_server.py +6188 -975
  702. package/agent/hermes_cli/win_pty_bridge.py +179 -0
  703. package/agent/hermes_cli/write_approval_commands.py +209 -0
  704. package/agent/hermes_constants.py +164 -33
  705. package/agent/hermes_logging.py +74 -2
  706. package/agent/hermes_state.py +919 -106
  707. package/agent/hermes_time.py +20 -0
  708. package/agent/locales/af.yaml +23 -0
  709. package/agent/locales/de.yaml +23 -0
  710. package/agent/locales/en.yaml +20 -0
  711. package/agent/locales/es.yaml +23 -0
  712. package/agent/locales/fr.yaml +23 -0
  713. package/agent/locales/ga.yaml +23 -0
  714. package/agent/locales/hu.yaml +23 -0
  715. package/agent/locales/it.yaml +23 -0
  716. package/agent/locales/ja.yaml +23 -0
  717. package/agent/locales/ko.yaml +23 -0
  718. package/agent/locales/pt.yaml +23 -0
  719. package/agent/locales/ru.yaml +23 -0
  720. package/agent/locales/tr.yaml +23 -0
  721. package/agent/locales/uk.yaml +23 -0
  722. package/agent/locales/zh-hant.yaml +23 -0
  723. package/agent/locales/zh.yaml +23 -0
  724. package/agent/model_tools.py +204 -40
  725. package/agent/optional-mcps/clawpump/manifest.yaml +15 -5
  726. package/agent/optional-mcps/clawpump-stdio/manifest.yaml +14 -4
  727. package/agent/optional-mcps/unreal-engine/manifest.yaml +54 -0
  728. package/agent/optional-skills/blockchain/hyperliquid/SKILL.md +2 -2
  729. package/agent/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1 -1
  730. package/agent/optional-skills/creative/kanban-video-orchestrator/SKILL.md +1 -1
  731. package/agent/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +4 -3
  732. package/agent/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +6 -4
  733. package/agent/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +2 -2
  734. package/agent/{skills/software-development → optional-skills/devops}/hermes-s6-container-supervision/SKILL.md +2 -0
  735. package/agent/optional-skills/devops/watchers/SKILL.md +1 -1
  736. package/agent/optional-skills/devops/watchers/scripts/watch_github.py +2 -1
  737. package/agent/optional-skills/payments/mpp-agent/SKILL.md +124 -0
  738. package/agent/optional-skills/payments/stripe-link-cli/SKILL.md +184 -0
  739. package/agent/optional-skills/payments/stripe-projects/SKILL.md +120 -0
  740. package/agent/optional-skills/productivity/canvas/SKILL.md +1 -1
  741. package/agent/optional-skills/productivity/canvas/scripts/canvas_api.py +4 -1
  742. package/agent/optional-skills/productivity/shop/SKILL.md +224 -0
  743. package/agent/optional-skills/productivity/shop/references/catalog-mcp.md +236 -0
  744. package/agent/optional-skills/productivity/shop/references/direct-api.md +278 -0
  745. package/agent/optional-skills/productivity/shop/references/legal.md +3 -0
  746. package/agent/optional-skills/productivity/shop/references/safety.md +36 -0
  747. package/agent/optional-skills/productivity/shopify/SKILL.md +1 -1
  748. package/agent/optional-skills/productivity/siyuan/SKILL.md +1 -1
  749. package/agent/optional-skills/productivity/telephony/SKILL.md +4 -4
  750. package/agent/optional-skills/productivity/telephony/scripts/telephony.py +15 -15
  751. package/agent/optional-skills/security/1password/SKILL.md +1 -1
  752. package/agent/{skills/red-teaming → optional-skills/security}/godmode/SKILL.md +3 -4
  753. package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/auto_jailbreak.py +3 -1
  754. package/agent/optional-skills/software-development/rest-graphql-debug/SKILL.md +1 -1
  755. package/agent/{skills → optional-skills}/software-development/subagent-driven-development/SKILL.md +5 -5
  756. package/agent/package-lock.json +4082 -7907
  757. package/agent/package.json +18 -3
  758. package/agent/plugins/browser/firecrawl/provider.py +4 -1
  759. package/agent/plugins/cron/__init__.py +344 -0
  760. package/agent/plugins/cron/chronos/__init__.py +241 -0
  761. package/agent/plugins/cron/chronos/_nas_client.py +123 -0
  762. package/agent/plugins/cron/chronos/plugin.yaml +9 -0
  763. package/agent/plugins/cron/chronos/verify.py +103 -0
  764. package/agent/plugins/dashboard_auth/basic/__init__.py +491 -0
  765. package/agent/plugins/dashboard_auth/basic/plugin.yaml +7 -0
  766. package/agent/plugins/dashboard_auth/nous/__init__.py +12 -14
  767. package/agent/plugins/dashboard_auth/self_hosted/__init__.py +736 -0
  768. package/agent/plugins/dashboard_auth/self_hosted/plugin.yaml +8 -0
  769. package/agent/plugins/disk-cleanup/disk_cleanup.py +100 -20
  770. package/agent/plugins/google_meet/audio_bridge.py +4 -0
  771. package/agent/plugins/google_meet/meet_bot.py +7 -1
  772. package/agent/plugins/hermes-achievements/dashboard/dist/index.js +9 -15
  773. package/agent/plugins/image_gen/fal/__init__.py +35 -6
  774. package/agent/plugins/image_gen/krea/__init__.py +56 -13
  775. package/agent/plugins/image_gen/openai/__init__.py +122 -24
  776. package/agent/plugins/image_gen/openai-codex/__init__.py +28 -2
  777. package/agent/plugins/image_gen/xai/__init__.py +92 -12
  778. package/agent/plugins/kanban/dashboard/dist/index.js +63 -48
  779. package/agent/plugins/kanban/dashboard/plugin_api.py +39 -35
  780. package/agent/plugins/memory/__init__.py +48 -5
  781. package/agent/plugins/memory/byterover/__init__.py +1 -0
  782. package/agent/plugins/memory/hindsight/README.md +1 -1
  783. package/agent/plugins/memory/hindsight/__init__.py +138 -24
  784. package/agent/plugins/memory/hindsight/plugin.yaml +1 -1
  785. package/agent/plugins/memory/honcho/README.md +13 -10
  786. package/agent/plugins/memory/honcho/cli.py +247 -122
  787. package/agent/plugins/memory/honcho/client.py +112 -102
  788. package/agent/plugins/memory/openviking/README.md +12 -1
  789. package/agent/plugins/memory/openviking/__init__.py +2281 -107
  790. package/agent/plugins/memory/openviking/plugin.yaml +1 -2
  791. package/agent/plugins/memory/supermemory/README.md +22 -10
  792. package/agent/plugins/memory/supermemory/__init__.py +142 -37
  793. package/agent/plugins/memory/supermemory/plugin.yaml +1 -1
  794. package/agent/plugins/model-providers/anthropic/__init__.py +1 -0
  795. package/agent/plugins/model-providers/bedrock/__init__.py +1 -0
  796. package/agent/plugins/model-providers/copilot-acp/__init__.py +1 -0
  797. package/agent/plugins/model-providers/custom/__init__.py +8 -2
  798. package/agent/plugins/model-providers/kimi-coding/__init__.py +16 -7
  799. package/agent/plugins/model-providers/minimax/__init__.py +60 -8
  800. package/agent/plugins/model-providers/opencode-zen/__init__.py +12 -3
  801. package/agent/plugins/model-providers/openrouter/__init__.py +75 -4
  802. package/agent/plugins/model-providers/xiaomi/__init__.py +2 -0
  803. package/agent/plugins/model-providers/zai/__init__.py +1 -0
  804. package/agent/plugins/observability/langfuse/__init__.py +147 -14
  805. package/agent/plugins/observability/nemo_relay/README.md +559 -0
  806. package/agent/plugins/observability/nemo_relay/__init__.py +962 -0
  807. package/agent/plugins/observability/nemo_relay/plugin.yaml +20 -0
  808. package/agent/plugins/platforms/discord/adapter.py +932 -61
  809. package/agent/plugins/platforms/discord/voice_mixer.py +379 -0
  810. package/agent/plugins/platforms/google_chat/adapter.py +9 -3
  811. package/agent/plugins/platforms/google_chat/oauth.py +1 -1
  812. package/agent/plugins/platforms/homeassistant/__init__.py +3 -0
  813. package/agent/{gateway/platforms/homeassistant.py → plugins/platforms/homeassistant/adapter.py} +128 -0
  814. package/agent/plugins/platforms/homeassistant/plugin.yaml +22 -0
  815. package/agent/plugins/platforms/irc/adapter.py +4 -1
  816. package/agent/plugins/platforms/line/adapter.py +16 -1
  817. package/agent/plugins/platforms/mattermost/adapter.py +100 -24
  818. package/agent/plugins/platforms/photon/README.md +179 -0
  819. package/agent/plugins/platforms/photon/__init__.py +4 -0
  820. package/agent/plugins/platforms/photon/adapter.py +1586 -0
  821. package/agent/plugins/platforms/photon/auth.py +1046 -0
  822. package/agent/plugins/platforms/photon/cli.py +439 -0
  823. package/agent/plugins/platforms/photon/plugin.yaml +88 -0
  824. package/agent/plugins/platforms/photon/sidecar/README.md +52 -0
  825. package/agent/plugins/platforms/photon/sidecar/index.mjs +720 -0
  826. package/agent/plugins/platforms/photon/sidecar/package-lock.json +1730 -0
  827. package/agent/plugins/platforms/photon/sidecar/package.json +25 -0
  828. package/agent/plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs +155 -0
  829. package/agent/plugins/platforms/raft/__init__.py +3 -0
  830. package/agent/plugins/platforms/raft/adapter.py +774 -0
  831. package/agent/plugins/platforms/raft/plugin.yaml +19 -0
  832. package/agent/plugins/platforms/simplex/adapter.py +777 -220
  833. package/agent/plugins/platforms/simplex/plugin.yaml +21 -2
  834. package/agent/plugins/platforms/teams/adapter.py +175 -5
  835. package/agent/plugins/plugin_utils.py +135 -0
  836. package/agent/plugins/video_gen/fal/__init__.py +10 -3
  837. package/agent/plugins/web/searxng/provider.py +15 -2
  838. package/agent/plugins/web/xai/provider.py +2 -2
  839. package/agent/providers/base.py +22 -3
  840. package/agent/pyproject.toml +115 -21
  841. package/agent/run_agent.py +733 -39
  842. package/agent/scripts/build_skills_index.py +51 -19
  843. package/agent/scripts/check_subprocess_stdin.py +177 -0
  844. package/agent/scripts/contributor_audit.py +2 -0
  845. package/agent/scripts/docker_config_migrate.py +67 -0
  846. package/agent/scripts/install.cmd +3 -3
  847. package/agent/scripts/install.ps1 +580 -154
  848. package/agent/scripts/install.sh +402 -185
  849. package/agent/scripts/lib/node-bootstrap.sh +39 -4
  850. package/agent/scripts/release.py +183 -0
  851. package/agent/scripts/run_tests.sh +1 -0
  852. package/agent/scripts/run_tests_parallel.py +18 -23
  853. package/agent/scripts/whatsapp-bridge/bridge.js +25 -4
  854. package/agent/setup.py +59 -0
  855. package/agent/skills/autonomous-ai-agents/codex/SKILL.md +19 -0
  856. package/agent/skills/autonomous-ai-agents/hermes-agent/SKILL.md +10 -3
  857. package/agent/skills/{mcp/native-mcp/SKILL.md → autonomous-ai-agents/hermes-agent/references/native-mcp.md} +0 -13
  858. package/agent/skills/{devops/webhook-subscriptions/SKILL.md → autonomous-ai-agents/hermes-agent/references/webhooks.md} +1 -11
  859. package/agent/skills/clawpump/SKILL.md +53 -5
  860. package/agent/skills/devops/kanban-orchestrator/SKILL.md +1 -0
  861. package/agent/skills/devops/kanban-worker/SKILL.md +1 -0
  862. package/agent/skills/github/github-auth/SKILL.md +2 -2
  863. package/agent/skills/github/github-auth/scripts/gh-env.sh +2 -2
  864. package/agent/skills/github/github-code-review/SKILL.md +2 -2
  865. package/agent/skills/github/github-issues/SKILL.md +2 -2
  866. package/agent/skills/github/github-pr-workflow/SKILL.md +2 -2
  867. package/agent/skills/github/github-repo-management/SKILL.md +2 -2
  868. package/agent/skills/media/gif-search/SKILL.md +1 -1
  869. package/agent/skills/media/youtube-content/SKILL.md +10 -7
  870. package/agent/skills/media/youtube-content/scripts/fetch_transcript.py +3 -3
  871. package/agent/skills/note-taking/obsidian/SKILL.md +1 -1
  872. package/agent/skills/productivity/airtable/SKILL.md +2 -2
  873. package/agent/skills/productivity/google-workspace/scripts/setup.py +33 -7
  874. package/agent/skills/productivity/notion/SKILL.md +2 -2
  875. package/agent/skills/productivity/teams-meeting-pipeline/SKILL.md +1 -1
  876. package/agent/skills/research/llm-wiki/SKILL.md +1 -1
  877. package/agent/skills/social-media/xurl/SKILL.md +9 -0
  878. package/agent/skills/software-development/hermes-agent-skill-authoring/SKILL.md +1 -1
  879. package/agent/skills/software-development/plan/SKILL.md +285 -5
  880. package/agent/skills/software-development/requesting-code-review/SKILL.md +2 -2
  881. package/agent/skills/software-development/simplify-code/SKILL.md +212 -0
  882. package/agent/skills/software-development/spike/SKILL.md +2 -2
  883. package/agent/skills/software-development/systematic-debugging/SKILL.md +1 -1
  884. package/agent/skills/software-development/test-driven-development/SKILL.md +1 -1
  885. package/agent/tools/approval.py +302 -4
  886. package/agent/tools/async_delegation.py +386 -0
  887. package/agent/tools/blueprints.py +325 -0
  888. package/agent/tools/browser_cdp_tool.py +3 -3
  889. package/agent/tools/browser_tool.py +34 -6
  890. package/agent/tools/checkpoint_manager.py +31 -1
  891. package/agent/tools/clarify_tool.py +55 -5
  892. package/agent/tools/code_execution_tool.py +31 -14
  893. package/agent/tools/computer_use/cua_backend.py +81 -3
  894. package/agent/tools/computer_use/tool.py +79 -5
  895. package/agent/tools/computer_use/vision_routing.py +55 -3
  896. package/agent/tools/credential_files.py +31 -12
  897. package/agent/tools/cronjob_tools.py +30 -20
  898. package/agent/tools/delegate_tool.py +356 -31
  899. package/agent/tools/env_probe.py +1 -0
  900. package/agent/tools/environments/docker.py +163 -8
  901. package/agent/tools/environments/file_sync.py +2 -1
  902. package/agent/tools/environments/local.py +74 -23
  903. package/agent/tools/environments/singularity.py +4 -1
  904. package/agent/tools/environments/ssh.py +78 -11
  905. package/agent/tools/file_operations.py +277 -41
  906. package/agent/tools/file_tools.py +166 -28
  907. package/agent/tools/image_generation_tool.py +515 -29
  908. package/agent/tools/kanban_tools.py +99 -0
  909. package/agent/tools/lazy_deps.py +33 -2
  910. package/agent/tools/mcp_oauth.py +5 -5
  911. package/agent/tools/mcp_oauth_manager.py +7 -5
  912. package/agent/tools/mcp_tool.py +840 -33
  913. package/agent/tools/memory_tool.py +335 -38
  914. package/agent/tools/osv_check.py +15 -1
  915. package/agent/tools/process_registry.py +155 -11
  916. package/agent/tools/read_extract.py +248 -0
  917. package/agent/tools/read_terminal_tool.py +93 -0
  918. package/agent/tools/schema_sanitizer.py +38 -0
  919. package/agent/tools/send_message_tool.py +163 -49
  920. package/agent/tools/session_search_tool.py +189 -7
  921. package/agent/tools/skill_manager_tool.py +202 -3
  922. package/agent/tools/skill_usage.py +52 -4
  923. package/agent/tools/skills_hub.py +184 -44
  924. package/agent/tools/skills_sync.py +232 -5
  925. package/agent/tools/skills_tool.py +125 -11
  926. package/agent/tools/terminal_tool.py +148 -26
  927. package/agent/tools/tirith_security.py +2 -0
  928. package/agent/tools/todo_tool.py +32 -1
  929. package/agent/tools/transcription_tools.py +13 -5
  930. package/agent/tools/tts_tool.py +332 -38
  931. package/agent/tools/url_safety.py +52 -1
  932. package/agent/tools/vision_tools.py +124 -39
  933. package/agent/tools/voice_mode.py +4 -3
  934. package/agent/tools/web_tools.py +45 -15
  935. package/agent/tools/write_approval.py +493 -0
  936. package/agent/toolsets.py +34 -10
  937. package/agent/trajectory_compressor.py +81 -10
  938. package/agent/tui_gateway/entry.py +43 -6
  939. package/agent/tui_gateway/server.py +3335 -330
  940. package/agent/tui_gateway/slash_worker.py +61 -0
  941. package/agent/tui_gateway/ws.py +67 -9
  942. package/agent/ui-tui/eslint.config.mjs +0 -4
  943. package/agent/ui-tui/package.json +6 -6
  944. package/agent/ui-tui/packages/hermes-ink/package.json +1 -1
  945. package/agent/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts +34 -1
  946. package/agent/ui-tui/packages/hermes-ink/src/ink/app-rawmode-mouse.test.ts +91 -0
  947. package/agent/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +35 -2
  948. package/agent/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +4 -11
  949. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +23 -57
  950. package/agent/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +11 -135
  951. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +185 -0
  952. package/agent/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +37 -3
  953. package/agent/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts +5 -5
  954. package/agent/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +217 -0
  955. package/agent/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx +73 -0
  956. package/agent/ui-tui/src/__tests__/approvalAction.test.ts +11 -0
  957. package/agent/ui-tui/src/__tests__/billingCommand.test.ts +301 -0
  958. package/agent/ui-tui/src/__tests__/blockLayout.test.ts +122 -0
  959. package/agent/ui-tui/src/__tests__/brandingMcpCount.test.ts +111 -0
  960. package/agent/ui-tui/src/__tests__/completionApply.test.ts +51 -0
  961. package/agent/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +487 -2
  962. package/agent/ui-tui/src/__tests__/createSlashHandler.test.ts +54 -0
  963. package/agent/ui-tui/src/__tests__/creditsCommand.test.ts +144 -0
  964. package/agent/ui-tui/src/__tests__/gatewayClient.test.ts +120 -99
  965. package/agent/ui-tui/src/__tests__/gracefulExit.test.ts +11 -0
  966. package/agent/ui-tui/src/__tests__/memoryMonitor.test.ts +102 -0
  967. package/agent/ui-tui/src/__tests__/paths.test.ts +41 -1
  968. package/agent/ui-tui/src/__tests__/terminalModes.test.ts +22 -0
  969. package/agent/ui-tui/src/__tests__/text.test.ts +23 -0
  970. package/agent/ui-tui/src/__tests__/textInputFastEcho.test.ts +37 -0
  971. package/agent/ui-tui/src/__tests__/turnControllerNotice.test.ts +43 -0
  972. package/agent/ui-tui/src/__tests__/useInputHandlers.test.ts +38 -1
  973. package/agent/ui-tui/src/__tests__/virtualHeights.test.ts +8 -0
  974. package/agent/ui-tui/src/app/createGatewayEventHandler.ts +102 -7
  975. package/agent/ui-tui/src/app/interfaces.ts +64 -1
  976. package/agent/ui-tui/src/app/overlayStore.ts +18 -2
  977. package/agent/ui-tui/src/app/slash/commands/billing.ts +332 -0
  978. package/agent/ui-tui/src/app/slash/commands/core.ts +31 -2
  979. package/agent/ui-tui/src/app/slash/commands/credits.ts +57 -0
  980. package/agent/ui-tui/src/app/slash/commands/ops.ts +28 -0
  981. package/agent/ui-tui/src/app/slash/commands/session.ts +32 -4
  982. package/agent/ui-tui/src/app/slash/registry.ts +4 -0
  983. package/agent/ui-tui/src/app/turnController.ts +145 -2
  984. package/agent/ui-tui/src/app/uiStore.ts +2 -0
  985. package/agent/ui-tui/src/app/useInputHandlers.ts +42 -4
  986. package/agent/ui-tui/src/app/useMainApp.ts +54 -8
  987. package/agent/ui-tui/src/app/useSessionLifecycle.ts +40 -31
  988. package/agent/ui-tui/src/app/useSubmission.ts +23 -31
  989. package/agent/ui-tui/src/components/appChrome.tsx +112 -5
  990. package/agent/ui-tui/src/components/appLayout.tsx +9 -0
  991. package/agent/ui-tui/src/components/appOverlays.tsx +25 -1
  992. package/agent/ui-tui/src/components/billingOverlay.tsx +684 -0
  993. package/agent/ui-tui/src/components/branding.tsx +15 -3
  994. package/agent/ui-tui/src/components/messageLine.tsx +25 -3
  995. package/agent/ui-tui/src/components/pluginsHub.tsx +238 -0
  996. package/agent/ui-tui/src/components/prompts.tsx +31 -17
  997. package/agent/ui-tui/src/components/streamingAssistant.tsx +63 -55
  998. package/agent/ui-tui/src/components/textInput.tsx +16 -0
  999. package/agent/ui-tui/src/config/env.ts +12 -0
  1000. package/agent/ui-tui/src/config/limits.ts +13 -0
  1001. package/agent/ui-tui/src/domain/blockLayout.ts +146 -0
  1002. package/agent/ui-tui/src/domain/paths.ts +24 -0
  1003. package/agent/ui-tui/src/domain/slash.ts +40 -0
  1004. package/agent/ui-tui/src/entry.tsx +35 -4
  1005. package/agent/ui-tui/src/gatewayClient.ts +22 -10
  1006. package/agent/ui-tui/src/gatewayTypes.ts +130 -1
  1007. package/agent/ui-tui/src/lib/gracefulExit.ts +24 -4
  1008. package/agent/ui-tui/src/lib/memory.test.ts +162 -0
  1009. package/agent/ui-tui/src/lib/memory.ts +60 -1
  1010. package/agent/ui-tui/src/lib/memoryMonitor.ts +79 -4
  1011. package/agent/ui-tui/src/lib/osc52.ts +1 -1
  1012. package/agent/ui-tui/src/lib/text.test.ts +32 -1
  1013. package/agent/ui-tui/src/lib/text.ts +29 -2
  1014. package/agent/ui-tui/src/lib/virtualHeights.ts +13 -0
  1015. package/agent/ui-tui/src/types.ts +5 -0
  1016. package/agent/ui-tui/tsconfig.build.json +0 -1
  1017. package/agent/ui-tui/tsconfig.json +2 -1
  1018. package/agent/utils.py +66 -2
  1019. package/agent/uv.lock +308 -696
  1020. package/agent/web/index.html +2 -2
  1021. package/agent/web/package.json +11 -6
  1022. package/agent/web/public/claw-bg.webp +0 -0
  1023. package/agent/web/public/claw-logo.webp +0 -0
  1024. package/agent/web/src/App.tsx +138 -48
  1025. package/agent/web/src/components/AutomationBlueprints.tsx +225 -0
  1026. package/agent/web/src/components/Backdrop.tsx +15 -0
  1027. package/agent/web/src/components/ChatSessionList.tsx +260 -0
  1028. package/agent/web/src/components/ChatSidebar.tsx +262 -78
  1029. package/agent/web/src/components/ConfirmDialog.tsx +122 -0
  1030. package/agent/web/src/components/ModelPickerDialog.tsx +111 -16
  1031. package/agent/web/src/components/ModelReloadConfirm.tsx +40 -0
  1032. package/agent/web/src/components/ProfileScopeBanner.tsx +30 -0
  1033. package/agent/web/src/components/ProfileSwitcher.tsx +67 -0
  1034. package/agent/web/src/components/ReasoningPicker.tsx +167 -0
  1035. package/agent/web/src/components/SkillEditorDialog.tsx +215 -0
  1036. package/agent/web/src/components/ThemeSwitcher.tsx +119 -4
  1037. package/agent/web/src/components/ToolsetConfigDrawer.tsx +457 -0
  1038. package/agent/web/src/contexts/PageHeaderProvider.tsx +7 -4
  1039. package/agent/web/src/contexts/ProfileProvider.tsx +137 -0
  1040. package/agent/web/src/contexts/SystemActions.tsx +6 -8
  1041. package/agent/web/src/contexts/profile-context.ts +19 -0
  1042. package/agent/web/src/contexts/useProfileScope.ts +6 -0
  1043. package/agent/web/src/i18n/af.ts +5 -4
  1044. package/agent/web/src/i18n/de.ts +5 -4
  1045. package/agent/web/src/i18n/en.ts +58 -4
  1046. package/agent/web/src/i18n/es.ts +5 -3
  1047. package/agent/web/src/i18n/fr.ts +5 -3
  1048. package/agent/web/src/i18n/ga.ts +5 -4
  1049. package/agent/web/src/i18n/hu.ts +5 -4
  1050. package/agent/web/src/i18n/it.ts +5 -4
  1051. package/agent/web/src/i18n/ja.ts +5 -4
  1052. package/agent/web/src/i18n/ko.ts +5 -4
  1053. package/agent/web/src/i18n/pt.ts +5 -3
  1054. package/agent/web/src/i18n/ru.ts +5 -4
  1055. package/agent/web/src/i18n/tr.ts +5 -4
  1056. package/agent/web/src/i18n/types.ts +59 -1
  1057. package/agent/web/src/i18n/uk.ts +5 -3
  1058. package/agent/web/src/i18n/zh-hant.ts +5 -4
  1059. package/agent/web/src/i18n/zh.ts +5 -4
  1060. package/agent/web/src/index.css +2 -2
  1061. package/agent/web/src/lib/api.ts +819 -52
  1062. package/agent/web/src/lib/dashboard-flags.ts +16 -7
  1063. package/agent/web/src/lib/reasoning-effort.test.ts +48 -0
  1064. package/agent/web/src/lib/reasoning-effort.ts +36 -0
  1065. package/agent/web/src/lib/session-refresh.test.ts +21 -0
  1066. package/agent/web/src/lib/session-refresh.ts +26 -0
  1067. package/agent/web/src/pages/ChannelsPage.tsx +529 -68
  1068. package/agent/web/src/pages/ChatPage.tsx +249 -56
  1069. package/agent/web/src/pages/ConfigPage.tsx +11 -1
  1070. package/agent/web/src/pages/CronPage.tsx +219 -31
  1071. package/agent/web/src/pages/EnvPage.tsx +25 -6
  1072. package/agent/web/src/pages/FilesPage.tsx +525 -0
  1073. package/agent/web/src/pages/McpPage.tsx +80 -3
  1074. package/agent/web/src/pages/ModelsPage.tsx +97 -12
  1075. package/agent/web/src/pages/PluginsPage.tsx +1 -1
  1076. package/agent/web/src/pages/ProfileBuilderPage.tsx +611 -0
  1077. package/agent/web/src/pages/ProfilesPage.tsx +1038 -172
  1078. package/agent/web/src/pages/SessionsPage.tsx +144 -13
  1079. package/agent/web/src/pages/SkillsPage.tsx +851 -70
  1080. package/agent/web/src/pages/SystemPage.tsx +340 -4
  1081. package/agent/web/src/pages/WalletPage.tsx +401 -0
  1082. package/agent/web/src/pages/WebhooksPage.tsx +145 -15
  1083. package/agent/web/src/pages/X402Page.tsx +207 -0
  1084. package/agent/web/src/plugins/registry.ts +28 -11
  1085. package/agent/web/src/plugins/sdk.d.ts +160 -0
  1086. package/agent/web/src/themes/context.tsx +112 -5
  1087. package/agent/web/src/themes/fonts.ts +167 -0
  1088. package/agent/web/src/themes/index.ts +7 -0
  1089. package/agent/web/tsconfig.app.json +0 -1
  1090. package/agent/web/vite.config.ts +1 -8
  1091. package/agent/web/vitest.config.ts +16 -0
  1092. package/package.json +1 -1
  1093. package/agent/apps/desktop/package-lock.json +0 -18363
  1094. package/agent/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +0 -56
  1095. package/agent/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +0 -382
  1096. package/agent/apps/desktop/src/components/assistant-ui/todo-tool.tsx +0 -109
  1097. package/agent/apps/desktop/src/components/chat/generated-image-context.tsx +0 -19
  1098. package/agent/optional-skills/productivity/shop-app/SKILL.md +0 -340
  1099. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/SKILL.md +0 -277
  1100. package/agent/skills/autonomous-ai-agents/kanban-codex-lane/templates/pmb-codex-lane-prompt.md +0 -57
  1101. package/agent/skills/diagramming/DESCRIPTION.md +0 -3
  1102. package/agent/skills/domain/DESCRIPTION.md +0 -24
  1103. package/agent/skills/gifs/DESCRIPTION.md +0 -3
  1104. package/agent/skills/inference-sh/DESCRIPTION.md +0 -19
  1105. package/agent/skills/mcp/DESCRIPTION.md +0 -3
  1106. package/agent/skills/media/spotify/SKILL.md +0 -135
  1107. package/agent/skills/mlops/training/DESCRIPTION.md +0 -3
  1108. package/agent/skills/mlops/vector-databases/DESCRIPTION.md +0 -3
  1109. package/agent/skills/productivity/linear/SKILL.md +0 -380
  1110. package/agent/skills/productivity/linear/scripts/linear_api.py +0 -445
  1111. package/agent/skills/software-development/debugging-hermes-tui-commands/SKILL.md +0 -152
  1112. package/agent/skills/software-development/writing-plans/SKILL.md +0 -297
  1113. package/agent/ui-tui/package-lock.json +0 -7449
  1114. package/agent/ui-tui/packages/hermes-ink/package-lock.json +0 -1289
  1115. package/agent/web/package-lock.json +0 -8887
  1116. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/PORT_NOTES.md +0 -0
  1117. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/SKILL.md +0 -0
  1118. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/prompts/system.md +0 -0
  1119. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/macaron.md +0 -0
  1120. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/mono-ink.md +0 -0
  1121. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/neon.md +0 -0
  1122. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/palettes/warm.md +0 -0
  1123. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/prompt-construction.md +0 -0
  1124. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/style-presets.md +0 -0
  1125. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/blueprint.md +0 -0
  1126. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/chalkboard.md +0 -0
  1127. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/editorial.md +0 -0
  1128. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/elegant.md +0 -0
  1129. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/fantasy-animation.md +0 -0
  1130. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat-doodle.md +0 -0
  1131. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/flat.md +0 -0
  1132. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/ink-notes.md +0 -0
  1133. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/intuition-machine.md +0 -0
  1134. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/minimal.md +0 -0
  1135. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/nature.md +0 -0
  1136. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/notion.md +0 -0
  1137. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/pixel-art.md +0 -0
  1138. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/playful.md +0 -0
  1139. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/retro.md +0 -0
  1140. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/scientific.md +0 -0
  1141. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/screen-print.md +0 -0
  1142. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch-notes.md +0 -0
  1143. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/sketch.md +0 -0
  1144. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vector-illustration.md +0 -0
  1145. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/vintage.md +0 -0
  1146. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/warm.md +0 -0
  1147. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles/watercolor.md +0 -0
  1148. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/styles.md +0 -0
  1149. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/usage.md +0 -0
  1150. /package/agent/{skills → optional-skills}/creative/baoyu-article-illustrator/references/workflow.md +0 -0
  1151. /package/agent/{skills → optional-skills}/creative/baoyu-comic/PORT_NOTES.md +0 -0
  1152. /package/agent/{skills → optional-skills}/creative/baoyu-comic/SKILL.md +0 -0
  1153. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/analysis-framework.md +0 -0
  1154. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/chalk.md +0 -0
  1155. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ink-brush.md +0 -0
  1156. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/ligne-claire.md +0 -0
  1157. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/manga.md +0 -0
  1158. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/minimalist.md +0 -0
  1159. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/art-styles/realistic.md +0 -0
  1160. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/auto-selection.md +0 -0
  1161. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/base-prompt.md +0 -0
  1162. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/character-template.md +0 -0
  1163. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/cinematic.md +0 -0
  1164. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/dense.md +0 -0
  1165. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/four-panel.md +0 -0
  1166. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/mixed.md +0 -0
  1167. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/splash.md +0 -0
  1168. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/standard.md +0 -0
  1169. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/layouts/webtoon.md +0 -0
  1170. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/ohmsha-guide.md +0 -0
  1171. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/partial-workflows.md +0 -0
  1172. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/concept-story.md +0 -0
  1173. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/four-panel.md +0 -0
  1174. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/ohmsha.md +0 -0
  1175. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/shoujo.md +0 -0
  1176. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/presets/wuxia.md +0 -0
  1177. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/storyboard-template.md +0 -0
  1178. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/action.md +0 -0
  1179. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/dramatic.md +0 -0
  1180. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/energetic.md +0 -0
  1181. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/neutral.md +0 -0
  1182. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/romantic.md +0 -0
  1183. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/vintage.md +0 -0
  1184. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/tones/warm.md +0 -0
  1185. /package/agent/{skills → optional-skills}/creative/baoyu-comic/references/workflow.md +0 -0
  1186. /package/agent/{skills → optional-skills}/creative/creative-ideation/SKILL.md +0 -0
  1187. /package/agent/{skills → optional-skills}/creative/creative-ideation/references/full-prompt-library.md +0 -0
  1188. /package/agent/{skills → optional-skills}/creative/pixel-art/ATTRIBUTION.md +0 -0
  1189. /package/agent/{skills → optional-skills}/creative/pixel-art/SKILL.md +0 -0
  1190. /package/agent/{skills → optional-skills}/creative/pixel-art/references/palettes.md +0 -0
  1191. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/__init__.py +0 -0
  1192. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/palettes.py +0 -0
  1193. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art.py +0 -0
  1194. /package/agent/{skills → optional-skills}/creative/pixel-art/scripts/pixel_art_video.py +0 -0
  1195. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/SKILL.md +0 -0
  1196. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/analysis-modules.md +0 -0
  1197. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/references/methods-guide.md +0 -0
  1198. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/abliteration-config.yaml +0 -0
  1199. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/analysis-study.yaml +0 -0
  1200. /package/agent/{skills/mlops/inference → optional-skills/mlops}/obliteratus/templates/batch-abliteration.yaml +0 -0
  1201. /package/agent/{skills → optional-skills}/mlops/research/DESCRIPTION.md +0 -0
  1202. /package/agent/{skills → optional-skills}/mlops/research/dspy/SKILL.md +0 -0
  1203. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/examples.md +0 -0
  1204. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/modules.md +0 -0
  1205. /package/agent/{skills → optional-skills}/mlops/research/dspy/references/optimizers.md +0 -0
  1206. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/jailbreak-templates.md +0 -0
  1207. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/references/refusal-detection.md +0 -0
  1208. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/godmode_race.py +0 -0
  1209. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/load_godmode.py +0 -0
  1210. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/scripts/parseltongue.py +0 -0
  1211. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill-subtle.json +0 -0
  1212. /package/agent/{skills/red-teaming → optional-skills/security}/godmode/templates/prefill.json +0 -0
  1213. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/context-budget-discipline.md +0 -0
  1214. /package/agent/{skills → optional-skills}/software-development/subagent-driven-development/references/gates-taxonomy.md +0 -0
@@ -1,5 +1,6 @@
1
1
  import atexit
2
2
  import concurrent.futures
3
+ import contextlib
3
4
  import contextvars
4
5
  import copy
5
6
  import inspect
@@ -16,7 +17,12 @@ from datetime import datetime
16
17
  from pathlib import Path
17
18
  from typing import Any, Optional
18
19
 
19
- from hermes_constants import get_hermes_home
20
+ from hermes_constants import (
21
+ get_hermes_home,
22
+ get_hermes_home_override,
23
+ reset_hermes_home_override,
24
+ set_hermes_home_override,
25
+ )
20
26
  from hermes_cli.env_loader import load_hermes_dotenv
21
27
  from utils import is_truthy_value
22
28
  from tui_gateway.transport import (
@@ -125,14 +131,35 @@ _db = None
125
131
  _db_error: str | None = None
126
132
  _stdout_lock = threading.Lock()
127
133
  _cfg_lock = threading.Lock()
134
+ _sessions_lock = threading.RLock() # reentrant: _close_session_by_id may run under callers that already hold it
135
+ _prompt_lock = threading.Lock()
128
136
  _cfg_cache: dict | None = None
129
137
  _cfg_mtime: float | None = None
130
138
  _cfg_path = None
139
+ _session_resume_lock = threading.Lock()
131
140
  try:
132
141
  _slash_timeout = float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S") or "45")
133
142
  except (ValueError, TypeError):
134
143
  _slash_timeout = 45.0
135
144
  _SLASH_WORKER_TIMEOUT_S = max(5.0, _slash_timeout)
145
+
146
+ # When a WebSocket client (the dashboard's embedded-chat tab / desktop app)
147
+ # disconnects, ``tui_gateway.ws`` detaches the transport but intentionally
148
+ # leaves the session parked so a quick reconnect can reattach it (see ws.py).
149
+ # That park is unbounded, though: a browser refresh spins up a brand-new
150
+ # ``session.create`` (new sid + a fresh _SlashWorker via _deferred_build) and
151
+ # never reattaches the OLD sid, so the old session's slash-worker subprocess
152
+ # lingers forever — one leaked python process per refresh (#38591 fallout).
153
+ # After this grace window, an orphaned (transport-detached, not-running) WS
154
+ # session is reaped: its _SlashWorker is closed and the session finalized.
155
+ # Set to 0 to disable (park forever, pre-fix behaviour).
156
+ try:
157
+ _ws_orphan_reap_grace = float(
158
+ os.environ.get("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S") or "20"
159
+ )
160
+ except (ValueError, TypeError):
161
+ _ws_orphan_reap_grace = 20.0
162
+ _WS_ORPHAN_REAP_GRACE_S = max(0.0, _ws_orphan_reap_grace)
136
163
  _DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
137
164
  _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
138
165
 
@@ -147,8 +174,10 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
147
174
  # response writes are safe.
148
175
  _LONG_HANDLERS = frozenset(
149
176
  {
177
+ "billing.step_up",
150
178
  "browser.manage",
151
179
  "cli.exec",
180
+ "plugins.manage",
152
181
  "session.branch",
153
182
  "session.compress",
154
183
  "session.resume",
@@ -176,11 +205,27 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
176
205
  _real_stdout = sys.stdout
177
206
  sys.stdout = sys.stderr
178
207
 
208
+
209
+ class _DropTransport:
210
+ """Detached WS sink: keep sessions resumable without writing stale frames."""
211
+
212
+ def write(self, obj: dict) -> bool:
213
+ return False
214
+
215
+ def close(self) -> None:
216
+ return None
217
+
218
+
179
219
  # Module-level stdio transport — fallback sink when no transport is bound via
180
220
  # contextvar or session. Stream resolved through a lambda so runtime monkey-
181
221
  # patches of `_real_stdout` (used extensively in tests) still land correctly.
182
222
  _stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock)
183
223
 
224
+ # Detached websocket sessions use a drop sink instead of stdio. Desktop embeds
225
+ # the gateway in-process and captures stdout into logs, so stale JSON-RPC frames
226
+ # must not fall through there while the session waits for resume or reap.
227
+ _detached_ws_transport = _DropTransport()
228
+
184
229
 
185
230
  class _SlashWorker:
186
231
  """Persistent HermesCLI subprocess for slash commands."""
@@ -201,6 +246,7 @@ class _SlashWorker:
201
246
  if model:
202
247
  argv += ["--model", model]
203
248
 
249
+ self._closed = False
204
250
  self.proc = subprocess.Popen(
205
251
  argv,
206
252
  stdin=subprocess.PIPE,
@@ -255,15 +301,33 @@ class _SlashWorker:
255
301
  )
256
302
 
257
303
  def close(self):
304
+ if getattr(self, "_closed", False):
305
+ return
306
+ self._closed = True
307
+ proc = self.proc
258
308
  try:
259
- if self.proc.poll() is None:
260
- self.proc.terminate()
261
- self.proc.wait(timeout=1)
309
+ if proc.poll() is None:
310
+ proc.terminate()
311
+ try:
312
+ proc.wait(timeout=1)
313
+ except Exception:
314
+ proc.kill()
315
+ try:
316
+ proc.wait(timeout=1) # reap the zombie SIGKILL leaves behind
317
+ except Exception:
318
+ pass
262
319
  except Exception:
263
320
  try:
264
- self.proc.kill()
321
+ proc.kill()
322
+ proc.wait(timeout=1)
265
323
  except Exception:
266
324
  pass
325
+ finally:
326
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
327
+ try:
328
+ stream.close()
329
+ except Exception:
330
+ pass
267
331
 
268
332
 
269
333
  def _load_busy_input_mode() -> str:
@@ -284,11 +348,44 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
284
348
  pass
285
349
 
286
350
 
351
+ def _claim_active_session_slot(
352
+ session_key: str,
353
+ *,
354
+ live_session_id: str,
355
+ surface: str = "tui",
356
+ ) -> tuple[Any, str | None]:
357
+ try:
358
+ from hermes_cli.active_sessions import try_acquire_active_session
359
+
360
+ return try_acquire_active_session(
361
+ session_id=session_key,
362
+ surface=surface,
363
+ config=_load_cfg(),
364
+ metadata={"live_session_id": live_session_id},
365
+ )
366
+ except Exception as exc:
367
+ logger.warning("Failed to claim active session slot: %s", exc)
368
+ return None, None
369
+
370
+
371
+ def _release_active_session_slot(session: dict | None) -> None:
372
+ if not session:
373
+ return
374
+ lease = session.pop("active_session_lease", None)
375
+ if lease is None:
376
+ return
377
+ try:
378
+ lease.release()
379
+ except Exception:
380
+ logger.debug("Failed to release active session slot", exc_info=True)
381
+
382
+
287
383
  def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None:
288
384
  """Best-effort finalize hook + memory commit for a session."""
289
385
  if not session or session.get("_finalized"):
290
386
  return
291
387
  session["_finalized"] = True
388
+ _release_active_session_slot(session)
292
389
  stop_event = session.get("_notif_stop")
293
390
  if stop_event is not None:
294
391
  stop_event.set()
@@ -322,19 +419,226 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
322
419
  except Exception:
323
420
  pass
324
421
 
422
+ # Close the slash-worker subprocess as part of finalize itself, not just
423
+ # in the callers. Defense-in-depth: every session-end path goes through
424
+ # _finalize_session (it's the single ``_finalized``-guarded chokepoint), so
425
+ # folding worker cleanup in here means a future code path that calls
426
+ # _finalize_session directly — without the surrounding _teardown_session /
427
+ # _shutdown_sessions worker.close() — can't reintroduce the #38095 leak.
428
+ # Idempotent: _SlashWorker.close() is poll()-guarded, so the explicit
429
+ # close() still in those callers is harmless.
430
+ try:
431
+ worker = session.get("slash_worker")
432
+ if worker:
433
+ worker.close()
434
+ except Exception:
435
+ pass
436
+
437
+
438
+ def _teardown_session(session: dict | None, *, end_reason: str = "tui_close") -> None:
439
+ """Fully tear down a session: finalize, unregister, close agent + worker.
440
+
441
+ Shared by ``session.close`` and the orphaned-WS-session reaper. The
442
+ slash-worker subprocess is closed inside ``_finalize_session`` (the single
443
+ finalize chokepoint); this still unregisters the approval notifier and
444
+ closes the in-process agent. Idempotent: the ``_finalized`` guard in
445
+ ``_finalize_session`` and the ``poll()`` guard in ``_SlashWorker.close``
446
+ make repeat calls harmless.
447
+ """
448
+ if not session:
449
+ return
450
+ _finalize_session(session, end_reason=end_reason)
451
+ try:
452
+ from tools.approval import unregister_gateway_notify
453
+
454
+ if key := session.get("session_key"):
455
+ unregister_gateway_notify(key)
456
+ except Exception:
457
+ pass
458
+ try:
459
+ agent = session.get("agent")
460
+ if agent is not None and hasattr(agent, "close"):
461
+ agent.close()
462
+ except Exception:
463
+ pass
464
+ # NOTE: the slash-worker is closed inside _finalize_session (the single
465
+ # _finalized-guarded chokepoint that main folded it into), exactly once.
466
+ # We deliberately do NOT re-close it here — _teardown_session's job beyond
467
+ # finalize is unregistering the notifier and closing the in-process agent.
468
+
469
+
470
+ def _attach_worker(sid: str, session: dict, worker) -> None:
471
+ """Store worker on session iff sid still maps to it, else close it — a
472
+ concurrent teardown already popped the session and would orphan the
473
+ worker. Closes the create/close race at every slash-worker spawn site."""
474
+ with _sessions_lock:
475
+ if _sessions.get(sid) is session:
476
+ session["slash_worker"] = worker
477
+ return
478
+ worker.close()
479
+
480
+
481
+ def _close_session_by_id(sid: str, *, end_reason: str = "tui_close") -> bool:
482
+ """Single idempotent teardown for one session: pop it under the sessions
483
+ lock, then finalize, unregister notify, close agent + slash worker via the
484
+ shared ``_teardown_session`` path. Returns True iff it closed a live
485
+ session. The ``_finalized`` / worker ``_closed`` guards make concurrent or
486
+ repeat calls (e.g. session.close racing the WS-orphan reaper) harmless."""
487
+ with _sessions_lock:
488
+ session = _sessions.pop(sid, None)
489
+ if session is None:
490
+ return False
491
+ _teardown_session(session, end_reason=end_reason)
492
+ return True
493
+
494
+
495
+
496
+ def _ws_session_is_orphaned(session: dict | None) -> bool:
497
+ """True if a WS session has no live transport and no in-flight turn.
498
+
499
+ After ``handle_ws`` detaches a disconnected client it points the session at
500
+ ``_detached_ws_transport``. A session left on that transport (and not
501
+ mid-turn) is genuinely orphaned and safe to reap.
502
+ """
503
+ if not session or session.get("_finalized"):
504
+ return False
505
+ if session.get("running"):
506
+ return False
507
+ return session.get("transport") is _detached_ws_transport
508
+
509
+
510
+ def _schedule_ws_orphan_reap(sid: str) -> None:
511
+ """After a grace window, reap session ``sid`` iff it's still orphaned.
512
+
513
+ Called from the WS-disconnect path. The grace window lets a transient
514
+ reconnect (or a ``session.resume`` that reattaches the transport) cancel
515
+ the reap by re-binding a live transport. Disabled when the grace is 0.
516
+ """
517
+ if _WS_ORPHAN_REAP_GRACE_S <= 0:
518
+ return
519
+
520
+ def _reap() -> None:
521
+ # Serialize the orphan re-check against session.resume (which re-binds a
522
+ # live transport under _session_resume_lock and would make this session
523
+ # non-orphaned). The actual pop + teardown then goes through the shared
524
+ # _close_session_by_id funnel so the dict mutation happens under
525
+ # _sessions_lock — consistent with every other _sessions mutator
526
+ # (#39591: _reap previously popped under _session_resume_lock, giving no
527
+ # mutual exclusion against _init_session / _close_session_by_id, which
528
+ # guard with _sessions_lock). _sessions_lock is an RLock and the global
529
+ # ordering is always resume_lock -> sessions_lock, so nesting is safe.
530
+ with _session_resume_lock:
531
+ if not _ws_session_is_orphaned(_sessions.get(sid)):
532
+ return
533
+ _close_session_by_id(sid, end_reason="ws_orphan_reap")
534
+
535
+ timer = threading.Timer(_WS_ORPHAN_REAP_GRACE_S, _reap)
536
+ timer.daemon = True
537
+ timer.start()
538
+
539
+
540
+ def _close_sessions_for_transport(
541
+ transport, *, end_reason: str = "ws_disconnect"
542
+ ) -> tuple[int, int]:
543
+ """On transport disconnect, reap the sessions that opted into
544
+ close_on_disconnect (sidecar/dashboard) immediately via the unified
545
+ ``_close_session_by_id`` path, and re-point the rest back to stdio so later
546
+ emits don't hit a dead socket.
547
+
548
+ Non-flagged detached sessions are handed to the grace-windowed WS-orphan
549
+ reaper (``_schedule_ws_orphan_reap``): a quick reconnect / session.resume
550
+ that re-binds a live transport cancels the reap, otherwise the orphan is
551
+ torn down through the same idempotent ``_teardown_session`` path. This is
552
+ the single WS-disconnect teardown entry point — there is no second
553
+ independent reap loop in ``handle_ws``.
554
+
555
+ Returns ``(reaped, detached)`` counts for disconnect-path observability."""
556
+ with _sessions_lock:
557
+ owned = [(sid, s) for sid, s in _sessions.items() if s.get("transport") is transport]
558
+ reaped = 0
559
+ detached = 0
560
+ for sid, session in owned:
561
+ if session.get("close_on_disconnect"):
562
+ _close_session_by_id(sid, end_reason=end_reason)
563
+ reaped += 1
564
+ else:
565
+ # Point detached sessions at the drop sentinel (NOT real stdio) so
566
+ # _ws_session_is_orphaned recognizes them and the grace-reap can
567
+ # actually fire; a standalone `hermes --tui` keeps real _stdio.
568
+ session["transport"] = _detached_ws_transport
569
+ detached += 1
570
+ try:
571
+ _schedule_ws_orphan_reap(sid)
572
+ except Exception:
573
+ pass
574
+ return reaped, detached
575
+
325
576
 
326
577
  def _shutdown_sessions() -> None:
327
- for session in list(_sessions.values()):
328
- _finalize_session(session, end_reason="tui_shutdown")
329
- try:
330
- worker = session.get("slash_worker")
331
- if worker:
332
- worker.close()
333
- except Exception:
334
- pass
578
+ with _sessions_lock:
579
+ sids = list(_sessions)
580
+ for sid in sids:
581
+ _close_session_by_id(sid, end_reason="tui_shutdown")
582
+
583
+
584
+ # Last-resort net for any disconnect path that slips past the WS finally. TTL is
585
+ # hours-scale because last_active freezes during a long turn and on passive
586
+ # viewing — running/pending/starting/live-transport are hard exemptions instead.
587
+ try:
588
+ _SESSION_TTL_S = float(os.environ.get("HERMES_TUI_SESSION_TTL_S") or 6 * 3600)
589
+ except (TypeError, ValueError):
590
+ _SESSION_TTL_S = float(6 * 3600)
591
+ _SESSION_TTL_S = max(0.0, _SESSION_TTL_S)
592
+ _REAPER_SCAN_S = 300.0
593
+
594
+
595
+ def _transport_is_dead(transport) -> bool:
596
+ # _detached_ws_transport is the post-WS-disconnect drop sentinel; a session
597
+ # parked on it has no live client. _stdio_transport is the REAL transport
598
+ # for a standalone `hermes --tui`, so it must NOT count as dead here (doing
599
+ # so let the idle reaper evict healthy standalone TUI sessions).
600
+ if transport is _detached_ws_transport:
601
+ return True
602
+ return getattr(transport, "_closed", None) is True
603
+
604
+
605
+ def _session_is_evictable(sid: str, session: dict, now: float) -> bool:
606
+ if session.get("running") or _session_pending_kind(sid):
607
+ return False
608
+ ready = session.get("agent_ready")
609
+ # Lazy watch sessions (subagent spectator windows) never start a build,
610
+ # so their forever-unset agent_ready must not make them immortal.
611
+ if ready is not None and not ready.is_set() and not session.get("lazy"):
612
+ return False
613
+ if not _transport_is_dead(session.get("transport")):
614
+ return False
615
+ last_active = float(session.get("last_active") or 0.0)
616
+ created_at = float(session.get("created_at") or 0.0)
617
+ return (now - last_active) > _SESSION_TTL_S and (now - created_at) > _SESSION_TTL_S
618
+
619
+
620
+ def _reap_idle_sessions() -> None:
621
+ now = time.time()
622
+ with _sessions_lock:
623
+ victims = [sid for sid, s in _sessions.items() if _session_is_evictable(sid, s, now)]
624
+ for sid in victims:
625
+ _close_session_by_id(sid, end_reason="idle_timeout")
626
+
627
+
628
+ def _start_idle_reaper() -> None:
629
+ def _loop():
630
+ while True:
631
+ time.sleep(_REAPER_SCAN_S)
632
+ try:
633
+ _reap_idle_sessions()
634
+ except Exception:
635
+ pass
636
+
637
+ threading.Thread(target=_loop, daemon=True).start()
335
638
 
336
639
 
337
640
  atexit.register(_shutdown_sessions)
641
+ _start_idle_reaper()
338
642
 
339
643
 
340
644
  # ── Plumbing ──────────────────────────────────────────────────────────
@@ -363,6 +667,65 @@ def _db_unavailable_error(rid, *, code: int):
363
667
  return _err(rid, code, f"state.db unavailable: {detail}")
364
668
 
365
669
 
670
+ # ── per-session profile scoping (global remote mode) ───────────────────────────
671
+ # One dashboard normally serves its launch profile. But the desktop's app-global
672
+ # remote mode points every profile at this single backend, so resume/prompt must
673
+ # be able to act on ANOTHER local profile's state.db + home. The desktop passes
674
+ # ``profile`` on those calls; we open that profile's db and bind its HERMES_HOME
675
+ # (a ContextVar override) for the duration of the call so config/skills/model and
676
+ # message persistence all resolve to the right profile. Omitted/own profile → the
677
+ # launch profile (unchanged for single-profile and per-profile-remote setups).
678
+ def _profile_home(profile: str | None) -> Path | None:
679
+ """Resolve a named profile's home on THIS host, or None for the launch profile."""
680
+ name = (profile or "").strip()
681
+ if not name:
682
+ return None
683
+ try:
684
+ from hermes_cli import profiles as profiles_mod
685
+
686
+ home = Path(profiles_mod.get_profile_dir(name))
687
+ except Exception:
688
+ return None
689
+ # Already the launch profile? No override needed.
690
+ if home.resolve() == Path(_hermes_home).resolve():
691
+ return None
692
+ return home if (home / "state.db").exists() or home.exists() else None
693
+
694
+
695
+ # Placeholder ``terminal.cwd`` values that don't name a real directory — the
696
+ # gateway resolves these to the home dir at runtime, so they must NOT be treated
697
+ # as an explicit workspace (mirrors gateway/run.py's config bridge).
698
+ _CWD_PLACEHOLDERS = {".", "auto", "cwd"}
699
+
700
+
701
+ def _profile_configured_cwd(profile_home: Path | None) -> str | None:
702
+ """Resolve a non-launch profile's ``terminal.cwd`` from its own config.yaml.
703
+
704
+ The desktop's app-global remote mode serves every profile from one backend,
705
+ so the process-global ``TERMINAL_CWD`` belongs to the *launch* profile. A new
706
+ session bound to another profile must take its workspace from THAT profile's
707
+ config, not the stale env var (issue #40334). Returns an absolute, existing
708
+ directory, or None for placeholders / missing / invalid paths.
709
+ """
710
+ if profile_home is None:
711
+ return None
712
+ try:
713
+ import yaml
714
+
715
+ p = Path(profile_home) / "config.yaml"
716
+ if not p.exists():
717
+ return None
718
+ with open(p, encoding="utf-8") as f:
719
+ data = yaml.safe_load(f) or {}
720
+ raw = str((data.get("terminal") or {}).get("cwd") or "").strip()
721
+ if not raw or raw in _CWD_PLACEHOLDERS:
722
+ return None
723
+ resolved = os.path.abspath(os.path.expanduser(raw))
724
+ return resolved if os.path.isdir(resolved) else None
725
+ except Exception:
726
+ return None
727
+
728
+
366
729
  def write_json(obj: dict) -> bool:
367
730
  """Emit one JSON frame. Routes via the most-specific transport available.
368
731
 
@@ -395,11 +758,16 @@ def _status_update(sid: str, kind: str, text: str | None = None):
395
758
  body = (text if text is not None else kind).strip()
396
759
  if not body:
397
760
  return
398
- _emit(
399
- "status.update",
400
- sid,
401
- {"kind": kind if text is not None else "status", "text": body},
402
- )
761
+ out_kind = kind if text is not None else "status"
762
+ # Auto-compaction reaches us as a generic "lifecycle" status. Re-tag it so
763
+ # drivers (desktop app) can show an explicit "Summarizing…" indicator —
764
+ # otherwise a mid-turn compaction looks like the transcript reset itself.
765
+ if out_kind == "lifecycle":
766
+ from agent.conversation_compression import COMPACTION_STATUS_MARKER
767
+
768
+ if COMPACTION_STATUS_MARKER in body:
769
+ out_kind = "compacting"
770
+ _emit("status.update", sid, {"kind": out_kind, "text": body})
403
771
 
404
772
 
405
773
  def _estimate_image_tokens(width: int, height: int) -> int:
@@ -537,35 +905,79 @@ def _start_agent_build(sid: str, session: dict) -> None:
537
905
  ready = session.get("agent_ready")
538
906
  if ready is None:
539
907
  return
908
+ # A lazy watch session spectating an in-flight child must stay lazy so the
909
+ # subagent live-mirror keeps flowing. Incidental RPCs (session.info, model
910
+ # metadata, etc.) resolve through _sess(), which would otherwise upgrade it
911
+ # to a full agent mid-stream and silently kill the mirror (the mirror bails
912
+ # once agent is set). Once the child completes, the guard lifts and the next
913
+ # prompt/RPC builds the agent normally so the user can talk to the session.
914
+ if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")):
915
+ return
540
916
  lock = session.setdefault("agent_build_lock", threading.Lock())
541
917
  with lock:
542
918
  if ready.is_set() or session.get("agent_build_started"):
543
919
  return
544
920
  session["agent_build_started"] = True
921
+ # An upgrading lazy session is now genuinely mid-construction — restore
922
+ # its "still starting" eviction exemption.
923
+ session.pop("lazy", None)
545
924
  key = session["session_key"]
546
925
 
547
926
  def _build() -> None:
548
- current = _sessions.get(sid)
927
+ with _sessions_lock:
928
+ current = _sessions.get(sid)
549
929
  if current is None:
550
930
  ready.set()
551
931
  return
552
932
 
553
933
  worker = None
554
934
  notify_registered = False
935
+ home_token = None
936
+ profile_home = current.get("profile_home")
555
937
  try:
556
938
  tokens = _set_session_context(key)
939
+ # Build against the session's profile (global-remote): bind its
940
+ # HERMES_HOME so config/skills/model resolve to it, and hand the
941
+ # agent that profile's db so turns persist to the right state.db.
942
+ session_db = None
943
+ if profile_home:
944
+ home_token = set_hermes_home_override(profile_home)
945
+ try:
946
+ from hermes_state import SessionDB
947
+
948
+ session_db = SessionDB(db_path=Path(profile_home) / "state.db")
949
+ except Exception:
950
+ session_db = None
557
951
  try:
558
- agent = _make_agent(sid, key)
952
+ # Lazy-resumed (watch) sessions carry the stored conversation
953
+ # id — pass it through so the upgrade continues that session
954
+ # instead of starting a fresh one under the same key.
955
+ kw = {"session_db": session_db}
956
+ if resume_sid := current.get("resume_session_id"):
957
+ kw["session_id"] = resume_sid
958
+ # Model/effort/fast the desktop picked for a brand-new chat ride
959
+ # in as per-session overrides so the first build uses them
960
+ # directly (no global config, no build-then-switch).
961
+ if override := current.get("model_override"):
962
+ kw["model_override"] = override
963
+ if (reasoning := current.get("create_reasoning_override")) is not None:
964
+ kw["reasoning_config_override"] = reasoning
965
+ if (tier := current.get("create_service_tier_override")) is not None:
966
+ kw["service_tier_override"] = tier
967
+ agent = _make_agent(sid, key, **kw)
559
968
  finally:
560
969
  _clear_session_context(tokens)
561
970
 
562
971
  # Session DB row deferred to first run_conversation() call.
563
972
  # pending_title applied post-first-message (see cli.exec handler).
564
973
  current["agent"] = agent
974
+ # Baseline for the per-turn config sync; the profile home
975
+ # override is still active here.
976
+ current["config_model_seen"] = _config_model_target()
565
977
 
566
978
  try:
567
979
  worker = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
568
- current["slash_worker"] = worker
980
+ _attach_worker(sid, current, worker)
569
981
  except Exception:
570
982
  pass
571
983
 
@@ -584,7 +996,18 @@ def _start_agent_build(sid: str, session: dict) -> None:
584
996
  pass
585
997
 
586
998
  _wire_callbacks(sid)
587
- _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
999
+ # Hydrate credits notices at session OPEN (not just on the first
1000
+ # message), so depletion / usage-band warnings show at "ready". Runs
1001
+ # off the build thread, after the notice_callback is wired. Fail-open.
1002
+ try:
1003
+ from agent.credits_tracker import seed_credits_at_session_start
1004
+
1005
+ seed_credits_at_session_start(agent)
1006
+ except Exception:
1007
+ pass
1008
+ with _sessions_lock:
1009
+ if sid in _sessions:
1010
+ _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
588
1011
  _notify_session_boundary("on_session_reset", key)
589
1012
 
590
1013
  info = _session_info(agent, current)
@@ -593,23 +1016,29 @@ def _start_agent_build(sid: str, session: dict) -> None:
593
1016
  info["config_warning"] = cfg_warn
594
1017
  logger.warning(cfg_warn)
595
1018
  _emit("session.info", sid, info)
1019
+ # If MCP discovery is still in flight (a server slower than the
1020
+ # bounded wait_for_mcp_discovery join in _make_agent), the agent
1021
+ # was built without those tools. Catch up once they land — see
1022
+ # _schedule_mcp_late_refresh. Cache-safe (pre-first-turn only).
1023
+ _schedule_mcp_late_refresh(sid, agent)
596
1024
  except Exception as e:
597
1025
  current["agent_error"] = str(e)
598
1026
  _emit("error", sid, {"message": f"agent init failed: {e}"})
599
1027
  finally:
600
- if _sessions.get(sid) is not current:
601
- if worker is not None:
602
- try:
603
- worker.close()
604
- except Exception:
605
- pass
606
- if notify_registered:
607
- try:
608
- from tools.approval import unregister_gateway_notify
1028
+ if home_token is not None:
1029
+ reset_hermes_home_override(home_token)
1030
+ # _attach_worker already closed the worker if this session was
1031
+ # reaped mid-build; only the late notify registration can still
1032
+ # leak (session.close unregistered before _build registered it).
1033
+ with _sessions_lock:
1034
+ replaced = _sessions.get(sid) is not current
1035
+ if replaced and notify_registered:
1036
+ try:
1037
+ from tools.approval import unregister_gateway_notify
609
1038
 
610
- unregister_gateway_notify(key)
611
- except Exception:
612
- pass
1039
+ unregister_gateway_notify(key)
1040
+ except Exception:
1041
+ pass
613
1042
  ready.set()
614
1043
 
615
1044
  threading.Thread(target=_build, daemon=True).start()
@@ -643,9 +1072,13 @@ def _normalize_completion_path(path_part: str) -> str:
643
1072
 
644
1073
 
645
1074
  def _completion_cwd(params: dict | None = None) -> str:
1075
+ params = params or {}
646
1076
  raw = (
647
- (params or {}).get("cwd")
648
- or _sessions.get((params or {}).get("session_id") or "", {}).get("cwd")
1077
+ params.get("cwd")
1078
+ or _sessions.get(params.get("session_id") or "", {}).get("cwd")
1079
+ # A session bound to another profile resolves its workspace from THAT
1080
+ # profile's config before falling back to the launch profile's env var.
1081
+ or _profile_configured_cwd(_profile_home(params.get("profile")))
649
1082
  or os.environ.get("TERMINAL_CWD")
650
1083
  or os.getcwd()
651
1084
  )
@@ -658,6 +1091,30 @@ def _completion_cwd(params: dict | None = None) -> str:
658
1091
  return os.getcwd()
659
1092
 
660
1093
 
1094
+ def _terminal_task_cwd(session: dict | None) -> str:
1095
+ """Return the cwd that terminal_tool should use for this TUI session.
1096
+
1097
+ ``_completion_cwd`` validates paths on the host so file completion does not
1098
+ point at nonsense. Non-local terminal backends are different: their cwd is
1099
+ inside the target environment, so an SSH path like /home/user/workspace may
1100
+ not exist on the local macOS host but is still the correct execution cwd.
1101
+ """
1102
+ backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
1103
+ if backend and backend != "local":
1104
+ raw = os.environ.get("TERMINAL_CWD", "").strip()
1105
+ if not raw:
1106
+ try:
1107
+ terminal_cfg = _load_cfg().get("terminal", {})
1108
+ if isinstance(terminal_cfg, dict):
1109
+ raw = str(terminal_cfg.get("cwd") or "").strip()
1110
+ except Exception:
1111
+ raw = ""
1112
+ if raw and raw not in {".", "auto", "cwd"}:
1113
+ return raw
1114
+
1115
+ return _session_cwd(session)
1116
+
1117
+
661
1118
  def _git_branch_for_cwd(cwd: str) -> str:
662
1119
  try:
663
1120
  result = subprocess.run(
@@ -666,6 +1123,7 @@ def _git_branch_for_cwd(cwd: str) -> str:
666
1123
  text=True,
667
1124
  timeout=1.5,
668
1125
  check=False,
1126
+ stdin=subprocess.DEVNULL,
669
1127
  )
670
1128
  if result.returncode == 0:
671
1129
  branch = result.stdout.strip()
@@ -677,6 +1135,7 @@ def _git_branch_for_cwd(cwd: str) -> str:
677
1135
  text=True,
678
1136
  timeout=1.5,
679
1137
  check=False,
1138
+ stdin=subprocess.DEVNULL,
680
1139
  )
681
1140
  return head.stdout.strip() if head.returncode == 0 else ""
682
1141
  except Exception:
@@ -696,7 +1155,7 @@ def _register_session_cwd(session: dict | None) -> None:
696
1155
  from tools.terminal_tool import register_task_env_overrides
697
1156
 
698
1157
  register_task_env_overrides(
699
- session["session_key"], {"cwd": _session_cwd(session)}
1158
+ session["session_key"], {"cwd": _terminal_task_cwd(session)}
700
1159
  )
701
1160
  except Exception:
702
1161
  pass
@@ -720,18 +1179,115 @@ def _ensure_session_db_row(session: dict) -> None:
720
1179
  key = session.get("session_key")
721
1180
  if not key:
722
1181
  return
723
- db = _get_db()
1182
+ # Persist into the session's own profile db (global remote mode), not the
1183
+ # launch profile's — otherwise the row lands in the wrong state.db, the
1184
+ # unified list mis-tags it, and resume 404s ("session not found").
1185
+ profile_home = session.get("profile_home")
1186
+ if profile_home:
1187
+ from hermes_state import SessionDB
1188
+
1189
+ try:
1190
+ db = SessionDB(db_path=Path(profile_home) / "state.db")
1191
+ except Exception:
1192
+ logger.debug("failed to open profile db for session row", exc_info=True)
1193
+ return
1194
+ close_db = True
1195
+ else:
1196
+ db = _get_db()
1197
+ close_db = False
724
1198
  if db is None:
725
1199
  return
1200
+ # The session's own model/effort/fast pick — the composer override shipped on
1201
+ # session.create, or a restored /model switch — must own the row's model +
1202
+ # model_config. The agent isn't built yet at first prompt.submit, so derive
1203
+ # the row from the live override dict; fall back to the global resolved model
1204
+ # only when this chat made no explicit pick. Writing the global default here
1205
+ # used to win the INSERT-OR-IGNORE race against the agent's own correct
1206
+ # lazy-create, so a reconnect/resume rebuilt from the global model and
1207
+ # silently reverted the chat (e.g. picked gpt-5.5, reconnect snapped back to
1208
+ # the profile default). model_config carries provider/reasoning/service_tier
1209
+ # so resume restores effort + fast too, not just the model name.
1210
+ override = session.get("model_override")
1211
+ override = override if isinstance(override, dict) else {}
1212
+ row_model = str(override.get("model") or "").strip() or _resolve_model()
1213
+ model_config: dict = {}
1214
+ for src_key, cfg_key in (
1215
+ ("model", "model"),
1216
+ ("provider", "provider"),
1217
+ ("base_url", "base_url"),
1218
+ ("api_mode", "api_mode"),
1219
+ ):
1220
+ if val := override.get(src_key):
1221
+ model_config[cfg_key] = str(val)
1222
+ # The composer override may carry the RESOLVED provider "custom" for a named
1223
+ # ``providers:`` / ``custom_providers:`` entry. Persisting bare "custom" here
1224
+ # (the very first DB write for a fresh desktop session, before the agent is
1225
+ # built) is the origin of the recurring "No LLM provider configured" rows:
1226
+ # on the next resume bare "custom" routes to OpenRouter with no key. Recover
1227
+ # the durable ``custom:<name>`` identity from the override's base_url, else
1228
+ # the configured provider, so a routable identity is persisted from the
1229
+ # start (matches _runtime_model_config's normalization).
1230
+ if str(model_config.get("provider") or "").strip().lower() == "custom":
1231
+ try:
1232
+ from hermes_cli.runtime_provider import canonical_custom_identity
1233
+
1234
+ healed = canonical_custom_identity(
1235
+ base_url=model_config.get("base_url") or None
1236
+ )
1237
+ if healed:
1238
+ model_config["provider"] = healed
1239
+ except Exception:
1240
+ logger.debug(
1241
+ "custom provider identity recovery failed (db row)", exc_info=True
1242
+ )
1243
+ if (reasoning := session.get("create_reasoning_override")) is not None:
1244
+ model_config["reasoning_config"] = reasoning
1245
+ if tier := session.get("create_service_tier_override"):
1246
+ model_config["service_tier"] = tier
726
1247
  try:
727
1248
  db.create_session(
728
1249
  key,
729
1250
  source="tui",
730
- model=_resolve_model(),
1251
+ model=row_model,
1252
+ model_config=model_config or None,
731
1253
  cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
732
1254
  )
733
1255
  except Exception:
734
1256
  logger.debug("failed to persist desktop session row", exc_info=True)
1257
+ finally:
1258
+ if close_db:
1259
+ try:
1260
+ db.close()
1261
+ except Exception:
1262
+ pass
1263
+
1264
+
1265
+ @contextlib.contextmanager
1266
+ def _session_db(session: dict):
1267
+ """Yield the SessionDB that owns this session's row (profile-aware).
1268
+
1269
+ Mirrors :func:`_ensure_session_db_row`: a remote/profile session persists
1270
+ into its own profile's ``state.db`` (a fresh handle we close on exit);
1271
+ everything else borrows the shared ``_get_db()`` handle (left open). Yields
1272
+ None when the db is unavailable.
1273
+ """
1274
+ db, close_db = None, False
1275
+ profile_home = session.get("profile_home")
1276
+ if profile_home:
1277
+ from hermes_state import SessionDB
1278
+
1279
+ try:
1280
+ db, close_db = SessionDB(db_path=Path(profile_home) / "state.db"), True
1281
+ except Exception:
1282
+ logger.debug("failed to open profile db for session", exc_info=True)
1283
+ else:
1284
+ db = _get_db()
1285
+ try:
1286
+ yield db
1287
+ finally:
1288
+ if close_db and db is not None:
1289
+ with contextlib.suppress(Exception):
1290
+ db.close()
735
1291
 
736
1292
 
737
1293
  def _set_session_cwd(session: dict, cwd: str) -> str:
@@ -743,12 +1299,12 @@ def _set_session_cwd(session: dict, cwd: str) -> str:
743
1299
  # lazy row creation persist it too, not the launch-dir fallback).
744
1300
  session["explicit_cwd"] = True
745
1301
  _register_session_cwd(session)
746
- db = _get_db()
747
- if db is not None:
748
- try:
749
- db.update_session_cwd(session.get("session_key", ""), resolved)
750
- except Exception:
751
- logger.debug("failed to persist session cwd", exc_info=True)
1302
+ with _session_db(session) as db:
1303
+ if db is not None:
1304
+ try:
1305
+ db.update_session_cwd(session.get("session_key", ""), resolved)
1306
+ except Exception:
1307
+ logger.debug("failed to persist session cwd", exc_info=True)
752
1308
  try:
753
1309
  from tools.terminal_tool import cleanup_vm
754
1310
 
@@ -773,26 +1329,52 @@ def _load_cfg() -> dict:
773
1329
  try:
774
1330
  import yaml
775
1331
 
776
- p = _hermes_home / "config.yaml"
1332
+ # Honor a per-session profile override (see session.resume) so a resumed
1333
+ # remote profile loads ITS config (model, skills, prompt); otherwise the
1334
+ # launch profile's _hermes_home. Cache is keyed on the resolved path, so
1335
+ # profiles don't clobber each other.
1336
+ override = get_hermes_home_override()
1337
+ home = override if isinstance(override, str) and override else _hermes_home
1338
+ p = Path(home) / "config.yaml"
777
1339
  mtime = p.stat().st_mtime if p.exists() else None
778
1340
  with _cfg_lock:
779
1341
  if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p:
780
- return copy.deepcopy(_cfg_cache)
1342
+ return _apply_managed(copy.deepcopy(_cfg_cache))
781
1343
  if p.exists():
782
1344
  with open(p, encoding="utf-8") as f:
783
1345
  data = yaml.safe_load(f) or {}
784
1346
  else:
785
1347
  data = {}
786
1348
  with _cfg_lock:
1349
+ # Cache the RAW user config (no managed overlay) so _save_cfg, which
1350
+ # writes _cfg_cache back to disk, never persists managed values into
1351
+ # the user's file. The managed overlay is applied on every return
1352
+ # path instead (read-side only).
787
1353
  _cfg_cache = copy.deepcopy(data)
788
1354
  _cfg_mtime = mtime
789
1355
  _cfg_path = p
790
- return data
1356
+ return _apply_managed(data)
791
1357
  except Exception:
792
1358
  pass
793
1359
  return {}
794
1360
 
795
1361
 
1362
+ def _apply_managed(cfg: dict) -> dict:
1363
+ """Overlay administrator-pinned managed-scope values on a config dict.
1364
+
1365
+ The TUI/desktop backend builds config independently of
1366
+ hermes_cli.config.load_config, so without this a managed skin / reasoning_effort
1367
+ / service_tier / provider_routing would be silently ignored here. Read-side
1368
+ only — the raw user config is what gets cached and saved. Fail-open.
1369
+ """
1370
+ try:
1371
+ from hermes_cli import managed_scope
1372
+
1373
+ return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {})
1374
+ except Exception:
1375
+ return cfg
1376
+
1377
+
796
1378
  def _save_cfg(cfg: dict):
797
1379
  global _cfg_cache, _cfg_mtime, _cfg_path
798
1380
  import yaml
@@ -809,11 +1391,32 @@ def _save_cfg(cfg: dict):
809
1391
  _cfg_mtime = None
810
1392
 
811
1393
 
812
- def _set_session_context(session_key: str) -> list:
1394
+ def _cwd_for_session_key(session_key: str) -> str:
1395
+ """Reverse-map session_key to the session's logical cwd.
1396
+
1397
+ Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the
1398
+ thread pool, so iterating the live view risks ``RuntimeError: dictionary
1399
+ changed size during iteration``.
1400
+ """
1401
+ if not session_key:
1402
+ return ""
1403
+ with _sessions_lock:
1404
+ for sess in list(_sessions.values()):
1405
+ if sess.get("session_key") == session_key:
1406
+ return str(sess.get("cwd") or "")
1407
+ return ""
1408
+
1409
+
1410
+ def _set_session_context(session_key: str, cwd: str | None = None) -> list:
813
1411
  try:
814
1412
  from gateway.session_context import set_session_vars
815
1413
 
816
- return set_session_vars(session_key=session_key)
1414
+ # Ephemeral task IDs (background, preview) aren't in `_sessions`, so the
1415
+ # reverse-map returns "" and would clear the cwd override. Callers that
1416
+ # know the parent workspace pass it explicitly so spawned agents inherit
1417
+ # it instead of falling back to the gateway launch dir.
1418
+ resolved = cwd if cwd is not None else _cwd_for_session_key(session_key)
1419
+ return set_session_vars(session_key=session_key, cwd=resolved)
817
1420
  except Exception:
818
1421
  return []
819
1422
 
@@ -842,16 +1445,19 @@ def _enable_gateway_prompts() -> None:
842
1445
  def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
843
1446
  rid = uuid.uuid4().hex[:8]
844
1447
  ev = threading.Event()
845
- _pending[rid] = (sid, ev)
846
- payload["request_id"] = rid
847
- _pending_prompt_payloads[rid] = (event, dict(payload))
1448
+ with _prompt_lock:
1449
+ _pending[rid] = (sid, ev)
1450
+ payload["request_id"] = rid
1451
+ _pending_prompt_payloads[rid] = (event, dict(payload))
848
1452
  try:
849
1453
  _emit(event, sid, payload)
850
1454
  ev.wait(timeout=timeout)
851
1455
  finally:
852
- _pending.pop(rid, None)
853
- _pending_prompt_payloads.pop(rid, None)
854
- return _answers.pop(rid, "")
1456
+ with _prompt_lock:
1457
+ _pending.pop(rid, None)
1458
+ _pending_prompt_payloads.pop(rid, None)
1459
+ with _prompt_lock:
1460
+ return _answers.pop(rid, "")
855
1461
 
856
1462
 
857
1463
  def _clear_pending(sid: str | None = None) -> None:
@@ -863,10 +1469,11 @@ def _clear_pending(sid: str | None = None) -> None:
863
1469
  sessions sharing the same tui_gateway process. When *sid* is
864
1470
  None, every pending prompt is released (used during shutdown).
865
1471
  """
866
- for rid, (owner_sid, ev) in list(_pending.items()):
867
- if sid is None or owner_sid == sid:
868
- _answers[rid] = ""
869
- ev.set()
1472
+ with _prompt_lock:
1473
+ for rid, (owner_sid, ev) in list(_pending.items()):
1474
+ if sid is None or owner_sid == sid:
1475
+ _answers[rid] = ""
1476
+ ev.set()
870
1477
 
871
1478
 
872
1479
  # ── Agent factory ────────────────────────────────────────────────────
@@ -906,6 +1513,31 @@ def _resolve_model() -> str:
906
1513
  return "anthropic/claude-sonnet-4"
907
1514
 
908
1515
 
1516
+ def _config_model_target() -> tuple[str, str]:
1517
+ """(model, provider) currently selected by config (env as fallback).
1518
+
1519
+ config.yaml wins over HERMES_MODEL / HERMES_INFERENCE_MODEL here, the
1520
+ reverse of `_resolve_model()`'s startup order. Those env vars are a
1521
+ provision-time seed (hosted instances set HERMES_INFERENCE_MODEL in the
1522
+ container env); if they outranked config.yaml, the per-turn sync would
1523
+ stay pinned to the seed forever and dashboard/CLI model changes would
1524
+ never reach an open chat — the exact bug this sync exists to fix.
1525
+ """
1526
+ cfg_model = _load_cfg().get("model")
1527
+ model = ""
1528
+ provider = ""
1529
+ if isinstance(cfg_model, dict):
1530
+ model = str(cfg_model.get("default", "") or "").strip()
1531
+ provider = str(cfg_model.get("provider") or "").strip()
1532
+ if provider.lower() == "auto":
1533
+ provider = ""
1534
+ elif isinstance(cfg_model, str):
1535
+ model = cfg_model.strip()
1536
+ if not model:
1537
+ model = _resolve_model()
1538
+ return model, provider
1539
+
1540
+
909
1541
  def _resolve_startup_runtime() -> tuple[str, str | None]:
910
1542
  model = _resolve_model()
911
1543
  explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip()
@@ -941,42 +1573,288 @@ def _resolve_startup_runtime() -> tuple[str, str | None]:
941
1573
  return model, None
942
1574
 
943
1575
 
944
- def _write_config_key(key_path: str, value):
945
- cfg = _load_cfg()
946
- current = cfg
947
- keys = key_path.split(".")
948
- for key in keys[:-1]:
949
- if key not in current or not isinstance(current.get(key), dict):
950
- current[key] = {}
951
- current = current[key]
952
- current[keys[-1]] = value
953
- _save_cfg(cfg)
1576
+ # Bare billing buckets are not routable provider identities (kept in parity with the
1577
+ # provider gate in agent_init). Restoring one as a session provider override breaks resume.
1578
+ _BARE_BILLING_PROVIDERS = {"auto", "openrouter", "custom"}
954
1579
 
955
1580
 
956
- _STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
1581
+ def _stored_session_runtime_overrides(row: dict | None) -> dict:
1582
+ """Return runtime fields persisted with a stored session.
957
1583
 
1584
+ ``session.resume`` is a session-scoped operation: reopening an older chat
1585
+ must restore the model/provider/reasoning state that chat actually used,
1586
+ not whatever global model the user most recently selected in another chat.
1587
+ The durable session row stores the model directly, the billing provider in
1588
+ ``billing_provider``, and richer runtime knobs in JSON ``model_config``.
1589
+ """
1590
+ if not row:
1591
+ return {}
958
1592
 
959
- def _coerce_statusbar(raw) -> str:
960
- if raw is False:
961
- return "off"
962
- if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES:
963
- return s
964
- return "top"
1593
+ raw_config = row.get("model_config")
1594
+ model_config: dict = {}
1595
+ if isinstance(raw_config, dict):
1596
+ model_config = raw_config
1597
+ elif isinstance(raw_config, str) and raw_config.strip():
1598
+ try:
1599
+ parsed = json.loads(raw_config)
1600
+ if isinstance(parsed, dict):
1601
+ model_config = parsed
1602
+ except Exception:
1603
+ logger.debug("failed to parse stored session model_config", exc_info=True)
1604
+
1605
+ overrides: dict = {}
1606
+ model = str(row.get("model") or model_config.get("model") or "").strip()
1607
+ # ``billing_provider`` is only the billing bucket — for a custom endpoint it is the
1608
+ # bare class ``"custom"``, which agent_init treats as non-routable, so restoring it as
1609
+ # the provider override makes ``session.resume`` fail with "No LLM provider configured".
1610
+ # Only restore an explicit provider; otherwise leave it unset so resume falls back to
1611
+ # the configured default, matching the working CLI path.
1612
+ explicit_provider = str(model_config.get("provider") or "").strip()
1613
+ billing_provider = str(
1614
+ model_config.get("billing_provider") or row.get("billing_provider") or ""
1615
+ ).strip()
1616
+ provider = explicit_provider
1617
+ if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS:
1618
+ provider = billing_provider
1619
+ base_url = str(model_config.get("base_url") or "").strip()
1620
+ api_mode = str(model_config.get("api_mode") or "").strip()
1621
+ reasoning_config = model_config.get("reasoning_config")
1622
+ service_tier = str(model_config.get("service_tier") or "").strip()
1623
+
1624
+ # Heal a bare ``"custom"`` provider stored by an older build (or any leak
1625
+ # site that bypassed _runtime_model_config's normalization). Bare custom is
1626
+ # the resolved billing class, not a routable identity — restoring it as the
1627
+ # session's provider override routes the resume to the OpenRouter default
1628
+ # URL with no api_key, surfacing as "No LLM provider configured". Recover
1629
+ # the durable ``custom:<name>`` menu key from the stored base_url, falling
1630
+ # back to the configured provider when the row has no base_url (the
1631
+ # recurring Desktop/TUI regression vector). If neither names a real entry,
1632
+ # drop the bare provider entirely so resume falls back to the configured
1633
+ # default rather than the broken OpenRouter route.
1634
+ if provider.strip().lower() == "custom":
1635
+ healed = None
1636
+ try:
1637
+ from hermes_cli.runtime_provider import canonical_custom_identity
965
1638
 
1639
+ healed = canonical_custom_identity(base_url=base_url or None)
1640
+ except Exception:
1641
+ logger.debug(
1642
+ "custom provider identity recovery failed", exc_info=True
1643
+ )
1644
+ provider = healed or ("" if not base_url else provider)
1645
+
1646
+ if model:
1647
+ # Use the same dict-shaped override that live /model switches use so a
1648
+ # DB-restored session can preserve custom endpoint metadata across both
1649
+ # initial resume and later rebuilds (/new). Deliberately do not persist
1650
+ # or restore raw api_key here; endpoint credentials should continue to
1651
+ # come from config/env/provider resolution rather than the session DB.
1652
+ overrides["model_override"] = {
1653
+ "model": model,
1654
+ "provider": provider or None,
1655
+ "base_url": base_url or None,
1656
+ "api_mode": api_mode or None,
1657
+ }
1658
+ if provider:
1659
+ overrides["provider_override"] = provider
1660
+ if isinstance(reasoning_config, dict):
1661
+ overrides["reasoning_config_override"] = reasoning_config
1662
+ if service_tier:
1663
+ overrides["service_tier_override"] = service_tier
1664
+
1665
+ return overrides
1666
+
1667
+
1668
+ def _runtime_model_config(agent, existing: dict | None = None) -> dict:
1669
+ config = dict(existing or {})
1670
+ model = str(getattr(agent, "model", "") or "").strip()
1671
+ provider = str(getattr(agent, "provider", "") or "").strip()
1672
+ base_url = str(getattr(agent, "base_url", "") or "").strip()
1673
+ api_mode = str(getattr(agent, "api_mode", "") or "").strip()
1674
+ reasoning_config = getattr(agent, "reasoning_config", None)
1675
+ service_tier = getattr(agent, "service_tier", None)
1676
+
1677
+ if model:
1678
+ config["model"] = model
1679
+ if provider:
1680
+ if provider.strip().lower() == "custom":
1681
+ # ``agent.provider`` is the RESOLVED provider, and for any named
1682
+ # ``providers:`` / ``custom_providers:`` entry that is the literal
1683
+ # string "custom" — persisting it loses the entry identity, so a
1684
+ # later resume/rebuild cannot re-resolve the entry's credentials
1685
+ # (the api_key is deliberately never persisted; see
1686
+ # _stored_session_runtime_overrides). Recover the canonical
1687
+ # ``custom:<name>`` menu key from the endpoint URL when present,
1688
+ # else from the configured provider — this second fallback is the
1689
+ # fix for sessions built WITHOUT a base_url on the override (the
1690
+ # recurring Desktop/TUI "No LLM provider configured" regression:
1691
+ # bare "custom" with no base_url was persisted verbatim and routed
1692
+ # to OpenRouter with no key on the next resume).
1693
+ try:
1694
+ from hermes_cli.runtime_provider import (
1695
+ canonical_custom_identity,
1696
+ )
966
1697
 
967
- _MOUSE_TRACKING_ALIASES = {
968
- "0": "off",
969
- "1": "all",
970
- "all": "all",
971
- "any": "all",
972
- "button": "buttons",
973
- "buttons": "buttons",
974
- "click": "buttons",
975
- "false": "off",
976
- "full": "all",
977
- "no": "off",
978
- "off": "off",
979
- "on": "all",
1698
+ provider = (
1699
+ canonical_custom_identity(base_url=base_url) or provider
1700
+ )
1701
+ except Exception:
1702
+ logger.debug(
1703
+ "custom provider identity lookup failed", exc_info=True
1704
+ )
1705
+ config["provider"] = provider
1706
+ if base_url:
1707
+ config["base_url"] = base_url
1708
+ else:
1709
+ config.pop("base_url", None)
1710
+ if api_mode:
1711
+ config["api_mode"] = api_mode
1712
+ else:
1713
+ config.pop("api_mode", None)
1714
+ if isinstance(reasoning_config, dict):
1715
+ config["reasoning_config"] = reasoning_config
1716
+ else:
1717
+ config.pop("reasoning_config", None)
1718
+ if service_tier:
1719
+ config["service_tier"] = service_tier
1720
+ else:
1721
+ config.pop("service_tier", None)
1722
+
1723
+ return config
1724
+
1725
+
1726
+ def _persist_live_session_runtime(session: dict | None) -> None:
1727
+ """Persist active session runtime so future resumes restore the same footer."""
1728
+ if not session:
1729
+ return
1730
+ agent = session.get("agent")
1731
+ session_key = str(session.get("session_key") or "").strip()
1732
+ if agent is None or not session_key:
1733
+ return
1734
+
1735
+ db = getattr(agent, "_session_db", None) or _get_db()
1736
+ if db is None:
1737
+ return
1738
+
1739
+ try:
1740
+ row = db.get_session(session_key) or {}
1741
+ raw_config = row.get("model_config")
1742
+ existing_config = {}
1743
+ if isinstance(raw_config, dict):
1744
+ existing_config = raw_config
1745
+ elif isinstance(raw_config, str) and raw_config.strip():
1746
+ parsed = json.loads(raw_config)
1747
+ if isinstance(parsed, dict):
1748
+ existing_config = parsed
1749
+ model_config = _runtime_model_config(agent, existing_config)
1750
+ model = str(getattr(agent, "model", "") or "").strip()
1751
+ if hasattr(db, "update_session_meta"):
1752
+ db.update_session_meta(session_key, json.dumps(model_config), model or None)
1753
+ elif model and hasattr(db, "update_session_model"):
1754
+ db.update_session_model(session_key, model)
1755
+ except Exception:
1756
+ logger.debug("failed to persist live session runtime", exc_info=True)
1757
+
1758
+
1759
+ def _persist_live_session_system_prompt(session: dict | None) -> None:
1760
+ """Refresh the stored system prompt after a live runtime identity change."""
1761
+ if not session:
1762
+ return
1763
+ agent = session.get("agent")
1764
+ session_key = str(session.get("session_key") or "").strip()
1765
+ if agent is None or not session_key or not hasattr(agent, "_build_system_prompt"):
1766
+ return
1767
+
1768
+ db = getattr(agent, "_session_db", None) or _get_db()
1769
+ if db is None or not hasattr(db, "update_system_prompt"):
1770
+ return
1771
+
1772
+ try:
1773
+ prompt = agent._build_system_prompt(None)
1774
+ agent._cached_system_prompt = prompt
1775
+ db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt)
1776
+ except Exception:
1777
+ logger.debug("failed to persist live session system prompt", exc_info=True)
1778
+
1779
+
1780
+ def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None:
1781
+ """Record a real system-history pivot after a live model switch."""
1782
+ if not session:
1783
+ return
1784
+ session_key = str(session.get("session_key") or "").strip()
1785
+ if not session_key:
1786
+ return
1787
+
1788
+ provider_part = f" via provider {provider}" if provider else ""
1789
+ marker = (
1790
+ "[System: The active model for this chat has changed to "
1791
+ f"{model}{provider_part}. From this point forward, use this runtime "
1792
+ "metadata when answering questions about what model/provider is active.]"
1793
+ )
1794
+ entry = {"role": "system", "content": marker}
1795
+
1796
+ lock = session.get("history_lock")
1797
+ if lock is not None:
1798
+ with lock:
1799
+ session.setdefault("history", []).append(entry)
1800
+ session["history_version"] = int(session.get("history_version", 0)) + 1
1801
+ else:
1802
+ session.setdefault("history", []).append(entry)
1803
+ session["history_version"] = int(session.get("history_version", 0)) + 1
1804
+
1805
+ try:
1806
+ agent = session.get("agent")
1807
+ db = getattr(agent, "_session_db", None) if agent is not None else None
1808
+ if db is not None:
1809
+ db.append_message(session_id=session_key, role="system", content=marker)
1810
+ return
1811
+
1812
+ _ensure_session_db_row(session)
1813
+ with _session_db(session) as scoped_db:
1814
+ if scoped_db is not None:
1815
+ scoped_db.append_message(
1816
+ session_id=session_key, role="system", content=marker
1817
+ )
1818
+ except Exception:
1819
+ logger.debug("failed to persist model switch marker", exc_info=True)
1820
+
1821
+
1822
+ def _write_config_key(key_path: str, value):
1823
+ cfg = _load_cfg()
1824
+ current = cfg
1825
+ keys = key_path.split(".")
1826
+ for key in keys[:-1]:
1827
+ if key not in current or not isinstance(current.get(key), dict):
1828
+ current[key] = {}
1829
+ current = current[key]
1830
+ current[keys[-1]] = value
1831
+ _save_cfg(cfg)
1832
+
1833
+
1834
+ _STATUSBAR_MODES = frozenset({"off", "top", "bottom"})
1835
+
1836
+
1837
+ def _coerce_statusbar(raw) -> str:
1838
+ if raw is False:
1839
+ return "off"
1840
+ if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES:
1841
+ return s
1842
+ return "top"
1843
+
1844
+
1845
+ _MOUSE_TRACKING_ALIASES = {
1846
+ "0": "off",
1847
+ "1": "all",
1848
+ "all": "all",
1849
+ "any": "all",
1850
+ "button": "buttons",
1851
+ "buttons": "buttons",
1852
+ "click": "buttons",
1853
+ "false": "off",
1854
+ "full": "all",
1855
+ "no": "off",
1856
+ "off": "off",
1857
+ "on": "all",
980
1858
  "scroll": "wheel",
981
1859
  "true": "all",
982
1860
  "wheel": "wheel",
@@ -1032,10 +1910,40 @@ def _load_service_tier() -> str | None:
1032
1910
  return None
1033
1911
 
1034
1912
 
1913
+ def _load_provider_routing() -> dict:
1914
+ """OpenRouter provider-routing prefs from config.yaml (``provider_routing``).
1915
+
1916
+ Parity with the messaging gateway (``gateway/run.py::_load_provider_routing``)
1917
+ and the classic CLI: without this the desktop/TUI backend builds agents with
1918
+ no routing prefs, so OpenRouter falls back to its default (effectively random)
1919
+ provider selection even when the user configured ``provider_routing``.
1920
+ """
1921
+ try:
1922
+ return _load_cfg().get("provider_routing", {}) or {}
1923
+ except Exception:
1924
+ return {}
1925
+
1926
+
1035
1927
  def _load_show_reasoning() -> bool:
1036
1928
  return bool((_load_cfg().get("display") or {}).get("show_reasoning", False))
1037
1929
 
1038
1930
 
1931
+ def _load_memory_notifications() -> str:
1932
+ """Self-improvement review notification mode from config.yaml.
1933
+
1934
+ Parity with the messaging gateway (``gateway/run.py``) and the classic CLI:
1935
+ ``display.memory_notifications`` controls whether the background review's
1936
+ "💾 Self-improvement review: …" summary is surfaced. Without this the
1937
+ TUI/desktop backend always behaved as ``"on"`` and silently ignored a user
1938
+ who set ``off``. Accepts ``off`` / ``on`` (default) / ``verbose``; a bool is
1939
+ normalized for back-compat.
1940
+ """
1941
+ raw = (_load_cfg().get("display") or {}).get("memory_notifications")
1942
+ if isinstance(raw, bool):
1943
+ return "on" if raw else "off"
1944
+ return str(raw).lower() if raw else "on"
1945
+
1946
+
1039
1947
  def _load_tool_progress_mode() -> str:
1040
1948
  env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
1041
1949
  if env in {"off", "new", "all", "verbose"}:
@@ -1058,6 +1966,22 @@ def _load_enabled_toolsets() -> list[str] | None:
1058
1966
  cfg = None
1059
1967
  fallback_notice = None
1060
1968
 
1969
+ # Coding posture (base Hermes): with no explicit pin, collapse to the
1970
+ # coding toolset (+ enabled MCP servers) when sitting in a code workspace.
1971
+ # The desktop app and `hermes --tui` both land here. See
1972
+ # agent/coding_context.py. No config is loaded yet at this point, so we let
1973
+ # coding_selection() load it lazily (cli.py passes its already-resolved
1974
+ # CLI_CONFIG instead, purely to avoid a redundant read).
1975
+ if not explicit:
1976
+ try:
1977
+ from agent.coding_context import coding_selection
1978
+
1979
+ selection = coding_selection(platform="tui")
1980
+ if selection is not None:
1981
+ return selection
1982
+ except Exception:
1983
+ pass
1984
+
1061
1985
  try:
1062
1986
  from toolsets import validate_toolset
1063
1987
  except Exception:
@@ -1188,7 +2112,7 @@ def _tool_progress_enabled(sid: str) -> bool:
1188
2112
  return _session_tool_progress_mode(sid) != "off"
1189
2113
 
1190
2114
 
1191
- def _restart_slash_worker(session: dict):
2115
+ def _restart_slash_worker(sid: str, session: dict):
1192
2116
  worker = session.get("slash_worker")
1193
2117
  if worker:
1194
2118
  try:
@@ -1196,12 +2120,18 @@ def _restart_slash_worker(session: dict):
1196
2120
  except Exception:
1197
2121
  pass
1198
2122
  try:
1199
- session["slash_worker"] = _SlashWorker(
2123
+ new_worker = _SlashWorker(
1200
2124
  session["session_key"],
1201
2125
  getattr(session.get("agent"), "model", _resolve_model()),
1202
2126
  )
1203
2127
  except Exception:
1204
2128
  session["slash_worker"] = None
2129
+ return
2130
+ # Route through the same store-iff-still-mapped guard as the spawn sites:
2131
+ # the post-turn restart runs as `running` flips false, exactly when a
2132
+ # close_on_disconnect reap can pop this session — a bare store would orphan
2133
+ # the fresh worker (it self-heals only on gateway exit via the watchdog).
2134
+ _attach_worker(sid, session, new_worker)
1205
2135
 
1206
2136
 
1207
2137
  def _persist_model_switch(result) -> None:
@@ -1222,11 +2152,32 @@ def _persist_model_switch(result) -> None:
1222
2152
  save_config(cfg)
1223
2153
 
1224
2154
 
1225
- def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
1226
- from hermes_cli.model_switch import parse_model_flags, switch_model
2155
+ def _apply_model_switch(
2156
+ sid: str,
2157
+ session: dict,
2158
+ raw_input: str,
2159
+ *,
2160
+ confirm_expensive_model: bool = False,
2161
+ pin_session_override: bool = True,
2162
+ parsed_flags: tuple[str, str, bool, bool, bool] | None = None,
2163
+ ) -> dict:
2164
+ from hermes_cli.model_switch import (
2165
+ parse_model_flags,
2166
+ resolve_persist_behavior,
2167
+ switch_model,
2168
+ )
1227
2169
  from hermes_cli.runtime_provider import resolve_runtime_provider
1228
2170
 
1229
- model_input, explicit_provider, persist_global, _force_refresh = parse_model_flags(raw_input)
2171
+ if parsed_flags is None:
2172
+ parsed_flags = parse_model_flags(raw_input)
2173
+ (
2174
+ model_input,
2175
+ explicit_provider,
2176
+ is_global_flag,
2177
+ _force_refresh,
2178
+ is_session,
2179
+ ) = parsed_flags
2180
+ persist_global = resolve_persist_behavior(is_global_flag, is_session)
1230
2181
  if not model_input:
1231
2182
  raise ValueError("model value required")
1232
2183
 
@@ -1237,20 +2188,24 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
1237
2188
  current_base_url = getattr(agent, "base_url", "") or ""
1238
2189
  current_api_key = getattr(agent, "api_key", "") or ""
1239
2190
  else:
1240
- runtime = resolve_runtime_provider(requested=None)
1241
- current_provider = str(runtime.get("provider", "") or "")
1242
2191
  current_model = _resolve_model()
1243
- current_base_url = str(runtime.get("base_url", "") or "")
1244
- # Preserve a callable api_key (Azure Foundry Entra ID bearer
1245
- # provider) unchanged — ``str(...)`` would produce
1246
- # ``"<function ...>"`` and poison downstream switch_model
1247
- # validation. Match the agent-present branch's behavior at the
1248
- # top of this block.
1249
- _runtime_key = runtime.get("api_key", "")
1250
- if callable(_runtime_key) and not isinstance(_runtime_key, str):
1251
- current_api_key = _runtime_key
1252
- else:
1253
- current_api_key = str(_runtime_key or "")
2192
+ current_provider = explicit_provider.strip()
2193
+ current_base_url = ""
2194
+ current_api_key = ""
2195
+ if not explicit_provider:
2196
+ runtime = resolve_runtime_provider(requested=None)
2197
+ current_provider = str(runtime.get("provider", "") or "")
2198
+ current_base_url = str(runtime.get("base_url", "") or "")
2199
+ # Preserve a callable api_key (Azure Foundry Entra ID bearer
2200
+ # provider) unchanged — ``str(...)`` would produce
2201
+ # ``"<function ...>"`` and poison downstream switch_model
2202
+ # validation. Match the agent-present branch's behavior at the
2203
+ # top of this block.
2204
+ _runtime_key = runtime.get("api_key", "")
2205
+ if callable(_runtime_key) and not isinstance(_runtime_key, str):
2206
+ current_api_key = _runtime_key
2207
+ else:
2208
+ current_api_key = str(_runtime_key or "")
1254
2209
 
1255
2210
  # Load user-defined providers so switch_model can resolve named custom
1256
2211
  # endpoints (e.g. "ollama-launch") and validate against saved model lists.
@@ -1279,6 +2234,27 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
1279
2234
  if not result.success:
1280
2235
  raise ValueError(result.error_message or "model switch failed")
1281
2236
 
2237
+ if not confirm_expensive_model:
2238
+ try:
2239
+ from hermes_cli.model_cost_guard import expensive_model_warning
2240
+
2241
+ warning = expensive_model_warning(
2242
+ result.new_model,
2243
+ provider=result.target_provider,
2244
+ base_url=result.base_url or current_base_url,
2245
+ api_key=result.api_key or current_api_key,
2246
+ model_info=result.model_info,
2247
+ )
2248
+ except Exception:
2249
+ warning = None
2250
+ if warning is not None:
2251
+ return {
2252
+ "value": result.new_model,
2253
+ "warning": warning.message,
2254
+ "confirm_required": True,
2255
+ "confirm_message": warning.message,
2256
+ }
2257
+
1282
2258
  if agent:
1283
2259
  agent.switch_model(
1284
2260
  new_model=result.new_model,
@@ -1287,27 +2263,83 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict:
1287
2263
  base_url=result.base_url,
1288
2264
  api_mode=result.api_mode,
1289
2265
  )
1290
- _restart_slash_worker(session)
2266
+ _restart_slash_worker(sid, session)
2267
+ _persist_live_session_runtime(session)
2268
+ _persist_live_session_system_prompt(session)
2269
+ _append_model_switch_marker(
2270
+ session, model=result.new_model, provider=result.target_provider
2271
+ )
1291
2272
  _emit("session.info", sid, _session_info(agent, session))
1292
2273
 
1293
- os.environ["HERMES_MODEL"] = result.new_model
1294
- os.environ["HERMES_INFERENCE_MODEL"] = result.new_model
1295
- # Keep the process-level provider env vars in sync with the user's
1296
- # explicit choice so any ambient re-resolution (credential pool refresh,
1297
- # compressor rebuild, aux clients) and startup re-resolution on /new
1298
- # both pick up the new provider instead of the original one persisted
1299
- # in config or env.
2274
+ # Record the switch as a PER-SESSION override so a later rebuild of THIS
2275
+ # session (e.g. /new via _reset_session_agent, or resume) re-derives the
2276
+ # user's chosen model/provider instead of falling back to global config.
1300
2277
  #
1301
- # HERMES_TUI_PROVIDER is the canonical "explicit-this-process" carrier
1302
- # consumed by _resolve_startup_runtime() set it unconditionally on
1303
- # /model so /new can't fall through to static-catalog detection and
1304
- # pick a coincidentally-matching native provider (fixes #16857).
1305
- if result.target_provider:
1306
- os.environ["HERMES_INFERENCE_PROVIDER"] = result.target_provider
1307
- os.environ["HERMES_TUI_PROVIDER"] = result.target_provider
2278
+ # We deliberately do NOT write process-global env vars (HERMES_MODEL /
2279
+ # HERMES_INFERENCE_MODEL / HERMES_TUI_PROVIDER / HERMES_INFERENCE_PROVIDER)
2280
+ # here. The desktop backend hosts every same-profile session in ONE process,
2281
+ # so mutating os.environ on a /model switch leaked the new model/provider
2282
+ # into every OTHER live session's next agent rebuild — switching the model
2283
+ # in one session silently changed it in the others (the cross-session
2284
+ # contamination bug). agent.switch_model() above already mutated the right
2285
+ # agent in place; the override dict makes that choice survive a rebuild
2286
+ # without touching shared process state.
2287
+ if pin_session_override and isinstance(session, dict):
2288
+ session["model_override"] = {
2289
+ "model": result.new_model,
2290
+ "provider": result.target_provider,
2291
+ "base_url": result.base_url,
2292
+ "api_key": result.api_key,
2293
+ "api_mode": result.api_mode,
2294
+ }
1308
2295
  if persist_global:
1309
2296
  _persist_model_switch(result)
1310
- return {"value": result.new_model, "warning": result.warning_message or ""}
2297
+ return {
2298
+ "value": result.new_model,
2299
+ "warning": result.warning_message or "",
2300
+ "confirm_required": False,
2301
+ }
2302
+
2303
+
2304
+ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
2305
+ """Adopt a config.yaml model change at turn start, like gateways do per
2306
+ message. Sessions pinned with /model keep their choice; a failed switch
2307
+ keeps the current model and never blocks the turn.
2308
+ """
2309
+ agent = session.get("agent")
2310
+ if agent is None or session.get("model_override"):
2311
+ return
2312
+ target = _config_model_target()
2313
+ if not target[0]:
2314
+ return
2315
+ seen = session.get("config_model_seen")
2316
+ # Record first so a broken config gets one attempt per edit, not per turn.
2317
+ session["config_model_seen"] = target
2318
+ if target == seen:
2319
+ return
2320
+ model, provider = target
2321
+ # Already running the configured model (branched/resumed session before
2322
+ # its first sync, or a config revert after a failed switch): adopt the
2323
+ # baseline without a redundant switch.
2324
+ if model == getattr(agent, "model", "") and (
2325
+ not provider or provider == getattr(agent, "provider", "")
2326
+ ):
2327
+ return
2328
+ raw = f"{model} --provider {provider}" if provider else model
2329
+ try:
2330
+ _apply_model_switch(
2331
+ sid,
2332
+ session,
2333
+ raw,
2334
+ confirm_expensive_model=True,
2335
+ pin_session_override=False,
2336
+ )
2337
+ except Exception as e:
2338
+ _emit(
2339
+ "error",
2340
+ sid,
2341
+ {"message": f"Could not switch to configured model {model}: {e}"},
2342
+ )
1311
2343
 
1312
2344
 
1313
2345
  def _compress_session_history(
@@ -1434,7 +2466,7 @@ def _sync_session_key_after_compress(
1434
2466
  session["pending_title"] = None
1435
2467
  if restart_slash_worker:
1436
2468
  try:
1437
- _restart_slash_worker(session)
2469
+ _restart_slash_worker(sid, session)
1438
2470
  except Exception:
1439
2471
  pass
1440
2472
 
@@ -1481,6 +2513,15 @@ def _get_usage(agent) -> dict:
1481
2513
  usage["cost_usd"] = float(cost.amount_usd)
1482
2514
  except Exception:
1483
2515
  pass
2516
+ # Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on
2517
+ # HERMES_DEV_CREDITS so the payload stays clean when the flag is off.
2518
+ if is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")):
2519
+ try:
2520
+ spent = agent.get_credits_spent_micros()
2521
+ if spent is not None:
2522
+ usage["dev_credits_spent_micros"] = int(spent)
2523
+ except Exception:
2524
+ pass
1484
2525
  return usage
1485
2526
 
1486
2527
 
@@ -1542,7 +2583,8 @@ def _current_profile_name() -> str:
1542
2583
  # backend reporting less than its required value (or none at all — a pre-GUI
1543
2584
  # checkout), surfacing a one-click "update to align" prompt instead of failing
1544
2585
  # cryptically downstream. Bump whenever the desktop's backend contract changes.
1545
- DESKTOP_BACKEND_CONTRACT = 1
2586
+ # v2: adds the file.attach RPC (remote-gateway non-image file upload).
2587
+ DESKTOP_BACKEND_CONTRACT = 2
1546
2588
 
1547
2589
 
1548
2590
  def _session_info(agent, session: dict | None = None) -> dict:
@@ -1562,11 +2604,32 @@ def _session_info(agent, session: dict | None = None) -> dict:
1562
2604
  ):
1563
2605
  reasoning_effort = str(reasoning_config.get("effort", "") or "")
1564
2606
  service_tier = getattr(agent, "service_tier", None) or ""
2607
+ # Effective approval-bypass state — the same three sources that
2608
+ # check_all_command_guards() ORs together: persistent config
2609
+ # (approvals.mode=off), the process-scoped --yolo env, and the
2610
+ # per-session flag. Reporting only the per-session flag here would lie to
2611
+ # the desktop status bar (it would show YOLO "off" while approvals.mode=off
2612
+ # silently auto-approves every dangerous command).
2613
+ yolo = False
2614
+ try:
2615
+ from tools.approval import (
2616
+ _YOLO_MODE_FROZEN,
2617
+ _get_approval_mode,
2618
+ is_session_yolo_enabled,
2619
+ )
2620
+
2621
+ session_key = (session or {}).get("session_key")
2622
+ session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False
2623
+ yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
2624
+ except Exception:
2625
+ yolo = False
1565
2626
  info: dict = {
1566
2627
  "model": getattr(agent, "model", ""),
2628
+ "provider": getattr(agent, "provider", ""),
1567
2629
  "reasoning_effort": reasoning_effort,
1568
2630
  "service_tier": service_tier,
1569
2631
  "fast": service_tier == "priority",
2632
+ "yolo": yolo,
1570
2633
  "tools": {},
1571
2634
  "skills": {},
1572
2635
  "cwd": cwd,
@@ -1637,8 +2700,15 @@ def _tool_ctx(name: str, args: dict) -> str:
1637
2700
  return ""
1638
2701
 
1639
2702
 
1640
- _TUI_VERBOSE_TEXT_MAX_CHARS = 16_000
1641
- _TUI_VERBOSE_TEXT_MAX_LINES = 240
2703
+ # Tool Args/Result text shipped to the TUI for the verbose trail line. The TUI
2704
+ # renders only a small persisted preview (ui-tui VERBOSE_TRAIL_MAX_CHARS), kept
2705
+ # all session and expanded by default — so shipping more than that is pure pipe
2706
+ # waste AND feeds the Ink render-tree blowup that silently OOM-killed the TUI
2707
+ # parent (#34095). Cap here to match the render budget (a hair more, so the
2708
+ # "[omitted …]" label is still informative when output is genuinely large).
2709
+ # Full output stays in the agent context and the SQLite session, untouched.
2710
+ _TUI_VERBOSE_TEXT_MAX_CHARS = 1_000
2711
+ _TUI_VERBOSE_TEXT_MAX_LINES = 16
1642
2712
 
1643
2713
 
1644
2714
  def _cap_tui_verbose_text(text: str) -> str:
@@ -1860,6 +2930,8 @@ def _on_tool_progress(
1860
2930
  payload["subagent_id"] = str(_kwargs["subagent_id"])
1861
2931
  if _kwargs.get("parent_id"):
1862
2932
  payload["parent_id"] = str(_kwargs["parent_id"])
2933
+ if _kwargs.get("child_session_id"):
2934
+ payload["child_session_id"] = str(_kwargs["child_session_id"])
1863
2935
  if _kwargs.get("depth") is not None:
1864
2936
  payload["depth"] = int(_kwargs["depth"])
1865
2937
  if _kwargs.get("model"):
@@ -1905,7 +2977,103 @@ def _on_tool_progress(
1905
2977
  if preview and event_type == "subagent.tool":
1906
2978
  payload["tool_preview"] = str(preview)
1907
2979
  payload["text"] = str(preview)
1908
- _emit(event_type, sid, payload)
2980
+ # subagent.text is the child's per-token reply, relayed solely to feed a
2981
+ # watch window's live mirror. It is meaningless on the parent session
2982
+ # (which shows the child via the spawn tree, not its reply body), so
2983
+ # skip the parent emit — sending hundreds of ignored token frames there
2984
+ # is wasted traffic and a trap for any future parent-side subagent
2985
+ # catch-all. The mirror keys off the child sid and is unaffected.
2986
+ if event_type != "subagent.text":
2987
+ _emit(event_type, sid, payload)
2988
+ _mirror_subagent_to_child(event_type, payload)
2989
+
2990
+
2991
+ # ── Child-session live mirror ────────────────────────────────────────
2992
+ # A delegated child is not a live gateway session — it runs synchronously
2993
+ # inside the parent's turn, and its activity reaches the gateway only as
2994
+ # relayed ``subagent.*`` events on the PARENT sid. When a UI opens the child's
2995
+ # own session (session.resume on ``child_session_id``, e.g. the desktop's
2996
+ # open-in-new-window), that window would otherwise sit silent until the run
2997
+ # persists. Translate the relayed events into the native stream events the
2998
+ # window already renders — emitted on the CHILD sid, routed to its transport
2999
+ # by write_json — so the window shows a real midstream turn.
3000
+ _child_mirrors: dict[str, dict] = {}
3001
+ _child_mirrors_lock = threading.Lock()
3002
+ # Stored child session ids with a delegation run currently in flight (refreshed
3003
+ # on every relayed subagent.* event, popped on subagent.complete). Lets a lazy
3004
+ # watch resume report running=true so the window shows a busy indicator even
3005
+ # while the child is silent inside a long tool call (no events for 25s+).
3006
+ _active_child_runs: dict[str, float] = {}
3007
+ # Staleness bound for the registry: entries refresh on every relayed event, so
3008
+ # anything this quiet means the completion event was lost (callback raised,
3009
+ # parent crashed) — don't let a leaked entry pin "running" forever.
3010
+ _CHILD_RUN_STALE_S = 3600.0
3011
+
3012
+
3013
+ def _child_run_active(child_key: str) -> bool:
3014
+ ts = _active_child_runs.get(child_key)
3015
+ return ts is not None and (time.time() - ts) < _CHILD_RUN_STALE_S
3016
+
3017
+
3018
+ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:
3019
+ child_key = str(payload.get("child_session_id") or "")
3020
+ if not child_key:
3021
+ return
3022
+ # Liveness registry first — it must be accurate even when no window is
3023
+ # open, so a window opened mid-run can immediately know the child is busy.
3024
+ if event_type == "subagent.complete":
3025
+ _active_child_runs.pop(child_key, None)
3026
+ else:
3027
+ _active_child_runs[child_key] = time.time()
3028
+ # Mirror only into a live watch session (keyed by session_key; its live sid
3029
+ # differs from the stored id) that has NOT been upgraded to a full agent.
3030
+ # No window / closed → nothing to mirror; an upgraded session owns a real
3031
+ # native stream and mirroring on top would interleave two turns on one sid.
3032
+ # Either way drop state so a reopened window starts a fresh synthetic turn.
3033
+ live = _find_live_session_by_key(child_key)
3034
+ if live is None or live[1].get("agent") is not None:
3035
+ with _child_mirrors_lock:
3036
+ _child_mirrors.pop(child_key, None)
3037
+ return
3038
+ csid = live[0]
3039
+ with _child_mirrors_lock:
3040
+ st = _child_mirrors.setdefault(child_key, {"seq": 0, "open_tool": None, "started": False})
3041
+ if not st["started"]:
3042
+ st["started"] = True
3043
+ _emit("message.start", csid)
3044
+ if event_type == "subagent.thinking":
3045
+ if text := str(payload.get("text") or ""):
3046
+ _emit("reasoning.delta", csid, {"text": text})
3047
+ elif event_type == "subagent.text":
3048
+ # The child's streamed reply text — the actual "agent talking".
3049
+ # Relayed token-by-token from the child's run_conversation
3050
+ # stream_callback, so the watch window streams the reply live.
3051
+ if text := str(payload.get("text") or ""):
3052
+ _emit("message.delta", csid, {"text": text})
3053
+ elif event_type == "subagent.start":
3054
+ # One-time header line (the child's goal) so a freshly opened window
3055
+ # shows immediate context before the first reply token streams.
3056
+ if text := str(payload.get("text") or ""):
3057
+ _emit("message.delta", csid, {"text": f"{text}\n"})
3058
+ elif event_type == "subagent.tool":
3059
+ if st["open_tool"]:
3060
+ _emit("tool.complete", csid, st["open_tool"])
3061
+ st["seq"] += 1
3062
+ tool = {
3063
+ "name": str(payload.get("tool_name") or "tool"),
3064
+ "tool_id": f"submirror:{child_key}:{st['seq']}",
3065
+ "args": {},
3066
+ }
3067
+ if preview := str(payload.get("tool_preview") or payload.get("text") or ""):
3068
+ tool["preview"] = preview
3069
+ st["open_tool"] = tool
3070
+ _emit("tool.start", csid, tool)
3071
+ elif event_type == "subagent.complete":
3072
+ if st["open_tool"]:
3073
+ _emit("tool.complete", csid, st["open_tool"])
3074
+ summary = str(payload.get("summary") or payload.get("text") or "")
3075
+ _emit("message.complete", csid, {"text": summary})
3076
+ _child_mirrors.pop(child_key, None)
1909
3077
 
1910
3078
 
1911
3079
  def _agent_cbs(sid: str) -> dict:
@@ -1930,9 +3098,35 @@ def _agent_cbs(sid: str) -> dict:
1930
3098
  "status_callback": lambda kind, text=None: _status_update(
1931
3099
  sid, str(kind), None if text is None else str(text)
1932
3100
  ),
3101
+ # Credits/notice spine (L1): an AgentNotice fired by the agent becomes a
3102
+ # notification.show WS event; a recovery clear becomes notification.clear.
3103
+ # Snake_case payload to match the existing gateway-event convention.
3104
+ "notice_callback": lambda n: _emit(
3105
+ "notification.show",
3106
+ sid,
3107
+ {
3108
+ "text": n.text,
3109
+ "level": n.level,
3110
+ "kind": n.kind,
3111
+ "ttl_ms": n.ttl_ms,
3112
+ "key": n.key,
3113
+ "id": n.id,
3114
+ },
3115
+ ),
3116
+ "notice_clear_callback": lambda key: _emit(
3117
+ "notification.clear", sid, {"key": key}
3118
+ ),
1933
3119
  "clarify_callback": lambda q, c: _block(
1934
3120
  "clarify.request", sid, {"question": q, "choices": c}
1935
3121
  ),
3122
+ # read_terminal tool (desktop GUI): same blocking bridge as clarify — the
3123
+ # renderer answers terminal.read.respond with the serialized buffer.
3124
+ "read_terminal_callback": lambda start=None, count=None: _block(
3125
+ "terminal.read.request",
3126
+ sid,
3127
+ {k: v for k, v in (("start", start), ("count", count)) if v is not None},
3128
+ timeout=30,
3129
+ ),
1936
3130
  }
1937
3131
 
1938
3132
 
@@ -2092,6 +3286,29 @@ def _parse_tui_skills_env() -> list[str]:
2092
3286
  return skills
2093
3287
 
2094
3288
 
3289
+ def _load_fallback_model():
3290
+ """Return the configured fallback chain for TUI-created agents.
3291
+
3292
+ Delegates to the shared ``get_fallback_chain`` helper so the TUI path
3293
+ stays in parity with ``HermesCLI.__init__`` and ``gateway/run.py``:
3294
+ ``fallback_providers`` is the primary source of truth and keeps its
3295
+ order, with legacy ``fallback_model`` entries merged in afterwards
3296
+ (deduped on provider/model/base_url).
3297
+ """
3298
+ from hermes_cli.fallback_config import get_fallback_chain
3299
+
3300
+ return get_fallback_chain(_load_cfg())
3301
+
3302
+
3303
+ def _agent_fallback_model(agent):
3304
+ """Return an agent's fallback chain without rehydrating deliberately empty chains."""
3305
+ if hasattr(agent, "_fallback_chain"):
3306
+ return getattr(agent, "_fallback_chain") or []
3307
+ if hasattr(agent, "_fallback_model"):
3308
+ return getattr(agent, "_fallback_model", None)
3309
+ return _load_fallback_model()
3310
+
3311
+
2095
3312
  def _background_agent_kwargs(agent, task_id: str) -> dict:
2096
3313
  cfg = _load_cfg()
2097
3314
 
@@ -2126,7 +3343,7 @@ def _background_agent_kwargs(agent, task_id: str) -> dict:
2126
3343
  "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
2127
3344
  "platform": "tui",
2128
3345
  "session_db": _get_db(),
2129
- "fallback_model": getattr(agent, "_fallback_model", None),
3346
+ "fallback_model": _agent_fallback_model(agent),
2130
3347
  }
2131
3348
 
2132
3349
 
@@ -2261,11 +3478,19 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
2261
3478
  tokens = _set_session_context(session["session_key"])
2262
3479
  try:
2263
3480
  new_agent = _make_agent(
2264
- sid, session["session_key"], session_id=session["session_key"]
3481
+ sid,
3482
+ session["session_key"],
3483
+ session_id=session["session_key"],
3484
+ # Preserve this session's chosen model across /new so a reset
3485
+ # doesn't silently revert to global config (or to a model another
3486
+ # session set). See the cross-session-contamination note in
3487
+ # _apply_model_switch.
3488
+ model_override=session.get("model_override"),
2265
3489
  )
2266
3490
  finally:
2267
3491
  _clear_session_context(tokens)
2268
3492
  session["agent"] = new_agent
3493
+ session["config_model_seen"] = _config_model_target()
2269
3494
  session["attached_images"] = []
2270
3495
  session["edit_snapshots"] = {}
2271
3496
  session["image_counter"] = 0
@@ -2278,20 +3503,109 @@ def _reset_session_agent(sid: str, session: dict) -> dict:
2278
3503
  session["history_version"] = int(session.get("history_version", 0)) + 1
2279
3504
  info = _session_info(new_agent, session)
2280
3505
  _emit("session.info", sid, info)
2281
- _restart_slash_worker(session)
3506
+ _restart_slash_worker(sid, session)
2282
3507
  return info
2283
3508
 
2284
3509
 
2285
- def _make_agent(sid: str, key: str, session_id: str | None = None):
3510
+ def _schedule_mcp_late_refresh(sid: str, agent) -> None:
3511
+ """Refresh a session's tool snapshot when MCP discovery lands late.
3512
+
3513
+ The agent snapshots ``agent.tools`` once at build time and never re-reads
3514
+ the registry (run_agent/agent_init). ``_make_agent`` briefly joins the
3515
+ background MCP discovery thread (``wait_for_mcp_discovery``, ~0.75s) so
3516
+ already-spawning servers land in that snapshot — but a server that takes
3517
+ longer than the bound to connect (common for an HTTP MCP server on first
3518
+ connect) lands *after* the agent is built. Its tools are then absent from
3519
+ both the agent and the banner for the whole session, even though the
3520
+ classic CLI shows them (the CLI re-derives ``get_tool_definitions`` at
3521
+ banner render time, which re-waits, so it picks them up).
3522
+
3523
+ This schedules an off-critical-path daemon that waits for discovery to
3524
+ finish, then rebuilds the snapshot and re-emits ``session.info`` so both
3525
+ the agent's callable tools and the banner count catch up — the same
3526
+ rebuild ``/reload-mcp`` performs, but automatic.
3527
+
3528
+ Cache safety: the rebuild only runs while the session is still pre-first-
3529
+ turn (no API call made yet → nothing cached to invalidate). If the user
3530
+ has already sent a message, we leave the snapshot frozen rather than
3531
+ invalidate the prompt cache mid-conversation — those late tools then
3532
+ require an explicit ``/reload-mcp`` (which gates on user consent), exactly
3533
+ as today. No-op when discovery already finished before the agent build.
3534
+ """
3535
+ try:
3536
+ from tui_gateway.entry import mcp_discovery_in_flight, join_mcp_discovery
3537
+ except Exception:
3538
+ return
3539
+ if not mcp_discovery_in_flight():
3540
+ return
3541
+
3542
+ def _wait_then_refresh() -> None:
3543
+ # Bounded but generous — a server still not connected after this is
3544
+ # genuinely slow/dead; the user can /reload-mcp once it recovers.
3545
+ if not join_mcp_discovery(timeout=30.0):
3546
+ return
3547
+ with _sessions_lock:
3548
+ session = _sessions.get(sid)
3549
+ # Session may have been closed/reset while we waited.
3550
+ if session is None or session.get("agent") is not agent:
3551
+ return
3552
+ # Cache safety: never rebuild the tool list once the conversation
3553
+ # has started — that would invalidate the cached prompt prefix.
3554
+ if (
3555
+ int(getattr(agent, "_user_turn_count", 0) or 0) > 0
3556
+ or int(getattr(agent, "_api_call_count", 0) or 0) > 0
3557
+ ):
3558
+ return
3559
+ try:
3560
+ from tools.mcp_tool import refresh_agent_mcp_tools
3561
+
3562
+ added = refresh_agent_mcp_tools(agent, quiet_mode=True)
3563
+ except Exception as exc:
3564
+ logger.warning(
3565
+ "Late MCP refresh: tool snapshot rebuild failed for %s: %s",
3566
+ sid,
3567
+ exc,
3568
+ )
3569
+ return
3570
+ # No new tools landed (discovery added nothing) → don't churn the client.
3571
+ if not added:
3572
+ return
3573
+ info = _session_info(agent, session)
3574
+ # Emit outside the lock — write_json must not block under _sessions_lock.
3575
+ _emit("session.info", sid, info)
3576
+
3577
+ threading.Thread(
3578
+ target=_wait_then_refresh,
3579
+ name=f"tui-mcp-late-refresh-{sid}",
3580
+ daemon=True,
3581
+ ).start()
3582
+
3583
+
3584
+ def _make_agent(
3585
+ sid: str,
3586
+ key: str,
3587
+ session_id: str | None = None,
3588
+ session_db=None,
3589
+ model_override: dict | str | None = None,
3590
+ provider_override: str | None = None,
3591
+ reasoning_config_override: dict | None = None,
3592
+ service_tier_override: str | None = None,
3593
+ ):
2286
3594
  from run_agent import AIAgent
2287
3595
  from hermes_cli.runtime_provider import resolve_runtime_provider
2288
3596
 
2289
3597
  # MCP tool discovery runs in a background daemon thread at startup so a
2290
- # dead server can't freeze the shell (see tui_gateway/entry.py). The agent
2291
- # snapshots its tool list once here and never re-reads it, so briefly wait
2292
- # for in-flight discovery to land before building — bounded, so a slow/dead
2293
- # server still can't block. No-op once discovery has finished (every build
2294
- # after the first during a slow startup).
3598
+ # dead server can't freeze the shell. The agent snapshots its tool list
3599
+ # once here and never re-reads it, so briefly wait for in-flight discovery
3600
+ # to land before building — bounded, so a slow/dead server still can't
3601
+ # block. Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps
3602
+ # its existing tui_gateway.entry-owned thread.
3603
+ try:
3604
+ from hermes_cli.mcp_startup import wait_for_mcp_discovery
3605
+
3606
+ wait_for_mcp_discovery()
3607
+ except Exception:
3608
+ pass
2295
3609
  try:
2296
3610
  from tui_gateway.entry import wait_for_mcp_discovery
2297
3611
 
@@ -2316,11 +3630,62 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
2316
3630
  system_prompt = "\n\n".join(
2317
3631
  part for part in (system_prompt, skills_prompt) if part
2318
3632
  ).strip()
2319
- model, requested_provider = _resolve_startup_runtime()
2320
- runtime = resolve_runtime_provider(
2321
- requested=requested_provider,
2322
- target_model=model or None,
2323
- )
3633
+ # Prefer a per-session model override (set by a prior in-session /model
3634
+ # switch) over global config/env resolution. Resume-time stored sessions may
3635
+ # also pass scalar model/provider/runtime knobs from the persisted DB row.
3636
+ if isinstance(model_override, dict) and model_override.get("model"):
3637
+ model = str(model_override.get("model") or "")
3638
+ requested_provider = model_override.get("provider") or provider_override or None
3639
+ override_base_url = model_override.get("base_url")
3640
+ override_api_key = model_override.get("api_key")
3641
+ override_api_mode = model_override.get("api_mode")
3642
+ resolve_kwargs = {}
3643
+ if str(requested_provider or "").strip().lower() == "custom":
3644
+ # Session rows persisted before the custom-provider identity fix
3645
+ # (see _runtime_model_config) stored the resolved provider
3646
+ # "custom", which _get_named_custom_provider cannot match back to
3647
+ # a named ``providers:`` / ``custom_providers:`` entry — the
3648
+ # rebuild then either raised auth_unavailable, silently resolved
3649
+ # placeholder credentials against the patched-back base_url, or
3650
+ # (when no base_url was stored) routed to the OpenRouter default
3651
+ # with no key, surfacing as "No LLM provider configured". Recover
3652
+ # the entry identity from the persisted base_url, falling back to
3653
+ # the configured provider when the override carries no base_url
3654
+ # (the recurring Desktop/TUI regression vector).
3655
+ from hermes_cli.runtime_provider import canonical_custom_identity
3656
+
3657
+ recovered = canonical_custom_identity(base_url=override_base_url or None)
3658
+ if recovered:
3659
+ requested_provider = recovered
3660
+ if override_base_url:
3661
+ # Failing identity recovery, still hand the base_url to the
3662
+ # direct-alias branch so pool/env credentials resolve for it.
3663
+ resolve_kwargs["explicit_base_url"] = override_base_url
3664
+ runtime = resolve_runtime_provider(
3665
+ requested=requested_provider,
3666
+ target_model=model or None,
3667
+ **resolve_kwargs,
3668
+ )
3669
+ # The switch already resolved concrete credentials/endpoint; honor them
3670
+ # so a custom/named endpoint survives the rebuild even if global
3671
+ # resolution would pick a different one.
3672
+ if override_base_url:
3673
+ runtime["base_url"] = override_base_url
3674
+ if override_api_key:
3675
+ runtime["api_key"] = override_api_key
3676
+ if override_api_mode:
3677
+ runtime["api_mode"] = override_api_mode
3678
+ else:
3679
+ model, requested_provider = _resolve_startup_runtime()
3680
+ if isinstance(model_override, str) and model_override:
3681
+ model = model_override
3682
+ if provider_override:
3683
+ requested_provider = provider_override
3684
+ runtime = resolve_runtime_provider(
3685
+ requested=requested_provider,
3686
+ target_model=model or None,
3687
+ )
3688
+ _pr = _load_provider_routing()
2324
3689
  return AIAgent(
2325
3690
  model=model,
2326
3691
  max_iterations=_cfg_max_turns(cfg, 90),
@@ -2337,51 +3702,84 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
2337
3702
  # display detail). See cli.py PR (decoupling fix) for the matching
2338
3703
  # change on the classic CLI side.
2339
3704
  verbose_logging=False,
2340
- reasoning_config=_load_reasoning_config(),
2341
- service_tier=_load_service_tier(),
3705
+ reasoning_config=(
3706
+ reasoning_config_override
3707
+ if reasoning_config_override is not None
3708
+ else _load_reasoning_config()
3709
+ ),
3710
+ service_tier=(
3711
+ service_tier_override
3712
+ if service_tier_override is not None
3713
+ else _load_service_tier()
3714
+ ),
2342
3715
  enabled_toolsets=_load_enabled_toolsets(),
3716
+ # OpenRouter provider-routing prefs (config.yaml `provider_routing`).
3717
+ # Mirrors the messaging gateway + CLI so the desktop/TUI honors the same
3718
+ # routing instead of letting OpenRouter pick providers at random.
3719
+ providers_allowed=_pr.get("only"),
3720
+ providers_ignored=_pr.get("ignore"),
3721
+ providers_order=_pr.get("order"),
3722
+ provider_sort=_pr.get("sort"),
3723
+ provider_require_parameters=_pr.get("require_parameters", False),
3724
+ provider_data_collection=_pr.get("data_collection"),
2343
3725
  platform="tui",
2344
3726
  session_id=session_id or key,
2345
- session_db=_get_db(),
3727
+ session_db=session_db if session_db is not None else _get_db(),
2346
3728
  ephemeral_system_prompt=system_prompt or None,
2347
3729
  checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")),
2348
3730
  pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
2349
3731
  skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
2350
3732
  skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
3733
+ fallback_model=_load_fallback_model(),
2351
3734
  **_agent_cbs(sid),
2352
3735
  )
2353
3736
 
2354
3737
 
2355
- def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
3738
+ def _init_session(
3739
+ sid: str,
3740
+ key: str,
3741
+ agent,
3742
+ history: list,
3743
+ cols: int = 80,
3744
+ cwd: str | None = None,
3745
+ session_db=None,
3746
+ ):
2356
3747
  now = time.time()
2357
- _sessions[sid] = {
2358
- "agent": agent,
2359
- "session_key": key,
2360
- "history": history,
2361
- "history_lock": threading.Lock(),
2362
- "history_version": 0,
2363
- "inflight_turn": None,
2364
- "created_at": now,
2365
- "last_active": now,
2366
- "running": False,
2367
- "attached_images": [],
2368
- "image_counter": 0,
2369
- "cwd": _completion_cwd(),
2370
- "cols": cols,
2371
- "slash_worker": None,
2372
- "show_reasoning": _load_show_reasoning(),
2373
- "tool_progress_mode": _load_tool_progress_mode(),
2374
- "edit_snapshots": {},
2375
- "tool_started_at": {},
2376
- # Pin async event emissions to whichever transport created the
2377
- # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar).
2378
- "transport": current_transport() or _stdio_transport,
2379
- }
2380
- db = _get_db()
3748
+ with _sessions_lock:
3749
+ _sessions[sid] = {
3750
+ "agent": agent,
3751
+ "session_key": key,
3752
+ "history": history,
3753
+ "history_lock": threading.Lock(),
3754
+ "history_version": 0,
3755
+ "inflight_turn": None,
3756
+ "created_at": now,
3757
+ "last_active": now,
3758
+ "running": False,
3759
+ "attached_images": [],
3760
+ "image_counter": 0,
3761
+ "cwd": cwd or _completion_cwd(),
3762
+ "cols": cols,
3763
+ "slash_worker": None,
3764
+ "show_reasoning": _load_show_reasoning(),
3765
+ "tool_progress_mode": _load_tool_progress_mode(),
3766
+ "edit_snapshots": {},
3767
+ "tool_started_at": {},
3768
+ # Per-session model override set by an in-session /model switch.
3769
+ # Honored on rebuild (/new, resume) so a switch in THIS session
3770
+ # never leaks into siblings via process-global env vars.
3771
+ "model_override": None,
3772
+ # Pin async event emissions to whichever transport created the
3773
+ # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar).
3774
+ "transport": current_transport() or _stdio_transport,
3775
+ }
3776
+ db = session_db if session_db is not None else _get_db()
2381
3777
  if db is not None:
2382
3778
  row = db.get_session(key)
2383
3779
  if row and row.get("cwd"):
2384
- _sessions[sid]["cwd"] = row["cwd"]
3780
+ with _sessions_lock:
3781
+ if sid in _sessions:
3782
+ _sessions[sid]["cwd"] = row["cwd"]
2385
3783
  else:
2386
3784
  try:
2387
3785
  db.update_session_cwd(key, _sessions[sid]["cwd"])
@@ -2389,8 +3787,10 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
2389
3787
  logger.debug("failed to persist resumed session cwd", exc_info=True)
2390
3788
  _register_session_cwd(_sessions[sid])
2391
3789
  try:
2392
- _sessions[sid]["slash_worker"] = _SlashWorker(
2393
- key, getattr(agent, "model", _resolve_model())
3790
+ _attach_worker(
3791
+ sid,
3792
+ _sessions[sid],
3793
+ _SlashWorker(key, getattr(agent, "model", _resolve_model())),
2394
3794
  )
2395
3795
  except Exception:
2396
3796
  # Defer hard-failure to slash.exec; chat still works without slash worker.
@@ -2411,14 +3811,21 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
2411
3811
  agent.background_review_callback = lambda message, _sid=sid: _emit(
2412
3812
  "review.summary", _sid, {"text": str(message)}
2413
3813
  )
3814
+ # Honor display.memory_notifications (off | on | verbose) like the
3815
+ # messaging gateway and CLI do — otherwise the review always behaved as
3816
+ # "on" on the TUI/desktop and a user who set "off" was ignored.
3817
+ agent.memory_notifications = _load_memory_notifications()
2414
3818
  except Exception:
2415
3819
  # Bare AIAgents that don't expose the attribute (unlikely, but keep
2416
3820
  # session startup resilient).
2417
3821
  pass
2418
3822
  _wire_callbacks(sid)
2419
- _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
3823
+ with _sessions_lock:
3824
+ if sid in _sessions:
3825
+ _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid])
2420
3826
  _notify_session_boundary("on_session_reset", key)
2421
- _emit("session.info", sid, _session_info(agent, _sessions[sid]))
3827
+ _emit("session.info", sid, _session_info(agent, _sessions.get(sid, {})))
3828
+ _schedule_mcp_late_refresh(sid, agent)
2422
3829
 
2423
3830
 
2424
3831
  def _new_session_key() -> str:
@@ -2626,16 +4033,27 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
2626
4033
  {"role": "tool", "name": name, "context": _tool_ctx(name, args)}
2627
4034
  )
2628
4035
  continue
2629
- if not content_text.strip():
4036
+ # An assistant turn may carry only reasoning/thinking content with no
4037
+ # visible text (extended-thinking turns, thinking-only recovery
4038
+ # responses). Such a turn is persisted with its reasoning fields and is
4039
+ # recallable from the transcript, but dropping it here as "empty" makes
4040
+ # it vanish from the resumed/reloaded session view while the desktop's
4041
+ # reasoning disclosure has nothing to render. Keep it when it carries
4042
+ # reasoning so the "Thinking…" block still shows. (#44022)
4043
+ reasoning_keys = (
4044
+ "reasoning",
4045
+ "reasoning_content",
4046
+ "reasoning_details",
4047
+ "codex_reasoning_items",
4048
+ )
4049
+ has_reasoning = role == "assistant" and any(
4050
+ m.get(key) for key in reasoning_keys
4051
+ )
4052
+ if not content_text.strip() and not has_reasoning:
2630
4053
  continue
2631
4054
  msg = {"role": role, "text": content_text}
2632
4055
  if role == "assistant":
2633
- for key in (
2634
- "reasoning",
2635
- "reasoning_content",
2636
- "reasoning_details",
2637
- "codex_reasoning_items",
2638
- ):
4056
+ for key in reasoning_keys:
2639
4057
  if key in m and m.get(key) is not None:
2640
4058
  msg[key] = m.get(key)
2641
4059
  messages.append(msg)
@@ -2764,37 +4182,78 @@ def _(rid, params: dict) -> dict:
2764
4182
  explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
2765
4183
  except Exception:
2766
4184
  explicit_cwd = False
4185
+ resolved_cwd = _completion_cwd(params)
2767
4186
  _enable_gateway_prompts()
2768
4187
 
4188
+ # ``profile`` (app-global remote mode): a new chat started under a non-launch
4189
+ # profile must build its agent + persist against THAT profile's home/state.db,
4190
+ # not the dashboard's launch profile. Stored on the session so _start_agent_build
4191
+ # and each turn re-bind HERMES_HOME. None/own profile → launch (unchanged).
4192
+ profile = (params.get("profile") or "").strip() or None
4193
+ profile_home = _profile_home(profile)
4194
+
4195
+ # The desktop composer owns its model/effort/fast as plain UI state and ships
4196
+ # it on every session.create. Honor each as a PER-SESSION override (built into
4197
+ # the agent below) — never a global config write, so picking a model/effort
4198
+ # for a new chat can't mutate the profile default. provider is optional
4199
+ # (resolved at build).
4200
+ create_model = str(params.get("model") or "").strip()
4201
+ session_model_override = (
4202
+ {"model": create_model, "provider": str(params.get("provider") or "").strip() or None}
4203
+ if create_model
4204
+ else None
4205
+ )
4206
+ create_reasoning_override = None
4207
+ if effort := str(params.get("reasoning_effort") or "").strip():
4208
+ try:
4209
+ from hermes_constants import parse_reasoning_effort
4210
+
4211
+ create_reasoning_override = parse_reasoning_effort(effort)
4212
+ except Exception:
4213
+ create_reasoning_override = None
4214
+ # Only pin "fast" when explicitly requested; leaving it None lets the build
4215
+ # fall back to the profile default service tier rather than forcing normal.
4216
+ create_service_tier_override = "priority" if params.get("fast") else None
4217
+
2769
4218
  ready = threading.Event()
2770
4219
  now = time.time()
2771
-
2772
- _sessions[sid] = {
2773
- "agent": None,
2774
- "agent_error": None,
2775
- "agent_ready": ready,
2776
- "attached_images": [],
2777
- "cols": cols,
2778
- "created_at": now,
2779
- "edit_snapshots": {},
2780
- "explicit_cwd": explicit_cwd,
2781
- "history": history,
2782
- "history_lock": threading.Lock(),
2783
- "history_version": 0,
2784
- "image_counter": 0,
2785
- "cwd": _completion_cwd(params),
2786
- "inflight_turn": None,
2787
- "last_active": now,
2788
- "pending_title": title or None,
2789
- "running": False,
2790
- "session_key": key,
2791
- "show_reasoning": _load_show_reasoning(),
2792
- "slash_worker": None,
2793
- "tool_progress_mode": _load_tool_progress_mode(),
2794
- "tool_started_at": {},
2795
- "transport": current_transport() or _stdio_transport,
2796
- }
2797
- _register_session_cwd(_sessions[sid])
4220
+ lease, limit_message = _claim_active_session_slot(key, live_session_id=sid)
4221
+ if limit_message is not None:
4222
+ return _err(rid, 4090, limit_message)
4223
+
4224
+ with _sessions_lock:
4225
+ _sessions[sid] = {
4226
+ "agent": None,
4227
+ "agent_error": None,
4228
+ "agent_ready": ready,
4229
+ "attached_images": [],
4230
+ "close_on_disconnect": is_truthy_value(params.get("close_on_disconnect", False)),
4231
+ "active_session_lease": lease,
4232
+ "cols": cols,
4233
+ "created_at": now,
4234
+ "edit_snapshots": {},
4235
+ "explicit_cwd": explicit_cwd,
4236
+ "history": history,
4237
+ "history_lock": threading.Lock(),
4238
+ "history_version": 0,
4239
+ "image_counter": 0,
4240
+ "cwd": resolved_cwd,
4241
+ "inflight_turn": None,
4242
+ "last_active": now,
4243
+ "model_override": session_model_override,
4244
+ "create_reasoning_override": create_reasoning_override,
4245
+ "create_service_tier_override": create_service_tier_override,
4246
+ "pending_title": title or None,
4247
+ "profile_home": str(profile_home) if profile_home is not None else None,
4248
+ "running": False,
4249
+ "session_key": key,
4250
+ "show_reasoning": _load_show_reasoning(),
4251
+ "slash_worker": None,
4252
+ "tool_progress_mode": _load_tool_progress_mode(),
4253
+ "tool_started_at": {},
4254
+ "transport": current_transport() or _stdio_transport,
4255
+ }
4256
+ _register_session_cwd(_sessions[sid])
2798
4257
  # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop
2799
4258
  # launch (and every "New agent" / draft) opens a session here just to paint
2800
4259
  # the composer, so eagerly creating a row left an "Untitled" empty session
@@ -2823,7 +4282,20 @@ def _(rid, params: dict) -> dict:
2823
4282
  "message_count": len(history),
2824
4283
  "messages": _history_to_messages(history),
2825
4284
  "info": {
2826
- "model": _resolve_model(),
4285
+ # Reflect the per-session model override (desktop composer pick)
4286
+ # in the immediate response so the client doesn't briefly clobber
4287
+ # its sticky pick with the global default before the deferred
4288
+ # build's session.info lands.
4289
+ "model": (
4290
+ session_model_override.get("model")
4291
+ if session_model_override
4292
+ else _resolve_model()
4293
+ ),
4294
+ **(
4295
+ {"provider": session_model_override["provider"]}
4296
+ if session_model_override and session_model_override.get("provider")
4297
+ else {}
4298
+ ),
2827
4299
  "tools": {},
2828
4300
  "skills": {},
2829
4301
  "cwd": _sessions[sid]["cwd"],
@@ -2931,33 +4403,296 @@ def _(rid, params: dict) -> dict:
2931
4403
  target = params.get("session_id", "")
2932
4404
  if not target:
2933
4405
  return _err(rid, 4006, "session_id required")
2934
- db = _get_db()
4406
+ try:
4407
+ cols = int(params.get("cols", 80))
4408
+ except (TypeError, ValueError):
4409
+ cols = 80
4410
+ # ``profile`` (app-global remote mode): resume a session that lives in another
4411
+ # local profile's state.db. None/own profile → the launch profile (unchanged).
4412
+ profile = (params.get("profile") or "").strip() or None
4413
+ profile_home = _profile_home(profile)
4414
+
4415
+ # In a profile scope, the agent OWNS a long-lived db handle bound to that
4416
+ # profile (do NOT auto-close it here). Otherwise reuse the shared launch db.
4417
+ if profile_home is not None:
4418
+ from hermes_state import SessionDB
4419
+
4420
+ db = SessionDB(db_path=profile_home / "state.db")
4421
+ else:
4422
+ db = _get_db()
2935
4423
  if db is None:
2936
4424
  return _db_unavailable_error(rid, code=5000)
4425
+
2937
4426
  found = db.get_session(target)
2938
4427
  if not found:
2939
4428
  found = db.get_session_by_title(target)
2940
4429
  if found:
2941
4430
  target = found["id"]
4431
+ elif is_truthy_value(params.get("lazy", False)) and _child_run_active(target):
4432
+ # Race: a watch window opened on a freshly-spawned subagent. The
4433
+ # child relays `subagent.start` (which carries child_session_id and
4434
+ # triggers the window) BEFORE its first run_conversation() flushes
4435
+ # the DB row via _ensure_db_session, so db.get_session(target) is
4436
+ # momentarily empty. On slower hosts (notably WSL2, where SQLite +
4437
+ # process scheduling widen the gap) the window's resume consistently
4438
+ # lands inside this window and used to hard-fail "session not found"
4439
+ # — the frontend then 404'd on the REST messages fallback and the
4440
+ # window spun forever. The child is provably live (_child_run_active),
4441
+ # so proceed into the lazy branch with empty history; the live mirror
4442
+ # streams the whole turn anyway and the row exists by upgrade time.
4443
+ found = {}
2942
4444
  else:
2943
4445
  return _err(rid, 4007, "session not found")
4446
+
4447
+ # Follow the compression-continuation chain to the live tip so a resume on
4448
+ # a rotated-out parent id binds to the descendant that actually holds the
4449
+ # post-compression turns. Auto-compression ends the session and forks a
4450
+ # continuation child; without this, resuming the original id (the desktop's
4451
+ # routed id when the chat was opened before it rotated) reloads the parent
4452
+ # transcript and the response generated after compression is missing — the
4453
+ # "I came back and the reply isn't there" bug on large sessions. Resolving
4454
+ # here also re-anchors the fast path below so a still-live rotated session
4455
+ # is reused (by its new key) instead of rebuilding a duplicate agent on the
4456
+ # stale parent. Skipped for lazy watch windows, which intentionally attach
4457
+ # to the exact child branch they were opened on.
4458
+ if found and not is_truthy_value(params.get("lazy", False)):
4459
+ try:
4460
+ tip = db.resolve_resume_session_id(target)
4461
+ except Exception:
4462
+ tip = target
4463
+ if tip and tip != target:
4464
+ target = tip
4465
+ found = db.get_session(target) or found
4466
+
4467
+ profile_resume_cwd = str(found.get("cwd") or "").strip() or _profile_configured_cwd(
4468
+ profile_home
4469
+ )
4470
+
4471
+ def _reuse_live_payload(sid: str, session: dict) -> dict:
4472
+ payload = _live_session_payload(
4473
+ sid,
4474
+ session,
4475
+ cols=cols,
4476
+ touch=True,
4477
+ transport=current_transport() or _stdio_transport,
4478
+ )
4479
+ payload["resumed"] = target
4480
+ # A lazy watch session never owns a run loop, so its payload's running
4481
+ # flag is always False — overlay the child-run registry so a reconnecting
4482
+ # watch window keeps its busy indicator while the child is still mid-run.
4483
+ if session.get("agent") is None and _child_run_active(target):
4484
+ payload["running"] = True
4485
+ payload["status"] = "streaming"
4486
+ return payload
4487
+
4488
+ # Fast path: if the session is already live, reuse it under the lock.
4489
+ with _session_resume_lock:
4490
+ live = _find_live_session_by_key(target)
4491
+ if live is not None:
4492
+ return _ok(rid, _reuse_live_payload(*live))
4493
+
4494
+ # Lazy/watch resume: register the live session WITHOUT building an agent.
4495
+ # Used by the desktop's subagent windows — the child runs inside the
4496
+ # parent's turn, so its window only needs the stored history plus a
4497
+ # transport for the child-mirror's live events. Skipping _make_agent here
4498
+ # is what keeps the window cheap while the backend is busy running the
4499
+ # delegation. A later prompt.submit upgrades it via _start_agent_build
4500
+ # (resume_session_id keeps the upgrade on the stored conversation).
4501
+ if is_truthy_value(params.get("lazy", False)):
4502
+ sid = uuid.uuid4().hex[:8]
4503
+ lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)
4504
+ if limit_message is not None:
4505
+ return _err(rid, 4090, limit_message)
4506
+ try:
4507
+ db.reopen_session(target)
4508
+ # The child's OWN conversation only. Delegation children are
4509
+ # parent-linked rows, so include_ancestors would prepend the
4510
+ # parent's entire transcript — a watch window opened on a subagent
4511
+ # must show the subagent's branch, not the parent's prompt.
4512
+ history = db.get_messages_as_conversation(target)
4513
+ except Exception as e:
4514
+ if lease is not None:
4515
+ lease.release()
4516
+ return _err(rid, 5000, f"resume failed: {e}")
4517
+ messages = _history_to_messages(history)
4518
+ cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd())
4519
+ now = time.time()
4520
+ # A delegated child mid-run emits no native session events of its own —
4521
+ # report its liveness from the relay registry so the window paints a
4522
+ # busy indicator instead of a dead idle transcript.
4523
+ child_running = _child_run_active(target)
4524
+ with _session_resume_lock:
4525
+ live = _find_live_session_by_key(target)
4526
+ if live is not None:
4527
+ if lease is not None:
4528
+ lease.release()
4529
+ return _ok(rid, _reuse_live_payload(*live))
4530
+ with _sessions_lock:
4531
+ _sessions[sid] = {
4532
+ "agent": None,
4533
+ "agent_error": None,
4534
+ "agent_ready": threading.Event(),
4535
+ "attached_images": [],
4536
+ "close_on_disconnect": is_truthy_value(
4537
+ params.get("close_on_disconnect", False)
4538
+ ),
4539
+ "active_session_lease": lease,
4540
+ "cols": cols,
4541
+ "created_at": now,
4542
+ "display_history_prefix": [],
4543
+ "edit_snapshots": {},
4544
+ "explicit_cwd": False,
4545
+ "history": history,
4546
+ "history_lock": threading.Lock(),
4547
+ "history_version": 0,
4548
+ "image_counter": 0,
4549
+ "cwd": cwd,
4550
+ "inflight_turn": None,
4551
+ "last_active": now,
4552
+ "lazy": True,
4553
+ "pending_title": None,
4554
+ "profile_home": str(profile_home) if profile_home is not None else None,
4555
+ "resume_session_id": target,
4556
+ "running": False,
4557
+ "session_key": target,
4558
+ "show_reasoning": _load_show_reasoning(),
4559
+ "slash_worker": None,
4560
+ "tool_progress_mode": _load_tool_progress_mode(),
4561
+ "tool_started_at": {},
4562
+ "transport": current_transport() or _stdio_transport,
4563
+ }
4564
+ _register_session_cwd(_sessions[sid])
4565
+ return _ok(
4566
+ rid,
4567
+ {
4568
+ "session_id": sid,
4569
+ "resumed": target,
4570
+ "message_count": len(messages),
4571
+ "messages": messages,
4572
+ "info": {
4573
+ "cwd": cwd,
4574
+ "branch": _git_branch_for_cwd(cwd),
4575
+ "model": _resolve_model(),
4576
+ "tools": {},
4577
+ "skills": {},
4578
+ "lazy": True,
4579
+ "desktop_contract": DESKTOP_BACKEND_CONTRACT,
4580
+ "profile_name": _current_profile_name(),
4581
+ },
4582
+ "inflight": None,
4583
+ "running": child_running,
4584
+ "session_key": target,
4585
+ "started_at": now,
4586
+ "status": "streaming" if child_running else "idle",
4587
+ },
4588
+ )
4589
+
4590
+ # Build the agent OUTSIDE the lock — _make_agent can block for seconds
4591
+ # (MCP discovery, prompt/skill build, AIAgent construction). Holding
4592
+ # _session_resume_lock across it would stall session.close on the main
4593
+ # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs.
2944
4594
  sid = uuid.uuid4().hex[:8]
4595
+ lease, limit_message = _claim_active_session_slot(target, live_session_id=sid)
4596
+ if limit_message is not None:
4597
+ return _err(rid, 4090, limit_message)
2945
4598
  _enable_gateway_prompts()
4599
+ home_token = (
4600
+ set_hermes_home_override(str(profile_home)) if profile_home is not None else None
4601
+ )
2946
4602
  try:
2947
4603
  db.reopen_session(target)
2948
4604
  history = db.get_messages_as_conversation(target)
2949
4605
  display_history = db.get_messages_as_conversation(
2950
4606
  target, include_ancestors=True
2951
4607
  )
4608
+ display_history_prefix = display_history[
4609
+ : max(0, len(display_history) - len(history))
4610
+ ]
2952
4611
  messages = _history_to_messages(display_history)
2953
4612
  tokens = _set_session_context(target)
2954
4613
  try:
2955
- agent = _make_agent(sid, target, session_id=target)
4614
+ # Pass the profile's db so the agent persists turns to the right
4615
+ # state.db; home override is active here so config/skills/model
4616
+ # resolve to the profile too. Runtime identity is restored from the
4617
+ # stored session row so switching chats does not inherit whatever
4618
+ # global model another chat last selected.
4619
+ stored_runtime_overrides = _stored_session_runtime_overrides(found)
4620
+ agent = _make_agent(
4621
+ sid,
4622
+ target,
4623
+ session_id=target,
4624
+ session_db=db,
4625
+ **stored_runtime_overrides,
4626
+ )
2956
4627
  finally:
2957
4628
  _clear_session_context(tokens)
2958
- _init_session(sid, target, agent, history, cols=int(params.get("cols", 80)))
2959
4629
  except Exception as e:
4630
+ if lease is not None:
4631
+ lease.release()
2960
4632
  return _err(rid, 5000, f"resume failed: {e}")
4633
+ finally:
4634
+ if home_token is not None:
4635
+ reset_hermes_home_override(home_token)
4636
+
4637
+ # Double-checked locking: another concurrent resume may have created the
4638
+ # live session while we were building. Re-check under the lock; if it won,
4639
+ # discard our just-built agent and reuse theirs (no worker/poller wired yet).
4640
+ with _session_resume_lock:
4641
+ live = _find_live_session_by_key(target)
4642
+ if live is not None:
4643
+ try:
4644
+ if hasattr(agent, "close"):
4645
+ agent.close()
4646
+ except Exception:
4647
+ pass
4648
+ if lease is not None:
4649
+ lease.release()
4650
+ other_sid, other_session = live
4651
+ payload = _live_session_payload(
4652
+ other_sid,
4653
+ other_session,
4654
+ cols=cols,
4655
+ touch=True,
4656
+ transport=current_transport() or _stdio_transport,
4657
+ )
4658
+ payload["resumed"] = target
4659
+ return _ok(rid, payload)
4660
+ try:
4661
+ init_home_token = (
4662
+ set_hermes_home_override(str(profile_home))
4663
+ if profile_home is not None
4664
+ else None
4665
+ )
4666
+ try:
4667
+ _init_session(
4668
+ sid,
4669
+ target,
4670
+ agent,
4671
+ history,
4672
+ cols=cols,
4673
+ cwd=profile_resume_cwd,
4674
+ session_db=db,
4675
+ )
4676
+ finally:
4677
+ if init_home_token is not None:
4678
+ reset_hermes_home_override(init_home_token)
4679
+ if sid in _sessions:
4680
+ if stored_runtime_overrides.get("model_override") is not None:
4681
+ _sessions[sid]["model_override"] = stored_runtime_overrides[
4682
+ "model_override"
4683
+ ]
4684
+ _sessions[sid]["display_history_prefix"] = display_history_prefix
4685
+ # Remember the profile home so each turn re-binds HERMES_HOME (the
4686
+ # agent persists to its own db, but mid-turn home reads — memory,
4687
+ # skills — must resolve to the resumed profile too).
4688
+ if profile_home is not None:
4689
+ _sessions[sid]["profile_home"] = str(profile_home)
4690
+ _sessions[sid]["active_session_lease"] = lease
4691
+ except Exception as e:
4692
+ if lease is not None:
4693
+ lease.release()
4694
+ return _err(rid, 5000, f"resume failed: {e}")
4695
+ session = _sessions.get(sid) or {}
2961
4696
  return _ok(
2962
4697
  rid,
2963
4698
  {
@@ -2965,7 +4700,12 @@ def _(rid, params: dict) -> dict:
2965
4700
  "resumed": target,
2966
4701
  "message_count": len(messages),
2967
4702
  "messages": messages,
2968
- "info": _session_info(agent, _sessions.get(sid)),
4703
+ "info": _session_info(agent, session),
4704
+ "inflight": None,
4705
+ "running": False,
4706
+ "session_key": target,
4707
+ "started_at": float(session.get("created_at") or time.time()),
4708
+ "status": "idle",
2969
4709
  },
2970
4710
  )
2971
4711
 
@@ -3007,7 +4747,9 @@ def _session_live_status(sid: str, session: dict) -> str:
3007
4747
  if _session_pending_kind(sid):
3008
4748
  return "waiting"
3009
4749
  ready = session.get("agent_ready")
3010
- if ready is not None and not ready.is_set():
4750
+ # Unset + build never started = a lazy watch session sitting idle, not a
4751
+ # session stuck mid-construction.
4752
+ if ready is not None and not ready.is_set() and session.get("agent_build_started"):
3011
4753
  return "starting"
3012
4754
  if session.get("running"):
3013
4755
  return "working"
@@ -3058,6 +4800,15 @@ def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
3058
4800
  }
3059
4801
 
3060
4802
 
4803
+ def _find_live_session_by_key(session_key: str) -> tuple[str, dict] | None:
4804
+ for sid, session in list(_sessions.items()):
4805
+ if session.get("_finalized"):
4806
+ continue
4807
+ if str(session.get("session_key") or "") == session_key:
4808
+ return sid, session
4809
+ return None
4810
+
4811
+
3061
4812
  def _fallback_session_info(session: dict) -> dict:
3062
4813
  agent = session.get("agent")
3063
4814
  if agent is not None:
@@ -3071,6 +4822,41 @@ def _fallback_session_info(session: dict) -> dict:
3071
4822
  }
3072
4823
 
3073
4824
 
4825
+ def _live_session_payload(
4826
+ sid: str,
4827
+ session: dict,
4828
+ *,
4829
+ cols: int | None = None,
4830
+ touch: bool = False,
4831
+ transport: Transport | None = None,
4832
+ ) -> dict:
4833
+ with session["history_lock"]:
4834
+ if cols is not None:
4835
+ session["cols"] = cols
4836
+ if transport is not None:
4837
+ session["transport"] = transport
4838
+ if touch:
4839
+ session["last_active"] = time.time()
4840
+ history = list(session.get("display_history_prefix") or []) + list(
4841
+ session.get("history") or []
4842
+ )
4843
+ inflight = _inflight_snapshot(session)
4844
+ running = bool(session.get("running"))
4845
+ payload = {
4846
+ "info": _fallback_session_info(session),
4847
+ "message_count": len(history),
4848
+ "messages": _history_to_messages(history),
4849
+ "running": running,
4850
+ "session_id": sid,
4851
+ "session_key": session.get("session_key") or sid,
4852
+ "started_at": float(session.get("created_at") or time.time()),
4853
+ "status": _session_live_status(sid, session),
4854
+ }
4855
+ if inflight:
4856
+ payload["inflight"] = inflight
4857
+ return payload
4858
+
4859
+
3074
4860
  @method("session.active_list")
3075
4861
  def _(rid, params: dict) -> dict:
3076
4862
  """Return live TUI sessions in this gateway process.
@@ -3081,14 +4867,31 @@ def _(rid, params: dict) -> dict:
3081
4867
  """
3082
4868
  current = str(params.get("current_session_id") or "")
3083
4869
  try:
3084
- snapshot = list(_sessions.items())
4870
+ with _sessions_lock:
4871
+ snapshot = list(_sessions.items())
3085
4872
  except Exception as e:
3086
4873
  return _err(rid, 5036, f"could not enumerate active sessions: {e}")
3087
4874
 
4875
+ # Liveness filter (#38950): a session whose teardown has begun (``_finalized``)
4876
+ # is dead — its agent/worker are being released and it is no longer
4877
+ # attachable — but it can briefly remain in ``_sessions`` until the reaper
4878
+ # pops it (the WS grace-reap and idle reaper both set ``_finalized`` inside
4879
+ # ``_teardown_session`` before the pop). Counting these inflated the footer's
4880
+ # "N sessions" count, which only ever went up until a gateway restart. Drop
4881
+ # them here so the count reflects genuinely attachable sessions. We do NOT
4882
+ # filter on ``transport is _detached_ws_transport`` (the WS-detached drop
4883
+ # sentinel): a detached session is still attachable via a quick reconnect /
4884
+ # session.resume until the grace-reap finalizes it, and a standalone
4885
+ # ``hermes --tui`` session legitimately rides the real stdio transport and
4886
+ # must stay visible.
3088
4887
  # Keep the natural creation/insertion order from ``_sessions``. The
3089
4888
  # frontend marks the focused session with ``current``; it should not jump to
3090
4889
  # the top just because the user switched to it.
3091
- rows = [_session_live_item(sid, session, current) for sid, session in snapshot]
4890
+ rows = [
4891
+ _session_live_item(sid, session, current)
4892
+ for sid, session in snapshot
4893
+ if not session.get("_finalized")
4894
+ ]
3092
4895
  return _ok(rid, {"sessions": rows})
3093
4896
 
3094
4897
 
@@ -3103,28 +4906,16 @@ def _(rid, params: dict) -> dict:
3103
4906
  session, err = _sess_nowait({"session_id": sid}, rid)
3104
4907
  if err:
3105
4908
  return err
4909
+ assert session is not None
3106
4910
 
3107
- with session["history_lock"]:
3108
- session["last_active"] = time.time()
3109
- history = list(session.get("display_history") or session.get("history") or [])
3110
- inflight = _inflight_snapshot(session)
3111
- running = bool(session.get("running"))
3112
- status = _session_live_status(sid, session)
3113
- payload = {
3114
- "info": _fallback_session_info(session),
3115
- "message_count": len(history),
3116
- "messages": _history_to_messages(history),
3117
- "running": running,
3118
- "session_id": sid,
3119
- "session_key": session.get("session_key") or sid,
3120
- "started_at": float(session.get("created_at") or time.time()),
3121
- "status": status,
3122
- }
3123
- if inflight:
3124
- payload["inflight"] = inflight
3125
4911
  return _ok(
3126
4912
  rid,
3127
- payload,
4913
+ _live_session_payload(
4914
+ sid,
4915
+ session,
4916
+ touch=True,
4917
+ transport=current_transport() or _stdio_transport,
4918
+ ),
3128
4919
  )
3129
4920
 
3130
4921
 
@@ -3153,7 +4944,8 @@ def _(rid, params: dict) -> dict:
3153
4944
  # dictionary changed size during iteration``. If even the snapshot
3154
4945
  # raises, fail closed (refuse the delete) rather than fail open.
3155
4946
  try:
3156
- snapshot = list(_sessions.values())
4947
+ with _sessions_lock:
4948
+ snapshot = list(_sessions.values())
3157
4949
  except Exception as e:
3158
4950
  return _err(rid, 5036, f"could not enumerate active sessions: {e}")
3159
4951
  active = {s.get("session_key") for s in snapshot if s.get("session_key")}
@@ -3213,7 +5005,6 @@ def _(rid, params: dict) -> dict:
3213
5005
  session["pending_title"] = None
3214
5006
  return _ok(rid, {"pending": False, "title": title})
3215
5007
  # rowcount == 0 can mean "same value" as well as "missing row".
3216
- # Queue only when the session row truly does not exist yet.
3217
5008
  existing_row = db.get_session(key)
3218
5009
  if existing_row:
3219
5010
  session["pending_title"] = None
@@ -3224,6 +5015,23 @@ def _(rid, params: dict) -> dict:
3224
5015
  "title": (existing_row.get("title") or title),
3225
5016
  },
3226
5017
  )
5018
+ # No row yet (the DB write is deferred to the first prompt so empty
5019
+ # drafts don't litter the sidebar). An explicit /title is clear user
5020
+ # intent, not an abandoned draft — so persist the row NOW and set the
5021
+ # title, mirroring the messaging gateway's _handle_title_command. The
5022
+ # old behavior only queued pending_title and relied on the post-turn
5023
+ # apply block; if that turn never landed under this session_key the
5024
+ # title was silently lost and the sidebar fell back to the message
5025
+ # preview. Creating the row up front removes that race entirely. The
5026
+ # min-messages sidebar filter keeps a titled 0-message row hidden, so
5027
+ # a /title'd-but-never-used draft still doesn't clutter the list.
5028
+ _ensure_session_db_row(session)
5029
+ with _session_db(session) as scoped_db:
5030
+ if scoped_db is not None and scoped_db.set_session_title(key, title):
5031
+ session["pending_title"] = None
5032
+ return _ok(rid, {"pending": False, "title": title})
5033
+ # Row creation didn't take (DB unavailable, or a concurrent writer) —
5034
+ # fall back to queuing so the post-turn apply block can still recover.
3227
5035
  session["pending_title"] = title
3228
5036
  return _ok(rid, {"pending": True, "title": title})
3229
5037
  except ValueError as e:
@@ -3232,21 +5040,416 @@ def _(rid, params: dict) -> dict:
3232
5040
  return _err(rid, 5007, str(e))
3233
5041
 
3234
5042
 
3235
- @method("session.usage")
5043
+ @method("handoff.request")
3236
5044
  def _(rid, params: dict) -> dict:
5045
+ """Queue a handoff of this session to a messaging platform.
5046
+
5047
+ Desktop parity with the CLI ``/handoff`` command: we only write
5048
+ ``handoff_state='pending'`` onto the persisted session row. The actual
5049
+ transfer is performed by the separate ``hermes gateway`` process, whose
5050
+ ``_handoff_watcher`` claims the row, re-binds the session to the platform's
5051
+ home channel, and forges a synthetic turn. The desktop then polls
5052
+ ``handoff.state`` for the terminal result.
5053
+ """
3237
5054
  session, err = _sess_nowait(params, rid)
3238
5055
  if err:
3239
5056
  return err
3240
- agent = session.get("agent")
5057
+ if session.get("running"):
5058
+ return _err(
5059
+ rid,
5060
+ 4009,
5061
+ "session busy — wait for the current turn to finish, then retry the handoff",
5062
+ )
5063
+
5064
+ platform_name = (params.get("platform", "") or "").strip().lower()
5065
+ if not platform_name:
5066
+ return _err(rid, 4023, "platform required")
5067
+
5068
+ # Validate against the live gateway config — an unconfigured platform or a
5069
+ # missing home channel would leave the handoff pending forever, so reject
5070
+ # up front with a clear, actionable message (mirrors cli.py).
5071
+ try:
5072
+ from gateway.config import Platform, load_gateway_config
5073
+ except Exception as e: # pragma: no cover — gateway pkg always ships
5074
+ return _err(rid, 5021, f"could not load gateway config: {e}")
5075
+ try:
5076
+ platform = Platform(platform_name)
5077
+ except (ValueError, KeyError):
5078
+ return _err(rid, 4024, f"unknown platform '{platform_name}'")
5079
+ try:
5080
+ gw_config = load_gateway_config()
5081
+ except Exception as e:
5082
+ return _err(rid, 5021, f"could not load gateway config: {e}")
5083
+ pcfg = gw_config.platforms.get(platform)
5084
+ if not pcfg or not pcfg.enabled:
5085
+ return _err(
5086
+ rid,
5087
+ 4025,
5088
+ f"platform '{platform_name}' is not configured/enabled in the gateway",
5089
+ )
5090
+ home = gw_config.get_home_channel(platform)
5091
+ if not home or not home.chat_id:
5092
+ return _err(
5093
+ rid,
5094
+ 4026,
5095
+ f"no home channel configured for {platform_name} — set one with "
5096
+ "/sethome on the destination chat first",
5097
+ )
5098
+
5099
+ # The watcher transfers a persisted DB row, so make sure one exists even
5100
+ # for a brand-new empty chat (mirrors the CLI's set_session_title stub).
5101
+ _ensure_session_db_row(session)
5102
+
5103
+ with _session_db(session) as db:
5104
+ if db is None:
5105
+ return _db_unavailable_error(rid, code=5007)
5106
+ key = session["session_key"]
5107
+ try:
5108
+ if not db.get_session(key):
5109
+ db.set_session_title(key, f"handoff-{key[:8]}")
5110
+ ok = db.request_handoff(key, platform_name)
5111
+ except Exception as e:
5112
+ return _err(rid, 5007, str(e))
5113
+
5114
+ if not ok:
5115
+ return _err(
5116
+ rid,
5117
+ 4027,
5118
+ "session is already in flight for handoff — wait for it to settle, then retry",
5119
+ )
3241
5120
  return _ok(
3242
5121
  rid,
3243
- (
3244
- _get_usage(agent)
3245
- if agent is not None
3246
- else {"calls": 0, "input": 0, "output": 0, "total": 0}
3247
- ),
5122
+ {
5123
+ "queued": True,
5124
+ "session_key": key,
5125
+ "platform": platform_name,
5126
+ "home_name": home.name,
5127
+ },
5128
+ )
5129
+
5130
+
5131
+ @method("handoff.state")
5132
+ def _(rid, params: dict) -> dict:
5133
+ """Poll the handoff state for a session.
5134
+
5135
+ Returns ``{state, platform, error}`` where ``state`` is one of
5136
+ ``pending|running|completed|failed`` (or empty when no handoff record
5137
+ exists). Desktop polls this after ``handoff.request``.
5138
+ """
5139
+ session, err = _sess_nowait(params, rid)
5140
+ if err:
5141
+ return err
5142
+ with _session_db(session) as db:
5143
+ if db is None:
5144
+ return _db_unavailable_error(rid, code=5007)
5145
+ record = db.get_handoff_state(session["session_key"])
5146
+
5147
+ record = record or {}
5148
+ return _ok(
5149
+ rid,
5150
+ {
5151
+ "state": record.get("state") or "",
5152
+ "platform": record.get("platform") or "",
5153
+ "error": record.get("error") or "",
5154
+ },
5155
+ )
5156
+
5157
+
5158
+ @method("handoff.fail")
5159
+ def _(rid, params: dict) -> dict:
5160
+ """Mark an in-flight handoff as failed so the user can retry.
5161
+
5162
+ Desktop calls this when its bounded poll times out. Only pending/running
5163
+ rows are changed so a late success from the gateway watcher is not clobbered.
5164
+ """
5165
+ session, err = _sess_nowait(params, rid)
5166
+ if err:
5167
+ return err
5168
+ reason = str(params.get("error") or "handoff failed").strip()[:500]
5169
+ with _session_db(session) as db:
5170
+ if db is None:
5171
+ return _db_unavailable_error(rid, code=5007)
5172
+ key = session["session_key"]
5173
+ record = db.get_handoff_state(key) or {}
5174
+ state = record.get("state") or ""
5175
+ if state in {"pending", "running"}:
5176
+ db.fail_handoff(key, reason)
5177
+ return _ok(rid, {"failed": True, "state": "failed"})
5178
+
5179
+ return _ok(rid, {"failed": False, "state": state})
5180
+
5181
+
5182
+ @method("session.usage")
5183
+ def _(rid, params: dict) -> dict:
5184
+ session, err = _sess_nowait(params, rid)
5185
+ if err:
5186
+ return err
5187
+ agent = session.get("agent")
5188
+ usage: dict = (
5189
+ _get_usage(agent)
5190
+ if agent is not None
5191
+ else {"calls": 0, "input": 0, "output": 0, "total": 0}
5192
+ )
5193
+ # Nous credits block — agent-independent (a portal fetch), so it shows even
5194
+ # with zero API calls or on a resumed session. The TUI /usage panel renders
5195
+ # these lines regardless of `calls`. Fail-open: [] when not logged into Nous
5196
+ # or on any portal hiccup.
5197
+ try:
5198
+ from agent.account_usage import nous_credits_lines
5199
+
5200
+ credits = nous_credits_lines()
5201
+ if credits:
5202
+ usage["credits_lines"] = credits
5203
+ except Exception:
5204
+ pass
5205
+ return _ok(rid, usage)
5206
+
5207
+
5208
+ @method("credits.view")
5209
+ def _(rid, params: dict) -> dict:
5210
+ """Structured Nous credit view for the TUI /credits command.
5211
+
5212
+ Account-independent (a portal fetch gated on "a Nous account is logged in"),
5213
+ so it works with no live agent / on a resumed session — same as the /usage
5214
+ credits block. Returns the surface-agnostic CreditsView fields so the TUI can
5215
+ render a clickable top-up <Link>. Fail-open: a portal hiccup or logged-out
5216
+ account yields {logged_in: false}, never an error the user has to parse.
5217
+ """
5218
+ try:
5219
+ from agent.account_usage import build_credits_view
5220
+
5221
+ view = build_credits_view()
5222
+ return _ok(
5223
+ rid,
5224
+ {
5225
+ "logged_in": bool(view.logged_in),
5226
+ "balance_lines": [
5227
+ line for line in view.balance_lines if not line.lstrip().startswith("📈")
5228
+ ],
5229
+ "identity_line": view.identity_line,
5230
+ "topup_url": view.topup_url,
5231
+ "depleted": bool(view.depleted),
5232
+ },
5233
+ )
5234
+ except Exception:
5235
+ # Fail-open: TUI treats this as "not logged in" and shows the prompt.
5236
+ return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False})
5237
+
5238
+
5239
+ # ===========================================================================
5240
+ # Phase 2b terminal billing RPC methods
5241
+ # ===========================================================================
5242
+ #
5243
+ # These return STRUCTURED success envelopes (result.ok / result.error) rather
5244
+ # than JSON-RPC-level errors, so the TUI's rpc() promise always resolves and the
5245
+ # Ink side can branch on the typed billing error code (insufficient_scope,
5246
+ # rate_limited, no_payment_method, …) to render the right affordance instead of
5247
+ # landing in a generic catch. The data-building lives in the shared core
5248
+ # (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /credits.
5249
+
5250
+
5251
+ def _serialize_billing_error(exc) -> dict:
5252
+ """Map a BillingError into the result.error envelope the TUI branches on."""
5253
+ from hermes_cli.nous_billing import (
5254
+ BillingRateLimited,
5255
+ BillingScopeRequired,
3248
5256
  )
3249
5257
 
5258
+ kind = "error"
5259
+ if isinstance(exc, BillingScopeRequired):
5260
+ kind = "insufficient_scope"
5261
+ elif isinstance(exc, BillingRateLimited):
5262
+ kind = "rate_limited"
5263
+ elif getattr(exc, "error", None):
5264
+ kind = str(exc.error)
5265
+ return {
5266
+ "ok": False,
5267
+ "error": kind,
5268
+ "message": str(exc),
5269
+ "portal_url": getattr(exc, "portal_url", None),
5270
+ "retry_after": getattr(exc, "retry_after", None),
5271
+ "payload": getattr(exc, "payload", {}) or {},
5272
+ }
5273
+
5274
+
5275
+ def _serialize_billing_state(state) -> dict:
5276
+ """Serialize a BillingState for the wire (Decimals → strings, money-safe)."""
5277
+ from agent.billing_view import format_money
5278
+
5279
+ def _s(value):
5280
+ return None if value is None else str(value)
5281
+
5282
+ card = None
5283
+ if state.card is not None:
5284
+ card = {"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked}
5285
+ monthly_cap = None
5286
+ if state.monthly_cap is not None:
5287
+ mc = state.monthly_cap
5288
+ monthly_cap = {
5289
+ "limit_usd": _s(mc.limit_usd),
5290
+ "limit_display": format_money(mc.limit_usd),
5291
+ "spent_this_month_usd": _s(mc.spent_this_month_usd),
5292
+ "spent_display": format_money(mc.spent_this_month_usd),
5293
+ "is_default_ceiling": mc.is_default_ceiling,
5294
+ }
5295
+ auto_reload = None
5296
+ if state.auto_reload is not None:
5297
+ ar = state.auto_reload
5298
+ auto_reload = {
5299
+ "enabled": ar.enabled,
5300
+ "threshold_usd": _s(ar.threshold_usd),
5301
+ "threshold_display": format_money(ar.threshold_usd),
5302
+ "reload_to_usd": _s(ar.reload_to_usd),
5303
+ "reload_to_display": format_money(ar.reload_to_usd),
5304
+ }
5305
+ return {
5306
+ "ok": True,
5307
+ "logged_in": state.logged_in,
5308
+ "org_name": state.org_name,
5309
+ "org_slug": state.org_slug,
5310
+ "role": state.role,
5311
+ "is_admin": state.is_admin,
5312
+ "can_charge": state.can_charge,
5313
+ "balance_usd": _s(state.balance_usd),
5314
+ "balance_display": format_money(state.balance_usd),
5315
+ "cli_billing_enabled": state.cli_billing_enabled,
5316
+ "charge_presets": [_s(p) for p in state.charge_presets],
5317
+ "charge_presets_display": [format_money(p) for p in state.charge_presets],
5318
+ "min_usd": _s(state.min_usd),
5319
+ "max_usd": _s(state.max_usd),
5320
+ "card": card,
5321
+ "monthly_cap": monthly_cap,
5322
+ "auto_reload": auto_reload,
5323
+ "portal_url": state.portal_url,
5324
+ "error": state.error,
5325
+ }
5326
+
5327
+
5328
+ @method("billing.state")
5329
+ def _(rid, params: dict) -> dict:
5330
+ """GET /api/billing/state → serialized BillingState (Screen 1 + 5).
5331
+
5332
+ Fail-open like credits.view: a logged-out / unreachable portal yields
5333
+ {ok:true, logged_in:false}. No scope required for this endpoint.
5334
+ """
5335
+ try:
5336
+ from agent.billing_view import build_billing_state
5337
+
5338
+ state = build_billing_state()
5339
+ return _ok(rid, _serialize_billing_state(state))
5340
+ except Exception:
5341
+ return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})
5342
+
5343
+
5344
+ @method("billing.charge")
5345
+ def _(rid, params: dict) -> dict:
5346
+ """POST /api/billing/charge → {ok, chargeId} or a typed error envelope.
5347
+
5348
+ params: {amount_usd: str|number, idempotency_key?: str}. If no key is
5349
+ supplied, the server-side core mints a fresh one and returns it so the TUI can
5350
+ reuse it on retry of the SAME purchase.
5351
+ """
5352
+ from hermes_cli.nous_billing import BillingError, post_charge
5353
+ from agent.billing_view import new_idempotency_key
5354
+
5355
+ amount = params.get("amount_usd")
5356
+ if amount is None:
5357
+ return _ok(rid, {"ok": False, "error": "invalid_request", "message": "amount_usd is required"})
5358
+ key = params.get("idempotency_key") or new_idempotency_key()
5359
+ try:
5360
+ result = post_charge(amount_usd=amount, idempotency_key=key)
5361
+ return _ok(rid, {"ok": True, "charge_id": result.get("chargeId"), "idempotency_key": key})
5362
+ except BillingError as exc:
5363
+ env = _serialize_billing_error(exc)
5364
+ env["idempotency_key"] = key # so the TUI can reuse on retry
5365
+ return _ok(rid, env)
5366
+ except Exception as exc:
5367
+ return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key})
5368
+
5369
+
5370
+ @method("billing.charge_status")
5371
+ def _(rid, params: dict) -> dict:
5372
+ """GET /api/billing/charge/{id} → {ok, status, ...} or typed error.
5373
+
5374
+ The poll. Caller drives the 2s/5-min cadence; this is a single status read.
5375
+ """
5376
+ from hermes_cli.nous_billing import BillingError, get_charge_status
5377
+
5378
+ charge_id = params.get("charge_id")
5379
+ if not charge_id:
5380
+ return _ok(rid, {"ok": False, "error": "invalid_charge_id", "message": "charge_id is required"})
5381
+ try:
5382
+ result = get_charge_status(charge_id)
5383
+ return _ok(
5384
+ rid,
5385
+ {
5386
+ "ok": True,
5387
+ "status": result.get("status"),
5388
+ "amount_usd": result.get("amountUsd"),
5389
+ "settled_at": result.get("settledAt"),
5390
+ "reason": result.get("reason"),
5391
+ },
5392
+ )
5393
+ except BillingError as exc:
5394
+ return _ok(rid, _serialize_billing_error(exc))
5395
+ except Exception as exc:
5396
+ return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
5397
+
5398
+
5399
+ @method("billing.auto_reload")
5400
+ def _(rid, params: dict) -> dict:
5401
+ """PATCH /api/billing/auto-top-up → {ok:true} or typed error (Screen 2).
5402
+
5403
+ params: {enabled: bool, threshold: number, top_up_amount: number}.
5404
+ """
5405
+ from hermes_cli.nous_billing import BillingError, patch_auto_top_up
5406
+
5407
+ try:
5408
+ enabled = bool(params.get("enabled"))
5409
+ threshold = params.get("threshold")
5410
+ top_up_amount = params.get("top_up_amount")
5411
+ if threshold is None or top_up_amount is None:
5412
+ return _ok(rid, {"ok": False, "error": "invalid_request", "message": "threshold and top_up_amount are required"})
5413
+ patch_auto_top_up(enabled=enabled, threshold=threshold, top_up_amount=top_up_amount)
5414
+ return _ok(rid, {"ok": True})
5415
+ except BillingError as exc:
5416
+ return _ok(rid, _serialize_billing_error(exc))
5417
+ except Exception as exc:
5418
+ return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
5419
+
5420
+
5421
+ @method("billing.step_up")
5422
+ def _(rid, params: dict) -> dict:
5423
+ """Run the lazy billing:manage step-up device flow → {ok, granted}.
5424
+
5425
+ Triggered by the TUI after a billing call returns error=insufficient_scope.
5426
+ Returns granted:false when the server silently downscopes (non-admin / unticked).
5427
+
5428
+ Runs on the thread pool (in _LONG_HANDLERS): the device flow blocks for the
5429
+ whole device-code lifetime (minutes), so it must not stall the main stdin loop.
5430
+ The verification URL/code reach the TUI via an out-of-band ``billing.step_up.
5431
+ verification`` event (a plain print would be dropped by the JSON-RPC stdout
5432
+ pipe), and the browser is opened TUI-side via openExternalUrl — never with the
5433
+ gateway's headless webbrowser.open (hence open_browser=False).
5434
+ """
5435
+ sid = params.get("session_id") or ""
5436
+ try:
5437
+ from hermes_cli.auth import step_up_nous_billing_scope
5438
+
5439
+ def _on_verification(url: str, code: str) -> None:
5440
+ _emit(
5441
+ "billing.step_up.verification",
5442
+ sid,
5443
+ {"verification_url": url, "user_code": code},
5444
+ )
5445
+
5446
+ granted = step_up_nous_billing_scope(
5447
+ open_browser=False, on_verification=_on_verification
5448
+ )
5449
+ return _ok(rid, {"ok": True, "granted": bool(granted)})
5450
+ except Exception as exc:
5451
+ return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False})
5452
+
3250
5453
 
3251
5454
  @method("session.status")
3252
5455
  def _(rid, params: dict) -> dict:
@@ -3457,54 +5660,67 @@ def _(rid, params: dict) -> dict:
3457
5660
  session, err = _sess(params, rid)
3458
5661
  if err:
3459
5662
  return err
3460
- import time as _time
3461
5663
 
3462
- filename = os.path.abspath(
3463
- f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json"
3464
- )
5664
+ agent = session["agent"]
5665
+ # Mirror the classic CLI /save: snapshot under the Hermes profile home
5666
+ # (~/.hermes/sessions/saved/) rather than the project/workspace CWD, and
5667
+ # include the system prompt so the export matches the dashboard save.
5668
+ saved_dir = get_hermes_home() / "sessions" / "saved"
5669
+ try:
5670
+ saved_dir.mkdir(parents=True, exist_ok=True)
5671
+ except Exception as e:
5672
+ return _err(rid, 5011, f"failed to create save directory {saved_dir}: {e}")
5673
+
5674
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
5675
+ path = saved_dir / f"hermes_conversation_{timestamp}.json"
5676
+
5677
+ with session["history_lock"]:
5678
+ messages = list(session.get("history", []))
5679
+
5680
+ session_id = getattr(agent, "session_id", None) or session.get("session_key") or ""
5681
+ # Prefer the agent's session_start datetime (matches the classic CLI export);
5682
+ # fall back to the gateway session's created_at timestamp.
5683
+ agent_start = getattr(agent, "session_start", None)
5684
+ if isinstance(agent_start, datetime):
5685
+ session_start = agent_start.isoformat()
5686
+ else:
5687
+ created_at = session.get("created_at")
5688
+ session_start = (
5689
+ datetime.fromtimestamp(created_at).isoformat()
5690
+ if isinstance(created_at, (int, float))
5691
+ else ""
5692
+ )
5693
+
3465
5694
  try:
3466
- with open(filename, "w", encoding="utf-8") as f:
5695
+ with open(path, "w", encoding="utf-8") as f:
3467
5696
  json.dump(
3468
5697
  {
3469
- "model": getattr(session["agent"], "model", ""),
3470
- "messages": session.get("history", []),
5698
+ "model": getattr(agent, "model", ""),
5699
+ "session_id": session_id,
5700
+ "session_start": session_start,
5701
+ "system_prompt": getattr(agent, "_cached_system_prompt", "") or "",
5702
+ "messages": messages,
3471
5703
  },
3472
5704
  f,
3473
5705
  indent=2,
3474
5706
  ensure_ascii=False,
3475
5707
  )
3476
- return _ok(rid, {"file": filename})
5708
+ return _ok(rid, {"file": str(path)})
3477
5709
  except Exception as e:
3478
5710
  return _err(rid, 5011, str(e))
3479
5711
 
3480
5712
 
3481
- @method("session.close")
3482
- def _(rid, params: dict) -> dict:
3483
- sid = params.get("session_id", "")
3484
- session = _sessions.pop(sid, None)
3485
- if not session:
3486
- return _ok(rid, {"closed": False})
3487
- _finalize_session(session)
3488
- try:
3489
- from tools.approval import unregister_gateway_notify
3490
-
3491
- unregister_gateway_notify(session["session_key"])
3492
- except Exception:
3493
- pass
3494
- try:
3495
- agent = session.get("agent")
3496
- if agent and hasattr(agent, "close"):
3497
- agent.close()
3498
- except Exception:
3499
- pass
3500
- try:
3501
- worker = session.get("slash_worker")
3502
- if worker:
3503
- worker.close()
3504
- except Exception:
3505
- pass
3506
- return _ok(rid, {"closed": True})
3507
-
5713
+ @method("session.close")
5714
+ def _(rid, params: dict) -> dict:
5715
+ sid = params.get("session_id", "")
5716
+ # Serialize against the WS-orphan reaper (which also pops under
5717
+ # _session_resume_lock) so a disconnect-reap and an explicit close can't
5718
+ # both tear the same session down. _close_session_by_id is the single
5719
+ # idempotent teardown path (pop + _teardown_session) and returns False
5720
+ # when the session is already gone.
5721
+ with _session_resume_lock:
5722
+ return _ok(rid, {"closed": _close_session_by_id(sid, end_reason="tui_close")})
5723
+
3508
5724
 
3509
5725
  @method("session.branch")
3510
5726
  def _(rid, params: dict) -> dict:
@@ -3520,6 +5736,10 @@ def _(rid, params: dict) -> dict:
3520
5736
  if not history:
3521
5737
  return _err(rid, 4008, "nothing to branch — send a message first")
3522
5738
  new_key = _new_session_key()
5739
+ new_sid = uuid.uuid4().hex[:8]
5740
+ lease, limit_message = _claim_active_session_slot(new_key, live_session_id=new_sid)
5741
+ if limit_message is not None:
5742
+ return _err(rid, 4090, limit_message)
3523
5743
  branch_name = params.get("name", "")
3524
5744
  try:
3525
5745
  if branch_name:
@@ -3535,6 +5755,12 @@ def _(rid, params: dict) -> dict:
3535
5755
  new_key,
3536
5756
  source="tui",
3537
5757
  model=_resolve_model(),
5758
+ # Stable _branched_from marker so list_sessions_rich() keeps the
5759
+ # branch visible in /resume and /sessions. The TUI branch leaves
5760
+ # the parent live (no end_reason='branched'), so the legacy
5761
+ # end_reason heuristic never matches it — the marker is the only
5762
+ # thing that surfaces TUI branches. See issue #20856.
5763
+ model_config={"_branched_from": old_key},
3538
5764
  parent_session_id=old_key,
3539
5765
  cwd=_session_cwd(session),
3540
5766
  )
@@ -3546,8 +5772,9 @@ def _(rid, params: dict) -> dict:
3546
5772
  )
3547
5773
  db.set_session_title(new_key, title)
3548
5774
  except Exception as e:
5775
+ if lease is not None:
5776
+ lease.release()
3549
5777
  return _err(rid, 5008, f"branch failed: {e}")
3550
- new_sid = uuid.uuid4().hex[:8]
3551
5778
  try:
3552
5779
  tokens = _set_session_context(new_key)
3553
5780
  try:
@@ -3557,7 +5784,11 @@ def _(rid, params: dict) -> dict:
3557
5784
  _init_session(
3558
5785
  new_sid, new_key, agent, list(history), cols=session.get("cols", 80)
3559
5786
  )
5787
+ if new_sid in _sessions:
5788
+ _sessions[new_sid]["active_session_lease"] = lease
3560
5789
  except Exception as e:
5790
+ if lease is not None:
5791
+ lease.release()
3561
5792
  return _err(rid, 5000, f"agent init failed on branch: {e}")
3562
5793
  return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key})
3563
5794
 
@@ -3860,6 +6091,13 @@ def _(rid, params: dict) -> dict:
3860
6091
  with session["history_lock"]:
3861
6092
  if session.get("running"):
3862
6093
  return _err(rid, 4009, "session busy")
6094
+ # A watch session's run lives in the PARENT turn, so its own running
6095
+ # flag is False — without this, typing mid-run builds a second agent
6096
+ # racing the in-flight child on the same stored session (interleaved
6097
+ # transcript, stale fork). After the run completes, submitting is fine:
6098
+ # the upgrade resumes the child's transcript as a normal conversation.
6099
+ if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")):
6100
+ return _err(rid, 4009, "subagent still running — wait for it to finish")
3863
6101
  if truncate_user_ordinal is not None:
3864
6102
  try:
3865
6103
  ordinal = int(truncate_user_ordinal)
@@ -3924,7 +6162,8 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
3924
6162
  if evt_key == str(session.get("session_key") or ""):
3925
6163
  return False
3926
6164
  try:
3927
- snapshot = list(_sessions.values())
6165
+ with _sessions_lock:
6166
+ snapshot = list(_sessions.values())
3928
6167
  except Exception:
3929
6168
  # If we can't safely enumerate live sessions, fail open so we don't
3930
6169
  # crash the poller thread or drop the event.
@@ -3936,6 +6175,43 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool:
3936
6175
  )
3937
6176
 
3938
6177
 
6178
+ def _notification_event_dedup_key(evt: dict) -> tuple:
6179
+ """Return the UI-emission identity for a process notification event.
6180
+
6181
+ Completion events are terminal notifications for a background process, so
6182
+ they remain one-shot per process session. Watch-match events are not
6183
+ terminal: a single background process can legitimately match the same or
6184
+ different patterns many times, so include event-specific content to avoid
6185
+ suppressing later distinct matches from the same process.
6186
+ """
6187
+ evt_type = evt.get("type", "completion")
6188
+ evt_sid = evt.get("session_id", "")
6189
+ if evt_type == "watch_match":
6190
+ return (
6191
+ evt_sid,
6192
+ evt_type,
6193
+ evt.get("command", ""),
6194
+ evt.get("pattern", ""),
6195
+ evt.get("output", ""),
6196
+ evt.get("suppressed", 0),
6197
+ evt.get("message_id", ""),
6198
+ )
6199
+ if evt_type.startswith("watch_overflow_") or evt_type == "watch_disabled":
6200
+ return (
6201
+ evt_sid,
6202
+ evt_type,
6203
+ evt.get("command", ""),
6204
+ evt.get("message", ""),
6205
+ evt.get("suppressed", 0),
6206
+ )
6207
+ if evt_type == "async_delegation":
6208
+ # Async-delegation completions have no process session_id; without
6209
+ # this the fallthrough keys every one as ("", "async_delegation")
6210
+ # and the second completion's status update is suppressed forever.
6211
+ return (evt.get("delegation_id", ""), evt_type)
6212
+ return (evt_sid, evt_type)
6213
+
6214
+
3939
6215
  def _notification_poller_loop(
3940
6216
  stop_event: threading.Event, sid: str, session: dict
3941
6217
  ) -> None:
@@ -3952,6 +6228,7 @@ def _notification_poller_loop(
3952
6228
  """
3953
6229
  from tools.process_registry import process_registry, format_process_notification
3954
6230
 
6231
+ _emitted = set() # dedup re-queued events so same completion isn't emitted 50 times while session is busy
3955
6232
  while not stop_event.is_set() and not session.get("_finalized"):
3956
6233
  try:
3957
6234
  evt = process_registry.completion_queue.get(timeout=0.5)
@@ -3976,7 +6253,14 @@ def _notification_poller_loop(
3976
6253
  if not text:
3977
6254
  continue
3978
6255
 
3979
- _emit("status.update", sid, {"kind": "process", "text": text})
6256
+ # Only emit the same notification identity to TUI once — re-queued
6257
+ # completions get re-emitted every 0.5s otherwise when session is busy,
6258
+ # while distinct watch_match events from the same process must remain
6259
+ # visible independently.
6260
+ _dedup_key = _notification_event_dedup_key(evt)
6261
+ if _dedup_key not in _emitted:
6262
+ _emit("status.update", sid, {"kind": "process", "text": text})
6263
+ _emitted.add(_dedup_key)
3980
6264
 
3981
6265
  with session["history_lock"]:
3982
6266
  if session.get("running"):
@@ -4016,7 +6300,10 @@ def _notification_poller_loop(
4016
6300
  if not text:
4017
6301
  continue
4018
6302
 
4019
- _emit("status.update", sid, {"kind": "process", "text": text})
6303
+ _dedup_key = _notification_event_dedup_key(evt)
6304
+ if _dedup_key not in _emitted:
6305
+ _emit("status.update", sid, {"kind": "process", "text": text})
6306
+ _emitted.add(_dedup_key)
4020
6307
 
4021
6308
  with session["history_lock"]:
4022
6309
  if session.get("running"):
@@ -4068,6 +6355,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
4068
6355
  def run():
4069
6356
  approval_token = None
4070
6357
  session_tokens = []
6358
+ home_token = None # per-turn HERMES_HOME override for a resumed remote profile
4071
6359
  goal_followup = None # set by the post-turn goal hook below
4072
6360
  try:
4073
6361
  from tools.approval import (
@@ -4077,6 +6365,17 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
4077
6365
 
4078
6366
  approval_token = set_current_session_key(session["session_key"])
4079
6367
  session_tokens = _set_session_context(session["session_key"])
6368
+ _profile_home_str = session.get("profile_home")
6369
+ if _profile_home_str:
6370
+ home_token = set_hermes_home_override(_profile_home_str)
6371
+ # The sudo password callback is thread-local (tools.terminal_tool
6372
+ # _callback_tls), so wiring it on the build thread doesn't reach this
6373
+ # turn thread — terminal sudo prompts would fall through to /dev/tty
6374
+ # and hang the headless gateway. Re-wire here so the prompt routes to
6375
+ # the sudo.request overlay. (secret capture is a module global, so
6376
+ # re-running is a harmless no-op.)
6377
+ _wire_callbacks(sid)
6378
+ _sync_agent_model_with_config(sid, session)
4080
6379
  cwd = _session_cwd(session)
4081
6380
  _register_session_cwd(session)
4082
6381
  cols = session.get("cols", 80)
@@ -4398,6 +6697,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
4398
6697
  reset_current_session_key(approval_token)
4399
6698
  except Exception:
4400
6699
  pass
6700
+ if home_token is not None:
6701
+ reset_hermes_home_override(home_token)
4401
6702
  _clear_session_context(session_tokens)
4402
6703
  with session["history_lock"]:
4403
6704
  session["running"] = False
@@ -4546,6 +6847,465 @@ def _(rid, params: dict) -> dict:
4546
6847
  return _err(rid, 5027, str(e))
4547
6848
 
4548
6849
 
6850
+ # Byte-upload attach caps. 25 MB matches Anthropic's per-image limit; 50 MB / 25
6851
+ # pages bounds a single PDF drop so it can't blow the context budget.
6852
+ _ATTACH_BYTES_MAX_BYTES = 25 * 1024 * 1024
6853
+ _PDF_ATTACH_MAX_BYTES = 50 * 1024 * 1024
6854
+ _PDF_ATTACH_MAX_PAGES = 25
6855
+
6856
+ # Leading magic bytes → file extension, for filename-less uploads.
6857
+ _IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = (
6858
+ (b"\x89PNG\r\n\x1a\n", ".png"),
6859
+ (b"\xff\xd8\xff", ".jpg"),
6860
+ (b"GIF87a", ".gif"),
6861
+ (b"GIF89a", ".gif"),
6862
+ (b"BM", ".bmp"),
6863
+ )
6864
+
6865
+
6866
+ def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
6867
+ """Decode a base64 (optionally data-URL-wrapped) payload.
6868
+
6869
+ Accepts ``data:<mime_prefix>...;base64,<b64>`` plus embedded whitespace.
6870
+ Returns the decoded bytes, or ``None`` when the input isn't valid base64.
6871
+ """
6872
+ import base64 as _base64
6873
+ import re as _re
6874
+
6875
+ cleaned = raw.strip()
6876
+ m = _re.match(
6877
+ rf"^data:{_re.escape(mime_prefix)}[a-zA-Z0-9.+-]*;base64,(.*)$",
6878
+ cleaned,
6879
+ _re.DOTALL,
6880
+ )
6881
+ if m:
6882
+ cleaned = m.group(1)
6883
+ cleaned = _re.sub(r"\s+", "", cleaned)
6884
+ try:
6885
+ return _base64.b64decode(cleaned, validate=True)
6886
+ except Exception:
6887
+ return None
6888
+
6889
+
6890
+ def _sniff_image_ext(img_bytes: bytes, filename: str = "") -> str:
6891
+ """Resolve an image extension from a filename hint, else magic bytes.
6892
+
6893
+ Falls back to ``.png``. WebP needs the RIFF/WEBP container check, handled
6894
+ before the generic table.
6895
+ """
6896
+ if filename:
6897
+ suffix = Path(filename).suffix.lower()
6898
+ if suffix:
6899
+ return suffix
6900
+ head = img_bytes[:16]
6901
+ if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
6902
+ return ".webp"
6903
+ for sig, ext in _IMAGE_MAGIC:
6904
+ if head.startswith(sig):
6905
+ return ext
6906
+ return ".png"
6907
+
6908
+
6909
+ def _allowed_image_extensions() -> frozenset[str]:
6910
+ try:
6911
+ from cli import _IMAGE_EXTENSIONS
6912
+
6913
+ return frozenset(_IMAGE_EXTENSIONS)
6914
+ except Exception:
6915
+ return frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
6916
+
6917
+
6918
+ def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix: str) -> Path:
6919
+ """Write image bytes into the gateway's images dir and queue them.
6920
+
6921
+ Mirrors what ``image.attach`` does for a local path: appends to
6922
+ ``session["attached_images"]`` so the next ``prompt.submit`` picks it up via
6923
+ the existing native-image-attach pipeline. Returns the written path.
6924
+ """
6925
+ session["image_counter"] = session.get("image_counter", 0) + 1
6926
+ img_dir = _hermes_home / "images"
6927
+ img_dir.mkdir(parents=True, exist_ok=True)
6928
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
6929
+ img_path = img_dir / f"{prefix}_{ts}_{session['image_counter']}{ext}"
6930
+ try:
6931
+ img_path.write_bytes(img_bytes)
6932
+ except Exception:
6933
+ session["image_counter"] = max(0, session["image_counter"] - 1)
6934
+ raise
6935
+ session.setdefault("attached_images", []).append(str(img_path))
6936
+ return img_path
6937
+
6938
+
6939
+ @method("image.attach_bytes")
6940
+ def _(rid, params: dict) -> dict:
6941
+ """Attach an image to the session from base64 bytes (remote-client path).
6942
+
6943
+ A desktop app or web dashboard running on a DIFFERENT machine than the
6944
+ gateway can't hand us a local path — that file only exists on the client's
6945
+ disk. So it uploads the raw image bytes (base64) and we write them into the
6946
+ gateway's own images dir. The response shape mirrors ``image.attach`` so the
6947
+ client treats both identically.
6948
+
6949
+ Params:
6950
+ content_base64 / data (str, required): base64 image bytes. Accepts a
6951
+ ``data:image/...;base64,`` prefix and embedded whitespace. ``data`` is
6952
+ an accepted alias for older desktop builds.
6953
+ filename / ext (str, optional): extension hint. Without it, magic bytes
6954
+ identify PNG/JPEG/GIF/WebP/BMP, falling back to ``.png``.
6955
+ """
6956
+ session, err = _sess(params, rid)
6957
+ if err:
6958
+ return err
6959
+
6960
+ raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
6961
+ if not raw_b64:
6962
+ return _err(rid, 4015, "content_base64 required")
6963
+
6964
+ img_bytes = _decode_attach_base64(raw_b64, mime_prefix="image/")
6965
+ if img_bytes is None:
6966
+ return _err(rid, 4017, "data is not valid base64")
6967
+ if not img_bytes:
6968
+ return _err(rid, 4017, "image is empty")
6969
+ if len(img_bytes) > _ATTACH_BYTES_MAX_BYTES:
6970
+ mb = _ATTACH_BYTES_MAX_BYTES // (1024 * 1024)
6971
+ return _err(rid, 4018, f"image too large ({len(img_bytes)} bytes; cap is {mb} MB)")
6972
+
6973
+ filename = str(params.get("filename", "") or "")
6974
+ ext_hint = str(params.get("ext", "") or "").strip().lower()
6975
+ if ext_hint and not ext_hint.startswith("."):
6976
+ ext_hint = "." + ext_hint
6977
+ ext = _sniff_image_ext(img_bytes, filename or (f"x{ext_hint}" if ext_hint else ""))
6978
+ if ext not in _allowed_image_extensions():
6979
+ return _err(rid, 4016, f"unsupported image extension: {ext}")
6980
+
6981
+ try:
6982
+ img_path = _queue_attached_image(session, img_bytes, ext, prefix="upload")
6983
+ except Exception as e:
6984
+ return _err(rid, 5027, f"write failed: {e}")
6985
+
6986
+ return _ok(
6987
+ rid,
6988
+ {
6989
+ "attached": True,
6990
+ "path": str(img_path),
6991
+ "count": len(session["attached_images"]),
6992
+ "remainder": "",
6993
+ "text": f"[User attached image: {img_path.name}]",
6994
+ "bytes": len(img_bytes),
6995
+ **_image_meta(img_path),
6996
+ },
6997
+ )
6998
+
6999
+
7000
+ @method("pdf.attach")
7001
+ def _(rid, params: dict) -> dict:
7002
+ """Attach a PDF by rendering each page to PNG and queuing the pages.
7003
+
7004
+ Anthropic's vision pipeline accepts images, not PDFs, so this runs
7005
+ ``pdftoppm`` (poppler-utils) at 150 DPI per page and queues each rendered
7006
+ page as an attached image. Accepts either a host ``path`` (local mode) or
7007
+ base64 ``content_base64`` (remote upload). Caps at 50 MB / 25 pages per call.
7008
+
7009
+ Requires ``pdftoppm`` on $PATH (``apt install poppler-utils``); returns 5028
7010
+ if missing.
7011
+ """
7012
+ import shutil
7013
+ import subprocess
7014
+ import tempfile
7015
+
7016
+ session, err = _sess(params, rid)
7017
+ if err:
7018
+ return err
7019
+
7020
+ if shutil.which("pdftoppm") is None:
7021
+ return _err(rid, 5028, "pdftoppm not installed (poppler-utils package required)")
7022
+
7023
+ raw_path = str(params.get("path", "") or "").strip()
7024
+ raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
7025
+ if not raw_path and not raw_b64:
7026
+ return _err(rid, 4015, "path or content_base64 required")
7027
+
7028
+ with tempfile.TemporaryDirectory(prefix="pdf_attach_") as td:
7029
+ td_path = Path(td)
7030
+ if raw_b64:
7031
+ pdf_bytes = _decode_attach_base64(raw_b64, mime_prefix="application/pdf")
7032
+ if pdf_bytes is None:
7033
+ return _err(rid, 4017, "data is not valid base64")
7034
+ if not pdf_bytes:
7035
+ return _err(rid, 4017, "decoded PDF is empty")
7036
+ if len(pdf_bytes) > _PDF_ATTACH_MAX_BYTES:
7037
+ mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
7038
+ return _err(rid, 4018, f"PDF too large ({len(pdf_bytes)} bytes; cap is {mb} MB)")
7039
+ if pdf_bytes[:5] != b"%PDF-":
7040
+ return _err(rid, 4017, "payload is not a PDF (missing %PDF- magic bytes)")
7041
+ pdf_path = td_path / "input.pdf"
7042
+ pdf_path.write_bytes(pdf_bytes)
7043
+ display_name = str(params.get("filename", "") or "uploaded.pdf")
7044
+ else:
7045
+ try:
7046
+ from cli import _resolve_attachment_path
7047
+
7048
+ resolved = _resolve_attachment_path(raw_path)
7049
+ except Exception:
7050
+ resolved = None
7051
+ if resolved is None or not Path(resolved).is_file():
7052
+ return _err(rid, 4016, f"PDF not found: {raw_path}")
7053
+ if Path(resolved).suffix.lower() != ".pdf":
7054
+ return _err(rid, 4016, f"not a PDF: {Path(resolved).name}")
7055
+ if Path(resolved).stat().st_size > _PDF_ATTACH_MAX_BYTES:
7056
+ mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
7057
+ return _err(rid, 4018, f"PDF too large; cap is {mb} MB")
7058
+ pdf_path = Path(resolved)
7059
+ display_name = pdf_path.name
7060
+
7061
+ try:
7062
+ first_page = int(params.get("first_page") or 1)
7063
+ last_page_param = params.get("last_page")
7064
+ last_page = int(last_page_param) if last_page_param is not None else None
7065
+ except (TypeError, ValueError):
7066
+ return _err(rid, 4015, "first_page/last_page must be integers")
7067
+
7068
+ if first_page < 1:
7069
+ return _err(rid, 4015, "first_page must be >= 1")
7070
+ if last_page is None:
7071
+ last_page = first_page + _PDF_ATTACH_MAX_PAGES - 1
7072
+ if last_page < first_page:
7073
+ return _err(rid, 4015, "last_page must be >= first_page")
7074
+ if last_page - first_page + 1 > _PDF_ATTACH_MAX_PAGES:
7075
+ return _err(rid, 4019, f"page range exceeds cap of {_PDF_ATTACH_MAX_PAGES} pages per attach call")
7076
+
7077
+ out_prefix = td_path / "page"
7078
+ argv = [
7079
+ "pdftoppm", "-png", "-r", "150",
7080
+ "-f", str(first_page), "-l", str(last_page),
7081
+ str(pdf_path), str(out_prefix),
7082
+ ]
7083
+ try:
7084
+ res = subprocess.run(argv, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
7085
+ except subprocess.TimeoutExpired:
7086
+ return _err(rid, 5028, "pdftoppm timed out (>120s)")
7087
+ if res.returncode != 0:
7088
+ tail = (res.stderr or res.stdout or "").strip().splitlines()[-3:]
7089
+ return _err(rid, 5028, "pdftoppm failed: " + " | ".join(tail))
7090
+
7091
+ rendered = sorted(td_path.glob("page-*.png"))
7092
+ if not rendered:
7093
+ return _err(rid, 5028, "pdftoppm produced no pages (corrupt PDF?)")
7094
+
7095
+ attached_pages = []
7096
+ for src in rendered:
7097
+ page_num = src.stem.split("-", 1)[-1]
7098
+ try:
7099
+ page_int = int(page_num)
7100
+ except ValueError:
7101
+ page_int = first_page + len(attached_pages)
7102
+ dst = _queue_attached_image(session, src.read_bytes(), ".png", prefix=f"pdf_p{page_num}")
7103
+ attached_pages.append({"path": str(dst), "page": page_int, **_image_meta(dst)})
7104
+
7105
+ return _ok(
7106
+ rid,
7107
+ {
7108
+ "attached": True,
7109
+ "filename": display_name,
7110
+ "pages_attached": len(attached_pages),
7111
+ "pages": attached_pages,
7112
+ "count": len(session["attached_images"]),
7113
+ "text": f"[User attached PDF: {display_name} ({len(attached_pages)} page(s))]",
7114
+ },
7115
+ )
7116
+
7117
+
7118
+ _ATTACHMENT_REF_NEEDS_QUOTING_RE = None
7119
+
7120
+
7121
+ def _format_ref_value(value: str) -> str:
7122
+ """Quote a context-ref value when it contains whitespace or bracket chars.
7123
+
7124
+ Mirrors the desktop ``formatRefValue`` so the staged ``@file:`` ref round-trips
7125
+ through ``agent.context_references`` cleanly.
7126
+ """
7127
+ import re as _re
7128
+
7129
+ global _ATTACHMENT_REF_NEEDS_QUOTING_RE
7130
+ if _ATTACHMENT_REF_NEEDS_QUOTING_RE is None:
7131
+ _ATTACHMENT_REF_NEEDS_QUOTING_RE = _re.compile(r"""[\s()\[\]{}<>"'`]""")
7132
+ if not value or not _ATTACHMENT_REF_NEEDS_QUOTING_RE.search(value):
7133
+ return value
7134
+ if "`" not in value:
7135
+ return f"`{value}`"
7136
+ if '"' not in value:
7137
+ return f'"{value}"'
7138
+ if "'" not in value:
7139
+ return f"'{value}'"
7140
+ return value
7141
+
7142
+
7143
+ def _attachment_ref_path(session: dict, target: Path) -> str:
7144
+ """Workspace-relative path for an attachment, or the absolute path if outside."""
7145
+ workspace = Path(_session_cwd(session)).resolve()
7146
+ try:
7147
+ rel = target.resolve().relative_to(workspace)
7148
+ return str(rel).replace(os.sep, "/")
7149
+ except ValueError:
7150
+ return str(target.resolve())
7151
+
7152
+
7153
+ def _desktop_attachment_dir(session: dict) -> Path:
7154
+ root = Path(_session_cwd(session)).resolve() / ".hermes" / "desktop-attachments"
7155
+ root.mkdir(parents=True, exist_ok=True)
7156
+ return root
7157
+
7158
+
7159
+ def _sanitize_attachment_name(name: str) -> str:
7160
+ import re as _re
7161
+
7162
+ candidate = Path(str(name or "").strip()).name
7163
+ candidate = _re.sub(r"[\x00-\x1f]+", "_", candidate)
7164
+ candidate = candidate.strip().strip(".")
7165
+ return candidate or "attachment"
7166
+
7167
+
7168
+ def _unique_attachment_path(root: Path, filename: str) -> Path:
7169
+ candidate = root / filename
7170
+ if not candidate.exists():
7171
+ return candidate
7172
+ stem = Path(filename).stem or "attachment"
7173
+ suffix = Path(filename).suffix
7174
+ counter = 2
7175
+ while True:
7176
+ next_candidate = root / f"{stem}-{counter}{suffix}"
7177
+ if not next_candidate.exists():
7178
+ return next_candidate
7179
+ counter += 1
7180
+
7181
+
7182
+ def _resolve_gateway_attachment_path(raw: str) -> Path | None:
7183
+ """Resolve a raw path token to a gateway-visible file, or None."""
7184
+ if not raw:
7185
+ return None
7186
+ try:
7187
+ from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
7188
+ except Exception:
7189
+ return None
7190
+
7191
+ dropped = _detect_file_drop(raw)
7192
+ if dropped:
7193
+ return Path(dropped["path"]).resolve()
7194
+ path_token, _remainder = _split_path_input(raw)
7195
+ resolved = _resolve_attachment_path(path_token)
7196
+ return Path(resolved).resolve() if resolved is not None else None
7197
+
7198
+
7199
+ def _decode_attachment_data_url(data_url: str) -> bytes:
7200
+ """Decode a ``data:<any-mime>;base64,<b64>`` payload to bytes.
7201
+
7202
+ Unlike ``_decode_attach_base64`` (image-mime-specific), this accepts any
7203
+ media type — text/csv, application/pdf, etc. — so non-image file uploads
7204
+ round-trip. Also tolerates a bare base64 string with no data-URL prefix.
7205
+ """
7206
+ import base64 as _base64
7207
+ import binascii as _binascii
7208
+ import re as _re
7209
+
7210
+ cleaned = (data_url or "").strip()
7211
+ m = _re.match(r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", cleaned, _re.DOTALL | _re.I)
7212
+ if m:
7213
+ cleaned = m.group(1)
7214
+ cleaned = _re.sub(r"\s+", "", cleaned)
7215
+ try:
7216
+ return _base64.b64decode(cleaned, validate=True)
7217
+ except (ValueError, _binascii.Error) as exc:
7218
+ raise ValueError("invalid data_url payload") from exc
7219
+
7220
+
7221
+ def _stage_session_file_attachment(
7222
+ session: dict,
7223
+ *,
7224
+ raw_path: str,
7225
+ data_url: str,
7226
+ name: str,
7227
+ ) -> tuple[Path, bool]:
7228
+ """Make a desktop file attachment available to the remote gateway agent.
7229
+
7230
+ Three cases:
7231
+ 1. The path resolves to a file already INSIDE the session workspace — use
7232
+ it as-is (no copy, ``uploaded=False``).
7233
+ 2. The path resolves to a gateway-visible file OUTSIDE the workspace — copy
7234
+ it into ``.hermes/desktop-attachments/`` so the ``@file:`` ref resolves.
7235
+ 3. The path doesn't exist on the gateway (the common remote case: it's a
7236
+ path on the CLIENT's disk) — decode the uploaded ``data_url`` bytes and
7237
+ write them into ``.hermes/desktop-attachments/``.
7238
+
7239
+ Returns ``(stored_path, uploaded)``.
7240
+ """
7241
+ workspace = Path(_session_cwd(session)).resolve()
7242
+ resolved = _resolve_gateway_attachment_path(raw_path)
7243
+ if resolved is not None:
7244
+ try:
7245
+ resolved.relative_to(workspace)
7246
+ return resolved, False
7247
+ except ValueError:
7248
+ payload = resolved.read_bytes()
7249
+ filename = resolved.name
7250
+ else:
7251
+ if not data_url:
7252
+ raise ValueError("file not found on gateway and no data_url provided")
7253
+ payload = _decode_attachment_data_url(data_url)
7254
+ filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
7255
+
7256
+ upload_dir = _desktop_attachment_dir(session)
7257
+ target = _unique_attachment_path(upload_dir, _sanitize_attachment_name(filename))
7258
+ target.write_bytes(payload)
7259
+ return target.resolve(), True
7260
+
7261
+
7262
+ @method("file.attach")
7263
+ def _(rid, params: dict) -> dict:
7264
+ """Stage a non-image file attachment into the session workspace.
7265
+
7266
+ The image/PDF path renders to vision tiles; this one keeps the file as a
7267
+ readable artifact and returns a workspace-relative ``@file:`` ref so the
7268
+ agent's file tools (and ``agent.context_references``) can read it. Solves the
7269
+ remote-gateway case where the desktop passes a path that only exists on the
7270
+ CLIENT's disk: the client uploads ``data_url`` bytes and we materialize the
7271
+ file on the gateway.
7272
+
7273
+ Params:
7274
+ session_id (str, required)
7275
+ path (str): client/host path of the file (used for naming + local-mode
7276
+ gateway-visible resolution).
7277
+ data_url (str): ``data:<mime>;base64,<b64>`` upload of the file bytes,
7278
+ required when the path isn't visible to the gateway.
7279
+ name (str, optional): preferred filename.
7280
+ """
7281
+ session, err = _sess(params, rid)
7282
+ if err:
7283
+ return err
7284
+ raw = str(params.get("path", "") or "").strip()
7285
+ data_url = str(params.get("data_url", "") or "").strip()
7286
+ name = str(params.get("name", "") or "").strip()
7287
+ if not raw and not data_url:
7288
+ return _err(rid, 4015, "path or data_url required")
7289
+ try:
7290
+ stored_path, uploaded = _stage_session_file_attachment(
7291
+ session, raw_path=raw, data_url=data_url, name=name
7292
+ )
7293
+ ref_path = _attachment_ref_path(session, stored_path)
7294
+ return _ok(
7295
+ rid,
7296
+ {
7297
+ "attached": True,
7298
+ "name": stored_path.name,
7299
+ "path": str(stored_path),
7300
+ "ref_path": ref_path,
7301
+ "ref_text": f"@file:{_format_ref_value(ref_path)}",
7302
+ "uploaded": uploaded,
7303
+ },
7304
+ )
7305
+ except Exception as e:
7306
+ return _err(rid, 5028, str(e))
7307
+
7308
+
4549
7309
  @method("image.detach")
4550
7310
  def _(rid, params: dict) -> dict:
4551
7311
  session, err = _sess(params, rid)
@@ -4624,7 +7384,7 @@ def _(rid, params: dict) -> dict:
4624
7384
  task_id = f"bg_{uuid.uuid4().hex[:6]}"
4625
7385
 
4626
7386
  def run():
4627
- session_tokens = _set_session_context(task_id)
7387
+ session_tokens = _set_session_context(task_id, cwd=_session_cwd(session))
4628
7388
  try:
4629
7389
  from run_agent import AIAgent
4630
7390
 
@@ -4709,14 +7469,25 @@ def _(rid, params: dict) -> dict:
4709
7469
  if line
4710
7470
  )
4711
7471
 
7472
+ # Normalize defensively: a malformed client path (embedded NUL, etc.) must
7473
+ # not blow up the whole restart — treat it as "no validated cwd".
7474
+ try:
7475
+ preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else ""
7476
+ if preview_cwd and not os.path.isdir(preview_cwd):
7477
+ preview_cwd = ""
7478
+ except Exception:
7479
+ preview_cwd = ""
7480
+
4712
7481
  def run():
4713
- session_tokens = _set_session_context(task_id)
7482
+ # Pin the validated preview cwd, else the parent workspace — never an
7483
+ # invalid client path, which would silently fall back to the launch dir.
7484
+ session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session)))
4714
7485
  try:
4715
7486
  from run_agent import AIAgent
4716
7487
  from tools.terminal_tool import register_task_env_overrides
4717
7488
 
4718
- if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
4719
- register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
7489
+ if preview_cwd:
7490
+ register_task_env_overrides(task_id, {"cwd": preview_cwd})
4720
7491
 
4721
7492
  history_note = (
4722
7493
  f" (with {len(parent_history)} parent-session messages of context)"
@@ -4766,12 +7537,13 @@ def _(rid, params: dict) -> dict:
4766
7537
 
4767
7538
  def _respond(rid, params, key):
4768
7539
  r = params.get("request_id", "")
4769
- entry = _pending.get(r)
4770
- if not entry:
4771
- return _err(rid, 4009, f"no pending {key} request")
4772
- _, ev = entry
4773
- _answers[r] = params.get(key, "")
4774
- ev.set()
7540
+ with _prompt_lock:
7541
+ entry = _pending.get(r)
7542
+ if not entry:
7543
+ return _err(rid, 4009, f"no pending {key} request")
7544
+ _, ev = entry
7545
+ _answers[r] = params.get(key, "")
7546
+ ev.set()
4775
7547
  return _ok(rid, {"status": "ok"})
4776
7548
 
4777
7549
 
@@ -4780,6 +7552,12 @@ def _(rid, params: dict) -> dict:
4780
7552
  return _respond(rid, params, "answer")
4781
7553
 
4782
7554
 
7555
+ @method("terminal.read.respond")
7556
+ def _(rid, params: dict) -> dict:
7557
+ # `text` is a JSON string of the serialized terminal buffer + line metadata.
7558
+ return _respond(rid, params, "text")
7559
+
7560
+
4783
7561
  @method("sudo.respond")
4784
7562
  def _(rid, params: dict) -> dict:
4785
7563
  return _respond(rid, params, "password")
@@ -4839,7 +7617,11 @@ def _(rid, params: dict) -> dict:
4839
7617
  4009,
4840
7618
  "session busy — /interrupt the current turn before switching models",
4841
7619
  )
4842
- if session.get("agent") is None:
7620
+ from hermes_cli.model_switch import parse_model_flags
7621
+
7622
+ parsed_flags = parse_model_flags(value)
7623
+ _model_input, explicit_provider, _persist_global, _force_refresh, _is_session = parsed_flags
7624
+ if session.get("agent") is None and not explicit_provider.strip():
4843
7625
  session_id = params.get("session_id", "")
4844
7626
  _start_agent_build(session_id, session)
4845
7627
  init_err = _wait_agent(session, rid)
@@ -4848,13 +7630,32 @@ def _(rid, params: dict) -> dict:
4848
7630
  if session.get("agent") is None:
4849
7631
  return _err(rid, 5032, "agent initialization failed")
4850
7632
  result = _apply_model_switch(
4851
- params.get("session_id", ""), session, value
7633
+ params.get("session_id", ""),
7634
+ session,
7635
+ value,
7636
+ confirm_expensive_model=bool(
7637
+ params.get("confirm_expensive_model", False)
7638
+ ),
7639
+ parsed_flags=parsed_flags,
4852
7640
  )
4853
7641
  else:
4854
- result = _apply_model_switch("", {"agent": None}, value)
7642
+ result = _apply_model_switch(
7643
+ "",
7644
+ {"agent": None},
7645
+ value,
7646
+ confirm_expensive_model=bool(
7647
+ params.get("confirm_expensive_model", False)
7648
+ ),
7649
+ )
4855
7650
  return _ok(
4856
7651
  rid,
4857
- {"key": key, "value": result["value"], "warning": result["warning"]},
7652
+ {
7653
+ "key": key,
7654
+ "value": result["value"],
7655
+ "warning": result["warning"],
7656
+ "confirm_required": result.get("confirm_required", False),
7657
+ "confirm_message": result.get("confirm_message", ""),
7658
+ },
4858
7659
  )
4859
7660
  except Exception as e:
4860
7661
  return _err(rid, 5001, str(e))
@@ -4912,6 +7713,7 @@ def _(rid, params: dict) -> dict:
4912
7713
  if nv == "fast":
4913
7714
  current_overrides.update(overrides)
4914
7715
  agent.request_overrides = current_overrides
7716
+ _persist_live_session_runtime(session)
4915
7717
  _emit(
4916
7718
  "session.info",
4917
7719
  params.get("session_id", ""),
@@ -4954,30 +7756,79 @@ def _(rid, params: dict) -> dict:
4954
7756
  return _ok(rid, {"key": key, "value": nv})
4955
7757
 
4956
7758
  if key == "yolo":
7759
+ # Approval bypass. Two scopes:
7760
+ # scope="session" (default) — same as the TUI's Shift+Tab. Toggles
7761
+ # ONLY this session's _session_yolo flag; never touches global
7762
+ # config, so CLI / TUI / cron behavior is unaffected.
7763
+ # scope="global" (Shift+click the zap) — flips the persistent global
7764
+ # approvals.mode in config.yaml between "off" (bypass on) and
7765
+ # "manual" (bypass off). This DOES affect every session, the CLI,
7766
+ # the TUI, and cron, and survives restarts.
7767
+ scope = str(params.get("scope") or "session").strip().lower()
4957
7768
  try:
4958
- if session:
4959
- from tools.approval import (
4960
- disable_session_yolo,
4961
- enable_session_yolo,
4962
- is_session_yolo_enabled,
4963
- )
7769
+ from tools.approval import (
7770
+ disable_session_yolo,
7771
+ enable_session_yolo,
7772
+ is_session_yolo_enabled,
7773
+ )
7774
+
7775
+ raw = str(value or "").strip().lower()
7776
+
7777
+ def _resolve_toggle(current: bool) -> bool:
7778
+ if raw in {"1", "on", "true", "yes"}:
7779
+ return True
7780
+ if raw in {"0", "off", "false", "no"}:
7781
+ return False
7782
+ return not current
7783
+
7784
+ if scope == "global":
7785
+ from tools.approval import _normalize_approval_mode
4964
7786
 
7787
+ cfg = _load_cfg()
7788
+ appr = cfg.get("approvals") if isinstance(cfg, dict) else None
7789
+ if not isinstance(appr, dict):
7790
+ appr = {}
7791
+ current = _normalize_approval_mode(appr.get("mode", "manual")) == "off"
7792
+ enable = _resolve_toggle(current)
7793
+ # Toggle between full bypass and the default manual gate. We do
7794
+ # not try to restore a prior "smart"/custom mode — the zap is a
7795
+ # binary on/off affordance; users with bespoke modes set them in
7796
+ # config.yaml.
7797
+ _write_config_key("approvals.mode", "off" if enable else "manual")
7798
+ nv = "1" if enable else "0"
7799
+ # Reflect the global flip in every live session's indicator.
7800
+ for sid, sess in list(_sessions.items()):
7801
+ agent = sess.get("agent")
7802
+ if agent is not None:
7803
+ _emit("session.info", sid, _session_info(agent, sess))
7804
+ return _ok(rid, {"key": key, "value": nv, "scope": "global"})
7805
+
7806
+ if session:
4965
7807
  current = is_session_yolo_enabled(session["session_key"])
4966
- if current:
4967
- disable_session_yolo(session["session_key"])
4968
- nv = "0"
4969
- else:
7808
+ enable = _resolve_toggle(current)
7809
+ if enable:
4970
7810
  enable_session_yolo(session["session_key"])
4971
7811
  nv = "1"
7812
+ else:
7813
+ disable_session_yolo(session["session_key"])
7814
+ nv = "0"
7815
+ agent = session.get("agent")
7816
+ if agent is not None:
7817
+ _emit(
7818
+ "session.info",
7819
+ params.get("session_id", ""),
7820
+ _session_info(agent, session),
7821
+ )
4972
7822
  else:
4973
7823
  current = is_truthy_value(os.environ.get("HERMES_YOLO_MODE"))
4974
- if current:
4975
- os.environ.pop("HERMES_YOLO_MODE", None)
4976
- nv = "0"
4977
- else:
7824
+ enable = _resolve_toggle(current)
7825
+ if enable:
4978
7826
  os.environ["HERMES_YOLO_MODE"] = "1"
4979
7827
  nv = "1"
4980
- return _ok(rid, {"key": key, "value": nv})
7828
+ else:
7829
+ os.environ.pop("HERMES_YOLO_MODE", None)
7830
+ nv = "0"
7831
+ return _ok(rid, {"key": key, "value": nv, "scope": "session"})
4981
7832
  except Exception as e:
4982
7833
  return _err(rid, 5001, str(e))
4983
7834
 
@@ -5029,6 +7880,12 @@ def _(rid, params: dict) -> dict:
5029
7880
  _write_config_key("agent.reasoning_effort", arg)
5030
7881
  if session and session.get("agent") is not None:
5031
7882
  session["agent"].reasoning_config = parsed
7883
+ _persist_live_session_runtime(session)
7884
+ _emit(
7885
+ "session.info",
7886
+ params.get("session_id", ""),
7887
+ _session_info(session["agent"], session),
7888
+ )
5032
7889
  return _ok(rid, {"key": key, "value": arg})
5033
7890
  except Exception as e:
5034
7891
  return _err(rid, 5001, str(e))
@@ -5443,6 +8300,58 @@ def _(rid, params: dict) -> dict:
5443
8300
  return _err(rid, 5010, str(e))
5444
8301
 
5445
8302
 
8303
+ def _session_processes(session: dict) -> list:
8304
+ """Background processes owned by this session (registry session_key match)."""
8305
+ from tools.process_registry import process_registry
8306
+
8307
+ key = str(session.get("session_key") or "")
8308
+ owned = []
8309
+ for entry in process_registry.list_sessions():
8310
+ proc = process_registry.get(entry["session_id"])
8311
+ if proc is None or str(getattr(proc, "session_key", "") or "") != key:
8312
+ continue
8313
+ # The 200-char list preview is too thin for the desktop's inline
8314
+ # terminal viewer — ship a real tail alongside it.
8315
+ entry["output_tail"] = (proc.output_buffer or "")[-4000:]
8316
+ owned.append(entry)
8317
+ return owned
8318
+
8319
+
8320
+ @method("process.list")
8321
+ def _(rid, params: dict) -> dict:
8322
+ """Session-scoped view of the background process registry (desktop status stack)."""
8323
+ session, err = _sess(params, rid)
8324
+ if err:
8325
+ return err
8326
+ try:
8327
+ return _ok(rid, {"processes": _session_processes(session)})
8328
+ except Exception as e:
8329
+ return _err(rid, 5010, str(e))
8330
+
8331
+
8332
+ @method("process.kill")
8333
+ def _(rid, params: dict) -> dict:
8334
+ """Kill ONE background process — scoped to the caller's session so one
8335
+ window can't reap another session's work (unlike process.stop's kill_all)."""
8336
+ session, err = _sess(params, rid)
8337
+ if err:
8338
+ return err
8339
+ proc_id = str(params.get("process_id") or "")
8340
+ if not proc_id:
8341
+ return _err(rid, 4012, "process_id required")
8342
+ try:
8343
+ from tools.process_registry import process_registry
8344
+
8345
+ proc = process_registry.get(proc_id)
8346
+ if proc is None or str(getattr(proc, "session_key", "") or "") != str(
8347
+ session.get("session_key") or ""
8348
+ ):
8349
+ return _err(rid, 4044, f"no such process: {proc_id}")
8350
+ return _ok(rid, process_registry.kill_process(proc_id))
8351
+ except Exception as e:
8352
+ return _err(rid, 5010, str(e))
8353
+
8354
+
5446
8355
  @method("reload.mcp")
5447
8356
  def _(rid, params: dict) -> dict:
5448
8357
  session = _sessions.get(params.get("session_id", ""))
@@ -5498,16 +8407,15 @@ def _(rid, params: dict) -> dict:
5498
8407
  # The user already consented to the prompt-cache invalidation via
5499
8408
  # the confirm gate above. Mirrors gateway/run.py::_execute_mcp_reload.
5500
8409
  try:
5501
- from model_tools import get_tool_definitions
8410
+ from tools.mcp_tool import refresh_agent_mcp_tools
5502
8411
 
5503
- new_defs = get_tool_definitions(
5504
- enabled_toolsets=_load_enabled_toolsets(),
8412
+ # Explicit reload: re-resolve enabled toolsets so a server the
8413
+ # user just enabled in config this session is picked up.
8414
+ refresh_agent_mcp_tools(
8415
+ agent,
8416
+ enabled_override=_load_enabled_toolsets(),
5505
8417
  quiet_mode=True,
5506
8418
  )
5507
- agent.tools = new_defs
5508
- agent.valid_tool_names = (
5509
- {t["function"]["name"] for t in new_defs} if new_defs else set()
5510
- )
5511
8419
  except Exception as _exc:
5512
8420
  logger.warning(
5513
8421
  "Failed to refresh cached agent tools after /reload-mcp: %s",
@@ -5577,7 +8485,9 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [
5577
8485
 
5578
8486
  # Commands that queue messages onto _pending_input in the CLI.
5579
8487
  # In the TUI the slash worker subprocess has no reader for that queue,
5580
- # so slash.exec rejects them → TUI falls through to command.dispatch.
8488
+ # so slash.exec routes them to command.dispatch internally (which handles
8489
+ # them and returns a structured payload) instead of erroring out and
8490
+ # relying on a client-side fallback. See #48848.
5581
8491
  _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
5582
8492
  {
5583
8493
  "retry",
@@ -5725,6 +8635,7 @@ def _(rid, params: dict) -> dict:
5725
8635
  timeout=min(int(params.get("timeout", 240)), 600),
5726
8636
  cwd=os.getcwd(),
5727
8637
  env=os.environ.copy(),
8638
+ stdin=subprocess.DEVNULL,
5728
8639
  )
5729
8640
  parts = [r.stdout or "", r.stderr or ""]
5730
8641
  out = "\n".join(p for p in parts if p).strip() or "(no output)"
@@ -5785,6 +8696,7 @@ def _(rid, params: dict) -> dict:
5785
8696
  capture_output=True,
5786
8697
  text=True,
5787
8698
  timeout=30,
8699
+ stdin=subprocess.DEVNULL,
5788
8700
  )
5789
8701
  output = (
5790
8702
  (r.stdout or "")
@@ -6175,6 +9087,7 @@ def _list_repo_files(root: str) -> list[str]:
6175
9087
  capture_output=True,
6176
9088
  timeout=2.0,
6177
9089
  check=False,
9090
+ stdin=subprocess.DEVNULL,
6178
9091
  )
6179
9092
  if top_result.returncode == 0:
6180
9093
  top = top_result.stdout.decode("utf-8", "replace").strip()
@@ -6192,6 +9105,7 @@ def _list_repo_files(root: str) -> list[str]:
6192
9105
  capture_output=True,
6193
9106
  timeout=2.0,
6194
9107
  check=False,
9108
+ stdin=subprocess.DEVNULL,
6195
9109
  )
6196
9110
  if list_result.returncode == 0:
6197
9111
  for p in list_result.stdout.decode("utf-8", "replace").split("\0"):
@@ -6627,7 +9541,8 @@ def _(rid, params: dict) -> dict:
6627
9541
  picker_hints=True,
6628
9542
  canonical_order=True,
6629
9543
  pricing=True,
6630
- max_models=50,
9544
+ capabilities=True,
9545
+ refresh=bool(params.get("refresh")),
6631
9546
  )
6632
9547
  return _ok(rid, payload)
6633
9548
  except Exception as e:
@@ -6839,8 +9754,16 @@ def _(rid, params: dict) -> dict:
6839
9754
  _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else ""
6840
9755
 
6841
9756
  if _cmd_base in _PENDING_INPUT_COMMANDS:
6842
- return _err(
6843
- rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}"
9757
+ # Route directly to command.dispatch instead of returning an error
9758
+ # that requires the frontend to retry. Some TUI clients fail the
9759
+ # fallback, leaving the command empty and showing "empty command".
9760
+ return _methods["command.dispatch"](
9761
+ rid,
9762
+ {
9763
+ "name": _cmd_base,
9764
+ "arg": _cmd_arg,
9765
+ "session_id": params.get("session_id", ""),
9766
+ },
6844
9767
  )
6845
9768
 
6846
9769
  if _cmd_base in _WORKER_BLOCKED_COMMANDS:
@@ -6891,7 +9814,7 @@ def _(rid, params: dict) -> dict:
6891
9814
  session["session_key"],
6892
9815
  getattr(session.get("agent"), "model", _resolve_model()),
6893
9816
  )
6894
- session["slash_worker"] = worker
9817
+ _attach_worker(params.get("session_id", ""), session, worker)
6895
9818
  except Exception as e:
6896
9819
  return _err(rid, 5030, f"slash worker start failed: {e}")
6897
9820
 
@@ -7899,7 +10822,83 @@ def _(rid, params: dict) -> dict:
7899
10822
  return _err(rid, 5025, str(e))
7900
10823
 
7901
10824
 
7902
- # ── Methods: shell ───────────────────────────────────────────────────
10825
+ @method("plugins.manage")
10826
+ def _(rid, params: dict) -> dict:
10827
+ """List installed plugins with activation state, or toggle one on/off.
10828
+
10829
+ Backs the TUI Plugins Hub. Uses the same disk-discovery + enable/disable
10830
+ primitives as ``hermes plugins`` / the dashboard, so the three surfaces
10831
+ agree on what's installed and what's enabled.
10832
+
10833
+ Actions:
10834
+ - ``list`` → {"plugins": [{name, version, description, source,
10835
+ status}], "user_count": N, "bundled_count": M}
10836
+ - ``toggle`` → flip ``name`` based on ``enable`` (bool). Returns the
10837
+ refreshed row plus {"ok", "unchanged"}.
10838
+ """
10839
+ action = params.get("action", "list")
10840
+ try:
10841
+ from hermes_cli.plugins_cmd import (
10842
+ _discover_all_plugins,
10843
+ _get_disabled_set,
10844
+ _get_enabled_set,
10845
+ _plugin_status,
10846
+ )
10847
+
10848
+ def _rows():
10849
+ enabled = _get_enabled_set()
10850
+ disabled = _get_disabled_set()
10851
+ out = []
10852
+ for name, version, desc, source, _dir, key in sorted(
10853
+ _discover_all_plugins()
10854
+ ):
10855
+ out.append(
10856
+ {
10857
+ "name": name,
10858
+ "version": str(version or ""),
10859
+ "description": desc or "",
10860
+ "source": source,
10861
+ "status": _plugin_status(name, enabled, disabled, key=key),
10862
+ }
10863
+ )
10864
+ return out
10865
+
10866
+ if action == "list":
10867
+ rows = _rows()
10868
+ user_count = sum(1 for r in rows if r["source"] != "bundled")
10869
+ return _ok(
10870
+ rid,
10871
+ {
10872
+ "plugins": rows,
10873
+ "user_count": user_count,
10874
+ "bundled_count": len(rows) - user_count,
10875
+ },
10876
+ )
10877
+
10878
+ if action == "toggle":
10879
+ from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled
10880
+
10881
+ name = (params.get("name") or "").strip()
10882
+ if not name:
10883
+ return _err(rid, 4019, "plugins.toggle requires a 'name'")
10884
+ enable = bool(params.get("enable"))
10885
+ result = dashboard_set_agent_plugin_enabled(name, enabled=enable)
10886
+ if not result.get("ok"):
10887
+ return _err(rid, 5026, result.get("error") or "toggle failed")
10888
+ row = next((r for r in _rows() if r["name"] == name), None)
10889
+ return _ok(
10890
+ rid,
10891
+ {
10892
+ "ok": True,
10893
+ "unchanged": bool(result.get("unchanged")),
10894
+ "name": name,
10895
+ "plugin": row,
10896
+ },
10897
+ )
10898
+
10899
+ return _err(rid, 4017, f"unknown plugins action: {action}")
10900
+ except Exception as e:
10901
+ return _err(rid, 5026, str(e))
7903
10902
 
7904
10903
 
7905
10904
  @method("shell.exec")
@@ -7908,18 +10907,24 @@ def _(rid, params: dict) -> dict:
7908
10907
  if not cmd:
7909
10908
  return _err(rid, 4004, "empty command")
7910
10909
  try:
7911
- from tools.approval import detect_dangerous_command
10910
+ from tools.approval import detect_dangerous_command, detect_hardline_command
7912
10911
 
10912
+ is_hardline, hardline_desc = detect_hardline_command(cmd)
10913
+ if is_hardline:
10914
+ return _err(
10915
+ rid, 4005, f"blocked (hardline): {hardline_desc}. Use the agent for dangerous commands."
10916
+ )
7913
10917
  is_dangerous, _, desc = detect_dangerous_command(cmd)
7914
10918
  if is_dangerous:
7915
10919
  return _err(
7916
10920
  rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands."
7917
10921
  )
7918
10922
  except ImportError:
7919
- pass
10923
+ return _err(rid, 5001, "shell.exec unavailable: approval safety module not importable")
7920
10924
  try:
7921
10925
  r = subprocess.run(
7922
- cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()
10926
+ cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd(),
10927
+ stdin=subprocess.DEVNULL,
7923
10928
  )
7924
10929
  return _ok(
7925
10930
  rid,