@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
@@ -0,0 +1,3854 @@
1
+ """Gateway slash-command handlers for GatewayRunner.
2
+
3
+ Extracted from ``gateway/run.py`` (god-file decomposition Phase 3b). These are
4
+ the in-session slash commands (/model, /reset, /usage, /compress, ...) the
5
+ gateway dispatches from ``_handle_message``. There are 42 of them (~3,200 LOC);
6
+ lifting them into a mixin that ``GatewayRunner`` inherits keeps every
7
+ ``self._handle_*_command`` dispatch + test reference working via the MRO, while
8
+ removing the bulk from run.py.
9
+
10
+ Module-level run.py helpers a handler needs (``_hermes_home``,
11
+ ``_load_gateway_config``, ``_resolve_gateway_model``, etc.) are imported lazily
12
+ inside the handler body — a deferred ``from gateway.run import ...`` resolves at
13
+ call time (run.py fully loaded by then), avoiding an import cycle.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import dataclasses
20
+ import hashlib
21
+ import inspect
22
+ import logging
23
+ import os
24
+ import re
25
+ import shlex
26
+ import sys
27
+ import time
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Any, Optional, Union
31
+
32
+ from agent.account_usage import fetch_account_usage, render_account_usage_lines
33
+ from agent.i18n import t
34
+ from gateway.config import HomeChannel, Platform, PlatformConfig
35
+ from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
36
+ from gateway.session import SessionSource, build_session_key
37
+ from hermes_cli.config import cfg_get
38
+ from utils import (
39
+ atomic_json_write,
40
+ atomic_yaml_write,
41
+ base_url_host_matches,
42
+ is_truthy_value,
43
+ )
44
+
45
+ logger = logging.getLogger("gateway.run")
46
+
47
+
48
+ class GatewaySlashCommandsMixin:
49
+ """In-session slash-command handlers for GatewayRunner."""
50
+
51
+ def _typed_command_prefix_for(self, platform) -> str:
52
+ """Return the prefix users can always type to reach Hermes commands.
53
+
54
+ Reads the adapter's ``typed_command_prefix`` capability flag
55
+ (default "/"). Slack and Matrix return "!" because typed "/"
56
+ commands are blocked in Slack threads / reserved by Matrix clients;
57
+ their adapters rewrite "!command" to "/command" on receive.
58
+ Instruction text built for those platforms must show the prefix
59
+ that actually works when typed.
60
+ """
61
+ adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
62
+ return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
63
+
64
+ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
65
+ """Handle /new or /reset command."""
66
+ source = event.source
67
+
68
+ # Get existing session key
69
+ session_key = self._session_key_for_source(source)
70
+ self._invalidate_session_run_generation(session_key, reason="session_reset")
71
+ # Evict the running-agent slot now that the generation is bumped. The
72
+ # in-flight run's own guarded release (run_generation=old) will return
73
+ # False and leave its dead agent behind; clearing here keeps the slot
74
+ # from becoming a zombie that silently drops all later messages (#28686).
75
+ # Idempotent, so the run's finally calling it again is harmless.
76
+ self._release_running_agent_state(session_key)
77
+
78
+ # Snapshot the old entry so on_session_finalize can report the
79
+ # expiring session id before reset_session() rotates it.
80
+ old_entry = self.session_store._entries.get(session_key)
81
+
82
+ # Close tool resources on the old agent (terminal sandboxes, browser
83
+ # daemons, background processes) before evicting from cache.
84
+ # Guard with getattr because test fixtures may skip __init__.
85
+ _cache_lock = getattr(self, "_agent_cache_lock", None)
86
+ if _cache_lock is not None:
87
+ with _cache_lock:
88
+ _cached = self._agent_cache.get(session_key)
89
+ _old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None
90
+ if _old_agent is not None:
91
+ self._cleanup_agent_resources(_old_agent)
92
+ self._evict_cached_agent(session_key)
93
+
94
+ # Discard any /queue overflow for this session — /new is a
95
+ # conversation-boundary operation, queued follow-ups from the
96
+ # previous conversation must not bleed into the new one.
97
+ _qe = getattr(self, "_queued_events", None)
98
+ if _qe is not None:
99
+ _qe.pop(session_key, None)
100
+
101
+ try:
102
+ from tools.env_passthrough import clear_env_passthrough
103
+ clear_env_passthrough()
104
+ except Exception:
105
+ pass
106
+
107
+ try:
108
+ from tools.credential_files import clear_credential_files
109
+ clear_credential_files()
110
+ except Exception:
111
+ pass
112
+
113
+ # Reset the session
114
+ new_entry = self.session_store.reset_session(session_key)
115
+
116
+ # Clear any session-scoped model/reasoning overrides so the next agent
117
+ # picks up configured defaults instead of previous session switches.
118
+ self._session_model_overrides.pop(session_key, None)
119
+ self._set_session_reasoning_override(session_key, None)
120
+ if hasattr(self, "_pending_model_notes"):
121
+ self._pending_model_notes.pop(session_key, None)
122
+
123
+ # Clear session-scoped dangerous-command approvals and /yolo state.
124
+ # /new is a conversation-boundary operation — approval state from the
125
+ # previous conversation must not survive the reset.
126
+ self._clear_session_boundary_security_state(session_key)
127
+
128
+ _old_sid = old_entry.session_id if old_entry else None
129
+
130
+ # Fire plugin on_session_finalize hook (session boundary)
131
+ try:
132
+ from hermes_cli.plugins import invoke_hook as _invoke_hook
133
+ _invoke_hook(
134
+ "on_session_finalize",
135
+ session_id=_old_sid,
136
+ platform=source.platform.value if source.platform else "",
137
+ reason="new_session",
138
+ old_session_id=_old_sid,
139
+ new_session_id=new_entry.session_id if new_entry else None,
140
+ )
141
+ except Exception:
142
+ pass
143
+
144
+ # Emit session:end hook (session is ending)
145
+ await self.hooks.emit("session:end", {
146
+ "platform": source.platform.value if source.platform else "",
147
+ "user_id": source.user_id,
148
+ "session_key": session_key,
149
+ })
150
+
151
+ # Emit session:reset hook
152
+ await self.hooks.emit("session:reset", {
153
+ "platform": source.platform.value if source.platform else "",
154
+ "user_id": source.user_id,
155
+ "session_key": session_key,
156
+ })
157
+
158
+ # Resolve session config info to surface to the user
159
+ try:
160
+ session_info = self._format_session_info()
161
+ except Exception:
162
+ session_info = ""
163
+
164
+ if new_entry:
165
+ header = self._telegram_topic_new_header(source) or t("gateway.reset.header_default")
166
+ else:
167
+ # No existing session, just create one
168
+ new_entry = self.session_store.get_or_create_session(source, force_new=True)
169
+ header = self._telegram_topic_new_header(source) or t("gateway.reset.header_new")
170
+
171
+ # Set session title if provided with /new <title>
172
+ _title_arg = event.get_command_args().strip()
173
+ _title_note = ""
174
+ if _title_arg and self._session_db and new_entry:
175
+ from hermes_state import SessionDB
176
+ try:
177
+ sanitized = SessionDB.sanitize_title(_title_arg)
178
+ except ValueError as e:
179
+ sanitized = None
180
+ _title_note = t("gateway.reset.title_rejected", error=str(e))
181
+ if sanitized:
182
+ try:
183
+ self._session_db.set_session_title(new_entry.session_id, sanitized)
184
+ header = t("gateway.reset.header_titled", title=sanitized)
185
+ except ValueError as e:
186
+ _title_note = t("gateway.reset.title_error_untitled", error=str(e))
187
+ except Exception:
188
+ pass
189
+ elif not _title_note:
190
+ # sanitize_title returned empty (whitespace-only / unprintable)
191
+ _title_note = t("gateway.reset.title_empty_untitled")
192
+ header = header + _title_note
193
+
194
+ # When /new runs inside a Telegram DM topic lane, rewrite the
195
+ # (chat_id, thread_id) → session_id binding so the next message
196
+ # uses the freshly-created session. Without this, the binding
197
+ # still points at the old session and the binding-lookup at the
198
+ # top of _handle_message_with_agent would switch right back.
199
+ if self._is_telegram_topic_lane(source) and new_entry is not None:
200
+ try:
201
+ self._record_telegram_topic_binding(source, new_entry)
202
+ except Exception:
203
+ logger.debug("Failed to rebind Telegram topic after /new", exc_info=True)
204
+
205
+ # Fire plugin on_session_reset hook (new session guaranteed to exist)
206
+ try:
207
+ from hermes_cli.plugins import invoke_hook as _invoke_hook
208
+ _new_sid = new_entry.session_id if new_entry else None
209
+ _invoke_hook(
210
+ "on_session_reset",
211
+ session_id=_new_sid,
212
+ platform=source.platform.value if source.platform else "",
213
+ reason="new_session",
214
+ old_session_id=_old_sid,
215
+ new_session_id=_new_sid,
216
+ )
217
+ except Exception:
218
+ pass
219
+
220
+ # Append a random tip to the reset message
221
+ try:
222
+ from hermes_cli.tips import get_random_tip
223
+ _tip_line = t("gateway.reset.tip", tip=get_random_tip())
224
+ except Exception:
225
+ _tip_line = ""
226
+
227
+ if session_info:
228
+ return EphemeralReply(f"{header}\n\n{session_info}{_tip_line}")
229
+ return EphemeralReply(f"{header}{_tip_line}")
230
+
231
+ async def _handle_profile_command(self, event: MessageEvent) -> str:
232
+ """Handle /profile — show active profile name and home directory."""
233
+ from hermes_constants import display_hermes_home
234
+ from hermes_cli.profiles import get_active_profile_name
235
+
236
+ display = display_hermes_home()
237
+ profile_name = get_active_profile_name()
238
+
239
+ lines = [
240
+ t("gateway.profile.header", profile=profile_name),
241
+ t("gateway.profile.home", home=display),
242
+ ]
243
+
244
+ return "\n".join(lines)
245
+
246
+ async def _handle_whoami_command(self, event: MessageEvent) -> str:
247
+ """Handle /whoami — show the user's slash command access on this scope.
248
+
249
+ Always works (it's in the always-allowed floor of slash_access).
250
+ Reports: platform, scope (DM vs group), the user's tier
251
+ (admin / user / unrestricted), and the slash commands they can
252
+ actually run on this scope.
253
+ """
254
+ from gateway.slash_access import policy_for_source as _policy_for_source
255
+
256
+ source = event.source
257
+ policy = _policy_for_source(self.config, source)
258
+ platform = source.platform.value if source and source.platform else "?"
259
+ chat_type = (source.chat_type if source else "") or "dm"
260
+ scope = "DM" if chat_type.lower() in {"dm", "direct", "private", ""} else "group/channel"
261
+ user_id = (source.user_id if source else None) or "?"
262
+
263
+ if not policy.enabled:
264
+ return (
265
+ f"**You** — {platform} ({scope})\n"
266
+ f"User ID: `{user_id}`\n"
267
+ f"Tier: unrestricted (no admin list configured for this scope)\n"
268
+ f"Slash commands: all available"
269
+ )
270
+
271
+ if policy.is_admin(user_id):
272
+ return (
273
+ f"**You** — {platform} ({scope})\n"
274
+ f"User ID: `{user_id}`\n"
275
+ f"Tier: **admin**\n"
276
+ f"Slash commands: all available"
277
+ )
278
+
279
+ # Non-admin user. Show what's actually reachable.
280
+ floor = ["help", "whoami"] # mirrors slash_access._ALWAYS_ALLOWED_FOR_USERS
281
+ configured = sorted(policy.user_allowed_commands)
282
+ # Combine + dedupe, preserve order: floor first, then operator additions.
283
+ seen: set[str] = set()
284
+ runnable: list[str] = []
285
+ for c in floor + configured:
286
+ if c not in seen:
287
+ seen.add(c)
288
+ runnable.append(c)
289
+ runnable_str = ", ".join(f"/{c}" for c in runnable) if runnable else "(none)"
290
+ return (
291
+ f"**You** — {platform} ({scope})\n"
292
+ f"User ID: `{user_id}`\n"
293
+ f"Tier: user\n"
294
+ f"Slash commands you can run: {runnable_str}"
295
+ )
296
+
297
+ async def _handle_kanban_command(self, event: MessageEvent) -> str:
298
+ """Handle /kanban — delegate to the shared kanban CLI.
299
+
300
+ Run the potentially-blocking DB work in a thread pool so the
301
+ gateway event loop stays responsive. Read operations (list,
302
+ show, context, tail) are permitted while an agent is running;
303
+ mutations are allowed too because the board is profile-agnostic
304
+ and does not touch the running agent's state.
305
+
306
+ For ``/kanban create`` invocations we also auto-subscribe the
307
+ originating gateway source (platform + chat + thread) to the new
308
+ task's terminal events, so the user hears back when the worker
309
+ completes / blocks / auto-blocks / crashes without having to poll.
310
+ """
311
+ import asyncio
312
+ import re
313
+ import shlex
314
+ from hermes_cli.kanban import run_slash
315
+
316
+ text = (event.text or "").strip()
317
+ # Strip the leading "/kanban" (with or without slash), leaving args.
318
+ if text.startswith("/"):
319
+ text = text.lstrip("/")
320
+ if text.startswith("kanban"):
321
+ text = text[len("kanban"):].lstrip()
322
+
323
+ tokens = shlex.split(text) if text else []
324
+ requested_board = None
325
+ action = None
326
+ i = 0
327
+ while i < len(tokens):
328
+ tok = tokens[i]
329
+ if tok == "--board":
330
+ if i + 1 >= len(tokens):
331
+ break
332
+ requested_board = tokens[i + 1]
333
+ i += 2
334
+ continue
335
+ if tok.startswith("--board="):
336
+ requested_board = tok.split("=", 1)[1]
337
+ i += 1
338
+ continue
339
+ action = tok
340
+ break
341
+
342
+ is_create = action == "create"
343
+
344
+ try:
345
+ output = await asyncio.to_thread(run_slash, text)
346
+ except Exception as exc: # pragma: no cover - defensive
347
+ return t("gateway.kanban.error_prefix", error=exc)
348
+
349
+ # Auto-subscribe on create. Parse the task id from the CLI's standard
350
+ # success line ("Created t_abcd (ready, assignee=...)"). If the user
351
+ # passed --json we don't subscribe; they're clearly scripting and
352
+ # can call /kanban notify-subscribe explicitly.
353
+ if is_create and output:
354
+ m = re.search(r"Created\s+(t_[0-9a-f]+)\b", output)
355
+ if m:
356
+ task_id = m.group(1)
357
+ try:
358
+ source = event.source
359
+ platform = getattr(source, "platform", None)
360
+ platform_str = (
361
+ platform.value if hasattr(platform, "value") else str(platform or "")
362
+ ).lower()
363
+ chat_id = str(getattr(source, "chat_id", "") or "")
364
+ thread_id = str(getattr(source, "thread_id", "") or "")
365
+ user_id = str(getattr(source, "user_id", "") or "") or None
366
+ if platform_str and chat_id:
367
+ def _sub():
368
+ from hermes_cli import kanban_db as _kb
369
+ conn = _kb.connect(board=requested_board)
370
+ try:
371
+ _kb.add_notify_sub(
372
+ conn, task_id=task_id,
373
+ platform=platform_str, chat_id=chat_id,
374
+ thread_id=thread_id or None,
375
+ user_id=user_id,
376
+ notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(),
377
+ )
378
+ finally:
379
+ conn.close()
380
+ await asyncio.to_thread(_sub)
381
+ output = (
382
+ output.rstrip()
383
+ + "\n"
384
+ + t("gateway.kanban.subscribed_suffix", task_id=task_id)
385
+ )
386
+ except Exception as exc:
387
+ logger.warning("kanban create auto-subscribe failed: %s", exc)
388
+
389
+ # Gateway messages have practical length caps; truncate long
390
+ # listings to keep the UX reasonable.
391
+ if len(output) > 3800:
392
+ output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix")
393
+ return output or t("gateway.kanban.no_output")
394
+
395
+ async def _handle_status_command(self, event: MessageEvent) -> str:
396
+ """Handle /status command."""
397
+ from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
398
+
399
+ source = event.source
400
+ session_entry = self.session_store.get_or_create_session(source)
401
+
402
+ connected_platforms = [p.value for p in self.adapters.keys()]
403
+
404
+ # Check if there's an active agent. Keep the sentinel distinct: a
405
+ # starting/pending run should not be treated as a fully usable agent for
406
+ # model/context display, but it still occupies the session slot.
407
+ session_key = session_entry.session_key
408
+ agent = self._running_agents.get(session_key)
409
+ is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL
410
+
411
+ # Count pending /queue follow-ups (slot + overflow).
412
+ adapter = self.adapters.get(source.platform) if source else None
413
+ queue_depth = self._queue_depth(session_key, adapter=adapter)
414
+
415
+ def _clean_str(value: Any) -> str:
416
+ return value.strip() if isinstance(value, str) and value.strip() else ""
417
+
418
+ def _int_value(value: Any) -> int:
419
+ try:
420
+ return int(value)
421
+ except (TypeError, ValueError):
422
+ return 0
423
+
424
+ title = None
425
+ session_row: dict[str, Any] = {}
426
+ # Pull token totals from the SQLite session DB rather than the
427
+ # in-memory SessionStore. The agent's per-turn token deltas are
428
+ # persisted into sessions_db (run_agent.py), not into SessionEntry,
429
+ # so session_entry.total_tokens is always 0. SessionDB is the
430
+ # single source of truth; reading it here keeps /status accurate
431
+ # without duplicating token writes into two stores.
432
+ db_total_tokens = 0
433
+ if self._session_db:
434
+ try:
435
+ title = self._session_db.get_session_title(session_entry.session_id)
436
+ except Exception:
437
+ title = None
438
+ try:
439
+ row = self._session_db.get_session(session_entry.session_id)
440
+ if isinstance(row, dict):
441
+ session_row = row
442
+ db_total_tokens = (
443
+ _int_value(row.get("input_tokens"))
444
+ + _int_value(row.get("output_tokens"))
445
+ + _int_value(row.get("cache_read_tokens"))
446
+ + _int_value(row.get("cache_write_tokens"))
447
+ + _int_value(row.get("reasoning_tokens"))
448
+ )
449
+ except Exception:
450
+ db_total_tokens = 0
451
+
452
+ # Resolve model/context for cockpit-style status. Prefer the live or
453
+ # cached agent because it carries the actual runtime route and context
454
+ # compressor. Fall back to persisted SessionDB metadata plus the
455
+ # SessionStore's last_prompt_tokens so /status remains useful between
456
+ # turns without making billing/account calls.
457
+ status_agent = agent if is_running else None
458
+ if status_agent is None:
459
+ cache_lock = getattr(self, "_agent_cache_lock", None)
460
+ cache = getattr(self, "_agent_cache", None)
461
+ if cache_lock is not None and cache is not None:
462
+ try:
463
+ with cache_lock:
464
+ cached = cache.get(session_key)
465
+ if cached:
466
+ status_agent = cached[0]
467
+ except Exception:
468
+ status_agent = None
469
+
470
+ model_name = ""
471
+ provider_name = ""
472
+ base_url = ""
473
+ context_used = 0
474
+ context_total = 0
475
+ if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL:
476
+ model_name = _clean_str(getattr(status_agent, "model", ""))
477
+ provider_name = _clean_str(getattr(status_agent, "provider", ""))
478
+ base_url = _clean_str(getattr(status_agent, "base_url", ""))
479
+ ctx = getattr(status_agent, "context_compressor", None)
480
+ if ctx is not None:
481
+ context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0))
482
+ context_total = _int_value(getattr(ctx, "context_length", 0))
483
+
484
+ model_name = model_name or _clean_str(session_row.get("model"))
485
+ provider_name = provider_name or _clean_str(session_row.get("billing_provider"))
486
+ base_url = base_url or _clean_str(session_row.get("billing_base_url"))
487
+ context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0))
488
+
489
+ user_config: dict[str, Any] = {}
490
+ if not model_name or not provider_name or not context_total:
491
+ try:
492
+ user_config = _load_gateway_config()
493
+ except Exception:
494
+ user_config = {}
495
+ if not model_name:
496
+ model_name = _resolve_gateway_model(user_config)
497
+ if not provider_name:
498
+ model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
499
+ if isinstance(model_cfg, dict):
500
+ provider_name = _clean_str(model_cfg.get("provider"))
501
+ if not context_total:
502
+ model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
503
+ configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
504
+ if isinstance(configured_context, int) and configured_context > 0:
505
+ context_total = configured_context
506
+
507
+ model_line = ""
508
+ if model_name:
509
+ if provider_name:
510
+ model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name)
511
+ else:
512
+ model_line = t("gateway.status.model", model=model_name)
513
+
514
+ context_line = ""
515
+ if context_total:
516
+ pct = min(100, round((context_used / context_total) * 100)) if context_total else 0
517
+ context_line = t(
518
+ "gateway.status.context",
519
+ used=f"{context_used:,}",
520
+ total=f"{context_total:,}",
521
+ pct=f"{pct}",
522
+ )
523
+ elif context_used:
524
+ context_line = t("gateway.status.context_used", used=f"{context_used:,}")
525
+
526
+ lines = [
527
+ t("gateway.status.header"),
528
+ "",
529
+ t("gateway.status.session_id", session_id=session_entry.session_id),
530
+ ]
531
+ if title:
532
+ lines.append(t("gateway.status.title", title=title))
533
+ lines.extend([
534
+ t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
535
+ t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')),
536
+ ])
537
+ if model_line:
538
+ lines.append(model_line)
539
+ if context_line:
540
+ lines.append(context_line)
541
+ lines.extend([
542
+ t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"),
543
+ t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")),
544
+ ])
545
+ if queue_depth:
546
+ lines.append(t("gateway.status.queued", count=queue_depth))
547
+ if source.platform == Platform.MATRIX:
548
+ adapter = self.adapters.get(Platform.MATRIX)
549
+ scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
550
+ thread = source.thread_id or "none"
551
+ lines.extend([
552
+ "",
553
+ t("gateway.status.matrix_scope_header"),
554
+ t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
555
+ t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
556
+ t("gateway.status.matrix_scope_thread", thread_id=thread),
557
+ t("gateway.status.matrix_scope_mode", scope=scope),
558
+ t(
559
+ "gateway.status.matrix_scope_key",
560
+ session_key=self._redact_matrix_session_key(session_key),
561
+ ),
562
+ ])
563
+ lines.extend([
564
+ "",
565
+ t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
566
+ ])
567
+
568
+ return "\n".join(lines)
569
+
570
+ @staticmethod
571
+ def _redact_matrix_session_key(session_key: str) -> str:
572
+ """Return a stable Matrix session-key fingerprint for shared room status."""
573
+ text = str(session_key or "")
574
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
575
+ return f"sha256:{digest}"
576
+
577
+ def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]:
578
+ """Best-effort origin lookup for gateway session IDs."""
579
+ lookup = getattr(type(self.session_store), "lookup_by_session_id", None)
580
+ if callable(lookup):
581
+ entry = lookup(self.session_store, session_id)
582
+ return getattr(entry, "origin", None) if entry is not None else None
583
+
584
+ # Test doubles and older stores may not expose the public lookup helper.
585
+ # Keep the Matrix resume guard fail-closed if no origin can be resolved.
586
+ entries = getattr(self.session_store, "_entries", {}) or {}
587
+ for entry in entries.values():
588
+ if getattr(entry, "session_id", None) == session_id:
589
+ return getattr(entry, "origin", None)
590
+ return None
591
+
592
+ @staticmethod
593
+ def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool:
594
+ return (
595
+ origin is not None
596
+ and origin.platform == Platform.MATRIX
597
+ and current.platform == Platform.MATRIX
598
+ and origin.chat_id == current.chat_id
599
+ )
600
+
601
+ async def _handle_agents_command(self, event: MessageEvent) -> str:
602
+ """Handle /agents command - list active agents and running tasks."""
603
+ from gateway.run import _AGENT_PENDING_SENTINEL
604
+ from tools.process_registry import format_uptime_short, process_registry
605
+
606
+ now = time.time()
607
+ current_session_key = self._session_key_for_source(event.source)
608
+
609
+ running_agents: dict = getattr(self, "_running_agents", {}) or {}
610
+ running_started: dict = getattr(self, "_running_agents_ts", {}) or {}
611
+
612
+ agent_rows: list[dict] = []
613
+ for session_key, agent in running_agents.items():
614
+ started = float(running_started.get(session_key, now))
615
+ elapsed = max(0, int(now - started))
616
+ is_pending = agent is _AGENT_PENDING_SENTINEL
617
+ agent_rows.append(
618
+ {
619
+ "session_key": session_key,
620
+ "elapsed": elapsed,
621
+ "state": t("gateway.agents.state_starting") if is_pending else t("gateway.agents.state_running"),
622
+ "session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""),
623
+ "model": "" if is_pending else str(getattr(agent, "model", "") or ""),
624
+ }
625
+ )
626
+
627
+ agent_rows.sort(key=lambda row: row["elapsed"], reverse=True)
628
+
629
+ running_processes: list[dict] = []
630
+ try:
631
+ running_processes = [
632
+ p for p in process_registry.list_sessions()
633
+ if p.get("status") == "running"
634
+ ]
635
+ except Exception:
636
+ running_processes = []
637
+
638
+ background_tasks = [
639
+ t for t in (getattr(self, "_background_tasks", set()) or set())
640
+ if hasattr(t, "done") and not t.done()
641
+ ]
642
+
643
+ lines = [
644
+ t("gateway.agents.header"),
645
+ "",
646
+ t("gateway.agents.active_agents", count=len(agent_rows)),
647
+ ]
648
+
649
+ if agent_rows:
650
+ for idx, row in enumerate(agent_rows[:12], 1):
651
+ current = t("gateway.agents.this_chat") if row["session_key"] == current_session_key else ""
652
+ sid = f" · `{row['session_id']}`" if row["session_id"] else ""
653
+ model = f" · `{row['model']}`" if row["model"] else ""
654
+ lines.append(
655
+ f"{idx}. `{row['session_key']}` · {row['state']} · "
656
+ f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}"
657
+ )
658
+ if len(agent_rows) > 12:
659
+ lines.append(t("gateway.agents.more", count=len(agent_rows) - 12))
660
+
661
+ lines.extend(
662
+ [
663
+ "",
664
+ t("gateway.agents.running_processes", count=len(running_processes)),
665
+ ]
666
+ )
667
+ if running_processes:
668
+ for proc in running_processes[:12]:
669
+ cmd = " ".join(str(proc.get("command", "")).split())
670
+ if len(cmd) > 90:
671
+ cmd = cmd[:87] + "..."
672
+ lines.append(
673
+ f"- `{proc.get('session_id', '?')}` · "
674
+ f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`"
675
+ )
676
+ if len(running_processes) > 12:
677
+ lines.append(t("gateway.agents.more", count=len(running_processes) - 12))
678
+
679
+ lines.extend(
680
+ [
681
+ "",
682
+ t("gateway.agents.async_jobs", count=len(background_tasks)),
683
+ ]
684
+ )
685
+
686
+ if not agent_rows and not running_processes and not background_tasks:
687
+ lines.append("")
688
+ lines.append(t("gateway.agents.none"))
689
+
690
+ return "\n".join(lines)
691
+
692
+ async def _handle_stop_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
693
+ """Handle /stop command - interrupt a running agent.
694
+
695
+ When an agent is truly hung (blocked thread that never checks
696
+ _interrupt_requested), the early intercept in _handle_message()
697
+ handles /stop before this method is reached. This handler fires
698
+ only through normal command dispatch (no running agent) or as a
699
+ fallback. Force-clean the session lock in all cases for safety.
700
+
701
+ The session is preserved so the user can continue the conversation.
702
+ """
703
+ from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP
704
+ source = event.source
705
+ session_entry = self.session_store.get_or_create_session(source)
706
+ session_key = session_entry.session_key
707
+
708
+ agent = self._running_agents.get(session_key)
709
+ if agent is _AGENT_PENDING_SENTINEL:
710
+ # Force-clean the sentinel so the session is unlocked.
711
+ await self._interrupt_and_clear_session(
712
+ session_key,
713
+ source,
714
+ interrupt_reason=_INTERRUPT_REASON_STOP,
715
+ invalidation_reason="stop_command_pending",
716
+ )
717
+ logger.info("STOP (pending) for session %s — sentinel cleared", session_key)
718
+ return EphemeralReply(t("gateway.stop.stopped_pending"))
719
+ if agent:
720
+ # Force-clean the session lock so a truly hung agent doesn't
721
+ # keep it locked forever.
722
+ await self._interrupt_and_clear_session(
723
+ session_key,
724
+ source,
725
+ interrupt_reason=_INTERRUPT_REASON_STOP,
726
+ invalidation_reason="stop_command_handler",
727
+ )
728
+ return EphemeralReply(t("gateway.stop.stopped"))
729
+
730
+ # No run under the caller's own session key. In a per-user thread
731
+ # (thread_sessions_per_user=True) each participant is isolated even
732
+ # inside one shared thread, so a run another user started lives under
733
+ # a different key. Authorized users should still be able to /stop it
734
+ # (#bernard-thread-stop). Fall back to interrupting any running
735
+ # agent(s) that share this thread, gated on authorization.
736
+ sibling_keys = self._sibling_thread_run_keys(source, session_key)
737
+ if sibling_keys and self._is_user_authorized(source):
738
+ for sibling_key in sibling_keys:
739
+ await self._interrupt_and_clear_session(
740
+ sibling_key,
741
+ source,
742
+ interrupt_reason=_INTERRUPT_REASON_STOP,
743
+ invalidation_reason="stop_command_thread_sibling",
744
+ )
745
+ logger.info(
746
+ "STOP (thread sibling) by %s — interrupted %d run(s) in thread: %s",
747
+ session_key,
748
+ len(sibling_keys),
749
+ ", ".join(sibling_keys),
750
+ )
751
+ return EphemeralReply(t("gateway.stop.stopped"))
752
+
753
+ return t("gateway.stop.no_active")
754
+
755
+ async def _handle_platform_command(self, event: MessageEvent) -> str:
756
+ """Handle ``/platform list|pause|resume [name]`` — surface and
757
+ manually control failed/paused gateway adapters.
758
+
759
+ Examples:
760
+ ``/platform list`` — show connected + failed/paused platforms
761
+ ``/platform pause whatsapp`` — stop the reconnect watcher hammering whatsapp
762
+ ``/platform resume whatsapp`` — re-queue a paused platform for retry
763
+ """
764
+ text = (getattr(event, "content", "") or "").strip()
765
+ # Strip the leading "/platform" (or "/PLATFORM") token if present
766
+ parts = text.split(maxsplit=2)
767
+ if parts and parts[0].lower().lstrip("/").startswith("platform"):
768
+ parts = parts[1:]
769
+ action = (parts[0] if parts else "list").lower()
770
+ target = parts[1].lower() if len(parts) > 1 else ""
771
+
772
+ # Resolve platform name (case-insensitive, value match)
773
+ def _resolve_platform(name: str):
774
+ if not name:
775
+ return None
776
+ for p in Platform.__members__.values():
777
+ if p.value.lower() == name:
778
+ return p
779
+ return None
780
+
781
+ if action == "list":
782
+ lines = ["**Gateway platforms**"]
783
+ connected = sorted(p.value for p in self.adapters.keys())
784
+ if connected:
785
+ lines.append("Connected: " + ", ".join(connected))
786
+ else:
787
+ lines.append("Connected: (none)")
788
+ failed = getattr(self, "_failed_platforms", {}) or {}
789
+ if failed:
790
+ for p, info in failed.items():
791
+ if info.get("paused"):
792
+ reason = info.get("pause_reason") or "paused"
793
+ lines.append(
794
+ f" · {p.value} — PAUSED ({reason}). "
795
+ f"Resume with `/platform resume {p.value}`."
796
+ )
797
+ else:
798
+ attempts = info.get("attempts", 0)
799
+ lines.append(
800
+ f" · {p.value} — retrying (attempt {attempts})"
801
+ )
802
+ else:
803
+ lines.append("Failed/paused: (none)")
804
+ return "\n".join(lines)
805
+
806
+ if action in {"pause", "resume"}:
807
+ if not target:
808
+ return f"Usage: /platform {action} <name>"
809
+ platform = _resolve_platform(target)
810
+ if platform is None:
811
+ return f"Unknown platform: {target}"
812
+ failed = getattr(self, "_failed_platforms", {}) or {}
813
+ if action == "pause":
814
+ if platform not in failed:
815
+ return (
816
+ f"{platform.value} is not in the retry queue "
817
+ f"(it's either connected or not enabled)."
818
+ )
819
+ if failed[platform].get("paused"):
820
+ return f"{platform.value} is already paused."
821
+ self._pause_failed_platform(platform, reason="paused via /platform pause")
822
+ return (
823
+ f"✓ {platform.value} paused. "
824
+ f"Resume with `/platform resume {platform.value}` or "
825
+ f"`hermes gateway restart` to reset."
826
+ )
827
+ # action == "resume"
828
+ if platform not in failed:
829
+ return (
830
+ f"{platform.value} is not in the retry queue — "
831
+ f"nothing to resume."
832
+ )
833
+ if not failed[platform].get("paused"):
834
+ return (
835
+ f"{platform.value} is already retrying — "
836
+ f"no resume needed."
837
+ )
838
+ self._resume_paused_platform(platform)
839
+ return f"✓ {platform.value} resumed — retrying on next watcher tick."
840
+
841
+ return (
842
+ "Usage: /platform <list|pause|resume> [name]\n"
843
+ " /platform list — show platform status\n"
844
+ " /platform pause <name> — stop retrying a failing platform\n"
845
+ " /platform resume <name> — re-queue a paused platform"
846
+ )
847
+
848
+ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
849
+ """Handle /restart command - drain active work, then restart the gateway."""
850
+ from gateway.run import _hermes_home
851
+ # Defensive idempotency check: if the previous gateway process
852
+ # recorded this same /restart (same platform + update_id) and the new
853
+ # process is seeing it *again*, this is a re-delivery caused by PTB's
854
+ # graceful-shutdown `get_updates` ACK failing on the way out ("Error
855
+ # while calling `get_updates` one more time to mark all fetched
856
+ # updates. Suppressing error to ensure graceful shutdown. When
857
+ # polling for updates is restarted, updates may be received twice."
858
+ # in gateway.log). Ignoring the stale redelivery prevents a
859
+ # self-perpetuating restart loop where every fresh gateway
860
+ # re-processes the same /restart command and immediately restarts
861
+ # again.
862
+ if self._is_stale_restart_redelivery(event):
863
+ logger.info(
864
+ "Ignoring redelivered /restart (platform=%s, update_id=%s) — "
865
+ "already processed by a previous gateway instance.",
866
+ event.source.platform.value if event.source and event.source.platform else "?",
867
+ event.platform_update_id,
868
+ )
869
+ return ""
870
+
871
+ if self._restart_requested or self._draining:
872
+ count = self._running_agent_count()
873
+ if count:
874
+ return t("gateway.draining", count=count)
875
+ return EphemeralReply(t("gateway.restart.in_progress"))
876
+
877
+ # Save the requester's routing info so the new gateway process can
878
+ # notify them once it comes back online.
879
+ try:
880
+ notify_data = {
881
+ "platform": event.source.platform.value if event.source.platform else None,
882
+ "chat_id": event.source.chat_id,
883
+ "chat_type": event.source.chat_type,
884
+ }
885
+ if event.source.thread_id:
886
+ notify_data["thread_id"] = event.source.thread_id
887
+ if event.message_id:
888
+ notify_data["message_id"] = event.message_id
889
+ if event.source is not None:
890
+ try:
891
+ self._restart_command_source = dataclasses.replace(
892
+ event.source,
893
+ message_id=str(event.message_id)
894
+ if event.message_id is not None
895
+ else event.source.message_id,
896
+ )
897
+ except Exception:
898
+ self._restart_command_source = event.source
899
+ atomic_json_write(
900
+ _hermes_home / ".restart_notify.json",
901
+ notify_data,
902
+ indent=None,
903
+ )
904
+ except Exception as e:
905
+ logger.debug("Failed to write restart notify file: %s", e)
906
+
907
+ # Record the triggering platform + update_id in a dedicated dedup
908
+ # marker. Unlike .restart_notify.json (which gets unlinked once the
909
+ # new gateway sends the "gateway restarted" notification), this
910
+ # marker persists so the new gateway can still detect a delayed
911
+ # /restart redelivery from Telegram. Overwritten on every /restart.
912
+ try:
913
+ dedup_data = {
914
+ "platform": event.source.platform.value if event.source.platform else None,
915
+ "requested_at": time.time(),
916
+ }
917
+ if event.platform_update_id is not None:
918
+ dedup_data["update_id"] = event.platform_update_id
919
+ atomic_json_write(
920
+ _hermes_home / ".restart_last_processed.json",
921
+ dedup_data,
922
+ indent=None,
923
+ )
924
+ except Exception as e:
925
+ logger.debug("Failed to write restart dedup marker: %s", e)
926
+
927
+ active_agents = self._running_agent_count()
928
+ # When running under a service manager (systemd/launchd) or inside a
929
+ # Docker/Podman container, use the service restart path: exit with
930
+ # code 75 so the service manager / container restart policy restarts
931
+ # us. The detached subprocess approach (setsid + bash) doesn't work
932
+ # under systemd (KillMode=mixed kills the cgroup) or Docker (tini
933
+ # exits when the gateway dies, taking the detached helper with it).
934
+ _under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this
935
+ _in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
936
+ if _under_service or _in_container:
937
+ self.request_restart(detached=False, via_service=True)
938
+ else:
939
+ self.request_restart(detached=True, via_service=False)
940
+ if active_agents:
941
+ return t("gateway.draining", count=active_agents)
942
+ return EphemeralReply(t("gateway.restart.restarting"))
943
+
944
+ async def _handle_version_command(self, event: MessageEvent) -> str:
945
+ """Handle /version — show the running Hermes Agent version."""
946
+ from hermes_cli.banner import format_banner_version_label
947
+
948
+ return format_banner_version_label()
949
+
950
+ async def _handle_help_command(self, event: MessageEvent) -> str:
951
+ """Handle /help command - list available commands."""
952
+ from gateway.run import _telegramize_command_mentions
953
+ from hermes_cli.commands import gateway_help_lines
954
+ lines = [
955
+ t("gateway.help.header"),
956
+ *gateway_help_lines(),
957
+ ]
958
+ try:
959
+ from agent.skill_commands import get_skill_commands
960
+ skill_cmds = get_skill_commands()
961
+ if skill_cmds:
962
+ lines.append(t("gateway.help.skill_header", count=len(skill_cmds)))
963
+ # Show first 10, then point to /commands for the rest
964
+ sorted_cmds = sorted(skill_cmds)
965
+ for cmd in sorted_cmds[:10]:
966
+ lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
967
+ if len(sorted_cmds) > 10:
968
+ lines.append(t("gateway.help.more_use_commands", count=len(sorted_cmds) - 10))
969
+ except Exception:
970
+ pass
971
+ return _telegramize_command_mentions(
972
+ "\n".join(lines),
973
+ getattr(getattr(event, "source", None), "platform", None),
974
+ )
975
+
976
+ async def _handle_commands_command(self, event: MessageEvent) -> str:
977
+ from gateway.run import _telegramize_command_mentions
978
+ from hermes_cli.commands import gateway_help_lines
979
+
980
+ raw_args = event.get_command_args().strip()
981
+ if raw_args:
982
+ try:
983
+ requested_page = int(raw_args)
984
+ except ValueError:
985
+ return t("gateway.commands.usage")
986
+ else:
987
+ requested_page = 1
988
+
989
+ # Build combined entry list: built-in commands + skill commands
990
+ entries = list(gateway_help_lines())
991
+ try:
992
+ from agent.skill_commands import get_skill_commands
993
+ skill_cmds = get_skill_commands()
994
+ if skill_cmds:
995
+ entries.append("")
996
+ entries.append(t("gateway.commands.skill_header"))
997
+ for cmd in sorted(skill_cmds):
998
+ desc = skill_cmds[cmd].get("description", "").strip() or t("gateway.commands.default_desc")
999
+ entries.append(f"`{cmd}` — {desc}")
1000
+ except Exception:
1001
+ pass
1002
+
1003
+ if not entries:
1004
+ return t("gateway.commands.none")
1005
+
1006
+ from gateway.config import Platform
1007
+ page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
1008
+ total_pages = max(1, (len(entries) + page_size - 1) // page_size)
1009
+ page = max(1, min(requested_page, total_pages))
1010
+ start = (page - 1) * page_size
1011
+ page_entries = entries[start:start + page_size]
1012
+
1013
+ lines = [
1014
+ t("gateway.commands.header", total=len(entries), page=page, total_pages=total_pages),
1015
+ "",
1016
+ *page_entries,
1017
+ ]
1018
+ if total_pages > 1:
1019
+ nav_parts = []
1020
+ if page > 1:
1021
+ nav_parts.append(t("gateway.commands.nav_prev", page=page - 1))
1022
+ if page < total_pages:
1023
+ nav_parts.append(t("gateway.commands.nav_next", page=page + 1))
1024
+ lines.extend(["", " | ".join(nav_parts)])
1025
+ if page != requested_page:
1026
+ lines.append(t("gateway.commands.out_of_range", requested=requested_page, page=page))
1027
+ return _telegramize_command_mentions(
1028
+ "\n".join(lines),
1029
+ getattr(getattr(event, "source", None), "platform", None),
1030
+ )
1031
+
1032
+ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
1033
+ """Handle /model command — switch model.
1034
+
1035
+ Supports:
1036
+ /model — interactive picker (Telegram/Discord) or text list
1037
+ /model <name> — switch model (persists by default)
1038
+ /model <name> --session — switch for this session only
1039
+ /model <name> --global — switch and persist (explicit)
1040
+ /model <name> --provider <provider> — switch provider + model
1041
+ /model --provider <provider> — switch to provider, auto-detect model
1042
+ """
1043
+ from gateway.run import _hermes_home, _load_gateway_config
1044
+ import yaml
1045
+ from hermes_cli.model_switch import (
1046
+ switch_model as _switch_model, parse_model_flags,
1047
+ resolve_persist_behavior,
1048
+ list_authenticated_providers,
1049
+ list_picker_providers,
1050
+ )
1051
+ from hermes_cli.providers import get_label
1052
+
1053
+ raw_args = event.get_command_args().strip()
1054
+
1055
+ # Parse --provider, --global, --session, and --refresh flags
1056
+ (
1057
+ model_input,
1058
+ explicit_provider,
1059
+ is_global_flag,
1060
+ force_refresh,
1061
+ is_session,
1062
+ ) = parse_model_flags(raw_args)
1063
+ persist_global = resolve_persist_behavior(is_global_flag, is_session)
1064
+
1065
+ # --refresh: bust the disk cache so the picker shows live data.
1066
+ if force_refresh:
1067
+ try:
1068
+ from hermes_cli.models import clear_provider_models_cache
1069
+ clear_provider_models_cache()
1070
+ except Exception:
1071
+ pass
1072
+
1073
+ # Read current model/provider from config
1074
+ current_model = ""
1075
+ current_provider = "openrouter"
1076
+ current_base_url = ""
1077
+ current_api_key = ""
1078
+ user_provs = None
1079
+ custom_provs = None
1080
+ config_path = _hermes_home / "config.yaml"
1081
+ try:
1082
+ cfg = _load_gateway_config()
1083
+ if cfg:
1084
+ model_cfg = cfg.get("model", {})
1085
+ if isinstance(model_cfg, dict):
1086
+ current_model = model_cfg.get("default", "")
1087
+ current_provider = model_cfg.get("provider", current_provider)
1088
+ current_base_url = model_cfg.get("base_url", "")
1089
+ user_provs = cfg.get("providers")
1090
+ try:
1091
+ from hermes_cli.config import get_compatible_custom_providers
1092
+ custom_provs = get_compatible_custom_providers(cfg)
1093
+ except Exception:
1094
+ custom_provs = cfg.get("custom_providers")
1095
+ except Exception:
1096
+ pass
1097
+
1098
+ # Check for session override
1099
+ source = event.source
1100
+ # Normalize the source the same way a normal message turn does
1101
+ # (Telegram DM topic recovery) before deriving the override key, so
1102
+ # the override is stored under the key the next message turn reads
1103
+ # (#30479).
1104
+ source = self._normalize_source_for_session_key(source)
1105
+ session_key = self._session_key_for_source(source)
1106
+ override = self._session_model_overrides.get(session_key, {})
1107
+ if override:
1108
+ current_model = override.get("model", current_model)
1109
+ current_provider = override.get("provider", current_provider)
1110
+ current_base_url = override.get("base_url", current_base_url)
1111
+ current_api_key = override.get("api_key", current_api_key)
1112
+
1113
+ # No args: show interactive picker (Telegram/Discord) or text list
1114
+ if not model_input and not explicit_provider:
1115
+ # Try interactive picker if the platform supports it
1116
+ adapter = self.adapters.get(source.platform)
1117
+ has_picker = (
1118
+ adapter is not None
1119
+ and getattr(type(adapter), "send_model_picker", None) is not None
1120
+ )
1121
+
1122
+ if has_picker:
1123
+ try:
1124
+ providers = list_picker_providers(
1125
+ current_provider=current_provider,
1126
+ current_base_url=current_base_url,
1127
+ current_model=current_model,
1128
+ user_providers=user_provs,
1129
+ custom_providers=custom_provs,
1130
+ max_models=50,
1131
+ )
1132
+ except Exception:
1133
+ providers = []
1134
+
1135
+ if providers:
1136
+ # Build a callback closure for when the user picks a model.
1137
+ # Captures self + locals needed for the switch logic.
1138
+ _self = self
1139
+ _session_key = session_key
1140
+ _cur_model = current_model
1141
+ _cur_provider = current_provider
1142
+ _cur_base_url = current_base_url
1143
+ _cur_api_key = current_api_key
1144
+
1145
+ async def _on_model_selected(
1146
+ _chat_id: str, model_id: str, provider_slug: str
1147
+ ) -> str:
1148
+ """Perform the model switch and return confirmation text."""
1149
+ result = _switch_model(
1150
+ raw_input=model_id,
1151
+ current_provider=_cur_provider,
1152
+ current_model=_cur_model,
1153
+ current_base_url=_cur_base_url,
1154
+ current_api_key=_cur_api_key,
1155
+ is_global=False,
1156
+ explicit_provider=provider_slug,
1157
+ user_providers=user_provs,
1158
+ custom_providers=custom_provs,
1159
+ )
1160
+ if not result.success:
1161
+ return t("gateway.model.error_prefix", error=result.error_message)
1162
+
1163
+ # Update cached agent in-place
1164
+ cached_entry = None
1165
+ _cache_lock = getattr(_self, "_agent_cache_lock", None)
1166
+ _cache = getattr(_self, "_agent_cache", None)
1167
+ if _cache_lock and _cache is not None:
1168
+ with _cache_lock:
1169
+ cached_entry = _cache.get(_session_key)
1170
+ if cached_entry and cached_entry[0] is not None:
1171
+ try:
1172
+ cached_entry[0].switch_model(
1173
+ new_model=result.new_model,
1174
+ new_provider=result.target_provider,
1175
+ api_key=result.api_key,
1176
+ base_url=result.base_url,
1177
+ api_mode=result.api_mode,
1178
+ )
1179
+ except Exception as exc:
1180
+ logger.warning("Picker model switch failed for cached agent: %s", exc)
1181
+
1182
+ # Persist the new model to the session DB so the
1183
+ # dashboard shows the updated model (#34850).
1184
+ _sess_db = getattr(_self, "_session_db", None)
1185
+ if _sess_db is not None:
1186
+ try:
1187
+ _sess_entry = _self.session_store.get_or_create_session(
1188
+ event.source
1189
+ )
1190
+ _sess_db.update_session_model(
1191
+ _sess_entry.session_id, result.new_model
1192
+ )
1193
+ except Exception as exc:
1194
+ logger.debug(
1195
+ "Failed to persist model switch to DB: %s", exc
1196
+ )
1197
+
1198
+ # Store model note + session override
1199
+ if not hasattr(_self, "_pending_model_notes"):
1200
+ _self._pending_model_notes = {}
1201
+ _self._pending_model_notes[_session_key] = (
1202
+ f"[Note: model was just switched from {_cur_model} to {result.new_model} "
1203
+ f"via {result.provider_label or result.target_provider}. "
1204
+ f"Adjust your self-identification accordingly.]"
1205
+ )
1206
+ _self._session_model_overrides[_session_key] = {
1207
+ "model": result.new_model,
1208
+ "provider": result.target_provider,
1209
+ "api_key": result.api_key,
1210
+ "base_url": result.base_url,
1211
+ "api_mode": result.api_mode,
1212
+ }
1213
+
1214
+ # Evict cached agent so the next turn creates a fresh
1215
+ # agent from the override rather than relying on the
1216
+ # stale cache signature to trigger a rebuild.
1217
+ _self._evict_cached_agent(_session_key)
1218
+
1219
+ # Build confirmation text
1220
+ plabel = result.provider_label or result.target_provider
1221
+ lines = [t("gateway.model.switched", model=result.new_model)]
1222
+ lines.append(t("gateway.model.provider_label", provider=plabel))
1223
+ mi = result.model_info
1224
+ from hermes_cli.model_switch import resolve_display_context_length
1225
+ _sw_config_ctx = None
1226
+ try:
1227
+ _sw_cfg = _load_gateway_config()
1228
+ _sw_model_cfg = _sw_cfg.get("model", {})
1229
+ if isinstance(_sw_model_cfg, dict):
1230
+ _sw_raw = _sw_model_cfg.get("context_length")
1231
+ if _sw_raw is not None:
1232
+ _sw_config_ctx = int(_sw_raw)
1233
+ except Exception:
1234
+ pass
1235
+ ctx = resolve_display_context_length(
1236
+ result.new_model,
1237
+ result.target_provider,
1238
+ base_url=result.base_url or current_base_url or "",
1239
+ api_key=result.api_key or current_api_key or "",
1240
+ model_info=mi,
1241
+ custom_providers=custom_provs,
1242
+ config_context_length=_sw_config_ctx,
1243
+ )
1244
+ if ctx:
1245
+ lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
1246
+ if mi:
1247
+ if mi.max_output:
1248
+ lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
1249
+ if mi.has_cost_data():
1250
+ lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
1251
+ lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
1252
+ lines.append(t("gateway.model.session_only_hint"))
1253
+ return "\n".join(lines)
1254
+
1255
+ metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
1256
+ result = await adapter.send_model_picker(
1257
+ chat_id=source.chat_id,
1258
+ providers=providers,
1259
+ current_model=current_model,
1260
+ current_provider=current_provider,
1261
+ session_key=session_key,
1262
+ on_model_selected=_on_model_selected,
1263
+ metadata=metadata,
1264
+ )
1265
+ if result.success:
1266
+ return None # Picker sent — adapter handles the response
1267
+
1268
+ # Fallback: text list (for platforms without picker or if picker failed)
1269
+ provider_label = get_label(current_provider)
1270
+ lines = [t("gateway.model.current_label", model=current_model or "unknown", provider=provider_label), ""]
1271
+
1272
+ try:
1273
+ providers = list_authenticated_providers(
1274
+ current_provider=current_provider,
1275
+ current_base_url=current_base_url,
1276
+ current_model=current_model,
1277
+ user_providers=user_provs,
1278
+ custom_providers=custom_provs,
1279
+ max_models=5,
1280
+ )
1281
+ for p in providers:
1282
+ tag = t("gateway.model.current_tag") if p["is_current"] else ""
1283
+ lines.append(f"**{p['name']}** `--provider {p['slug']}`{tag}:")
1284
+ if p["models"]:
1285
+ model_strs = ", ".join(f"`{m}`" for m in p["models"])
1286
+ extra = t("gateway.model.more_models_suffix", count=p["total_models"] - len(p["models"])) if p["total_models"] > len(p["models"]) else ""
1287
+ lines.append(f" {model_strs}{extra}")
1288
+ elif p.get("api_url"):
1289
+ lines.append(f" `{p['api_url']}`")
1290
+ lines.append("")
1291
+ except Exception:
1292
+ pass
1293
+
1294
+ lines.append(t("gateway.model.usage_switch_model"))
1295
+ lines.append(t("gateway.model.usage_switch_provider"))
1296
+ lines.append(t("gateway.model.usage_persist"))
1297
+ return "\n".join(lines)
1298
+
1299
+ # Perform the switch
1300
+ result = _switch_model(
1301
+ raw_input=model_input,
1302
+ current_provider=current_provider,
1303
+ current_model=current_model,
1304
+ current_base_url=current_base_url,
1305
+ current_api_key=current_api_key,
1306
+ is_global=persist_global,
1307
+ explicit_provider=explicit_provider,
1308
+ user_providers=user_provs,
1309
+ custom_providers=custom_provs,
1310
+ )
1311
+
1312
+ if not result.success:
1313
+ return t("gateway.model.error_prefix", error=result.error_message)
1314
+
1315
+ async def _finish_switch() -> str:
1316
+ """Apply the resolved switch (agent, session, config) and build the reply."""
1317
+ # If there's a cached agent, update it in-place
1318
+ cached_entry = None
1319
+ _cache_lock = getattr(self, "_agent_cache_lock", None)
1320
+ _cache = getattr(self, "_agent_cache", None)
1321
+ if _cache_lock and _cache is not None:
1322
+ with _cache_lock:
1323
+ cached_entry = _cache.get(session_key)
1324
+
1325
+ if cached_entry and cached_entry[0] is not None:
1326
+ try:
1327
+ cached_entry[0].switch_model(
1328
+ new_model=result.new_model,
1329
+ new_provider=result.target_provider,
1330
+ api_key=result.api_key,
1331
+ base_url=result.base_url,
1332
+ api_mode=result.api_mode,
1333
+ )
1334
+ except Exception as exc:
1335
+ logger.warning("In-place model switch failed for cached agent: %s", exc)
1336
+
1337
+ # Persist the new model to the session DB so the dashboard
1338
+ # shows the updated model (#34850).
1339
+ _sess_db = getattr(self, "_session_db", None)
1340
+ if _sess_db is not None:
1341
+ try:
1342
+ _sess_entry = self.session_store.get_or_create_session(source)
1343
+ _sess_db.update_session_model(
1344
+ _sess_entry.session_id, result.new_model
1345
+ )
1346
+ except Exception as exc:
1347
+ logger.debug(
1348
+ "Failed to persist model switch to DB: %s", exc
1349
+ )
1350
+
1351
+ # Store a note to prepend to the next user message so the model
1352
+ # knows about the switch (avoids system messages mid-history).
1353
+ if not hasattr(self, "_pending_model_notes"):
1354
+ self._pending_model_notes = {}
1355
+ self._pending_model_notes[session_key] = (
1356
+ f"[Note: model was just switched from {current_model} to {result.new_model} "
1357
+ f"via {result.provider_label or result.target_provider}. "
1358
+ f"Adjust your self-identification accordingly.]"
1359
+ )
1360
+
1361
+ # Store session override so next agent creation uses the new model
1362
+ self._session_model_overrides[session_key] = {
1363
+ "model": result.new_model,
1364
+ "provider": result.target_provider,
1365
+ "api_key": result.api_key,
1366
+ "base_url": result.base_url,
1367
+ "api_mode": result.api_mode,
1368
+ }
1369
+
1370
+ # Evict cached agent so the next turn creates a fresh agent from the
1371
+ # override rather than relying on cache signature mismatch detection.
1372
+ self._evict_cached_agent(session_key)
1373
+
1374
+ # Persist to config (default) unless --session opted out
1375
+ if persist_global:
1376
+ try:
1377
+ if config_path.exists():
1378
+ with open(config_path, encoding="utf-8") as f:
1379
+ cfg = yaml.safe_load(f) or {}
1380
+ else:
1381
+ cfg = {}
1382
+ # Coerce scalar/None ``model:`` into a dict before mutation —
1383
+ # otherwise ``cfg.setdefault("model", {})`` returns the existing
1384
+ # scalar and the next assignment raises
1385
+ # ``TypeError: 'str' object does not support item assignment``.
1386
+ # Reproduces when ``config.yaml`` has ``model: <name>`` (flat
1387
+ # string) instead of the proper nested ``model: {default: ...}``.
1388
+ raw_model = cfg.get("model")
1389
+ if isinstance(raw_model, dict):
1390
+ model_cfg = raw_model
1391
+ elif isinstance(raw_model, str) and raw_model.strip():
1392
+ model_cfg = {"default": raw_model.strip()}
1393
+ cfg["model"] = model_cfg
1394
+ else:
1395
+ model_cfg = {}
1396
+ cfg["model"] = model_cfg
1397
+ model_cfg["default"] = result.new_model
1398
+ model_cfg["provider"] = result.target_provider
1399
+ if result.base_url:
1400
+ model_cfg["base_url"] = result.base_url
1401
+ from hermes_cli.config import save_config
1402
+ save_config(cfg)
1403
+ except Exception as e:
1404
+ logger.warning("Failed to persist model switch: %s", e)
1405
+
1406
+ # Build confirmation message with full metadata
1407
+ provider_label = result.provider_label or result.target_provider
1408
+ lines = [t("gateway.model.switched", model=result.new_model)]
1409
+ lines.append(t("gateway.model.provider_label", provider=provider_label))
1410
+
1411
+ # Context: always resolve via the provider-aware chain so Codex OAuth,
1412
+ # Copilot, and Nous-enforced caps win over the raw models.dev entry.
1413
+ mi = result.model_info
1414
+ from hermes_cli.model_switch import resolve_display_context_length
1415
+ _sw2_config_ctx = None
1416
+ try:
1417
+ _sw2_cfg = _load_gateway_config()
1418
+ _sw2_model_cfg = _sw2_cfg.get("model", {})
1419
+ if isinstance(_sw2_model_cfg, dict):
1420
+ _sw2_raw = _sw2_model_cfg.get("context_length")
1421
+ if _sw2_raw is not None:
1422
+ _sw2_config_ctx = int(_sw2_raw)
1423
+ except Exception:
1424
+ pass
1425
+ ctx = resolve_display_context_length(
1426
+ result.new_model,
1427
+ result.target_provider,
1428
+ base_url=result.base_url or current_base_url or "",
1429
+ api_key=result.api_key or current_api_key or "",
1430
+ model_info=mi,
1431
+ custom_providers=custom_provs,
1432
+ config_context_length=_sw2_config_ctx,
1433
+ )
1434
+ if ctx:
1435
+ lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
1436
+ if mi:
1437
+ if mi.max_output:
1438
+ lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
1439
+ if mi.has_cost_data():
1440
+ lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
1441
+ lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
1442
+
1443
+ # Cache notice
1444
+ cache_enabled = (
1445
+ (base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
1446
+ or result.api_mode == "anthropic_messages"
1447
+ )
1448
+ if cache_enabled:
1449
+ lines.append(t("gateway.model.prompt_caching_enabled"))
1450
+
1451
+ if result.warning_message:
1452
+ lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
1453
+
1454
+ if persist_global:
1455
+ lines.append(t("gateway.model.saved_global"))
1456
+ else:
1457
+ lines.append(t("gateway.model.session_only_hint"))
1458
+
1459
+ return "\n".join(lines)
1460
+
1461
+ # Expensive-model confirmation gate (typed /model <name> path).
1462
+ # The pickers (Telegram/Discord inline keyboards, TUI, dashboard)
1463
+ # already confirm via their own UI affordances; this covers the
1464
+ # direct text command, which previously bypassed the guard.
1465
+ # expensive_model_warning() may hit models.dev or a /models endpoint
1466
+ # on a cache miss, so run it off the event loop.
1467
+ _cost_warning = None
1468
+ try:
1469
+ from hermes_cli.model_cost_guard import expensive_model_warning
1470
+
1471
+ _cost_warning = await asyncio.to_thread(
1472
+ expensive_model_warning,
1473
+ result.new_model,
1474
+ provider=result.target_provider,
1475
+ base_url=result.base_url or current_base_url or "",
1476
+ api_key=result.api_key or current_api_key or "",
1477
+ model_info=result.model_info,
1478
+ )
1479
+ except Exception:
1480
+ _cost_warning = None
1481
+ if _cost_warning is not None:
1482
+ async def _on_cost_confirm(choice: str) -> str:
1483
+ if choice == "cancel":
1484
+ return (
1485
+ f"🟡 Model switch cancelled. Current model unchanged "
1486
+ f"({current_model or 'unknown'})."
1487
+ )
1488
+ # "once" and "always" both proceed — there is no persistent
1489
+ # opt-out for the cost guard (each expensive switch should be
1490
+ # an explicit decision).
1491
+ return await _finish_switch()
1492
+
1493
+ _p = self._typed_command_prefix_for(event.source.platform)
1494
+ return await self._request_slash_confirm(
1495
+ event=event,
1496
+ command="model",
1497
+ title="Expensive Model Warning",
1498
+ message=(
1499
+ f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
1500
+ f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
1501
+ "the current model._"
1502
+ ),
1503
+ handler=_on_cost_confirm,
1504
+ )
1505
+
1506
+ return await _finish_switch()
1507
+
1508
+ async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
1509
+ """Handle /codex-runtime command in the gateway.
1510
+
1511
+ Same surface as the CLI handler in cli.py:
1512
+ /codex-runtime — show current state
1513
+ /codex-runtime auto — Hermes default runtime
1514
+ /codex-runtime codex_app_server — codex subprocess runtime
1515
+ /codex-runtime on / off — synonyms
1516
+
1517
+ On change, the cached agent for this session is evicted so the next
1518
+ message creates a fresh AIAgent with the new api_mode wired in
1519
+ (avoids prompt-cache invalidation mid-session)."""
1520
+ from hermes_cli import codex_runtime_switch as crs
1521
+
1522
+ raw_args = event.get_command_args().strip() if event else ""
1523
+ new_value, errors = crs.parse_args(raw_args)
1524
+ if errors:
1525
+ return "❌ " + "\n❌ ".join(errors)
1526
+
1527
+ # Load + persist via the same helpers used for /model and /yolo
1528
+ try:
1529
+ from hermes_cli.config import load_config, save_config
1530
+ except Exception as exc:
1531
+ return f"❌ Could not load config: {exc}"
1532
+ cfg = load_config()
1533
+
1534
+ result = crs.apply(
1535
+ cfg,
1536
+ new_value,
1537
+ persist_callback=(save_config if new_value is not None else None),
1538
+ )
1539
+
1540
+ # On a real change, evict the cached agent so the new runtime takes
1541
+ # effect on the next message rather than waiting for cache TTL.
1542
+ if result.success and new_value is not None and result.requires_new_session:
1543
+ try:
1544
+ session_key = self._session_key_for_source(event.source)
1545
+ self._evict_cached_agent(session_key)
1546
+ except Exception:
1547
+ logger.debug("could not evict cached agent after codex-runtime change",
1548
+ exc_info=True)
1549
+
1550
+ prefix = "✓" if result.success else "✗"
1551
+ return f"{prefix} {result.message}"
1552
+
1553
+ async def _handle_personality_command(self, event: MessageEvent) -> str:
1554
+ """Handle /personality command - list or set a personality."""
1555
+ from gateway.run import _hermes_home, _load_gateway_config
1556
+ from hermes_constants import display_hermes_home
1557
+
1558
+ args = event.get_command_args().strip().lower()
1559
+ config_path = _hermes_home / 'config.yaml'
1560
+
1561
+ try:
1562
+ config = _load_gateway_config()
1563
+ personalities = cfg_get(config, "agent", "personalities", default={})
1564
+ except Exception:
1565
+ config = {}
1566
+ personalities = {}
1567
+
1568
+ if not personalities:
1569
+ return t("gateway.personality.none_configured", path=display_hermes_home())
1570
+
1571
+ if not args:
1572
+ lines = [t("gateway.personality.header")]
1573
+ lines.append(t("gateway.personality.none_option"))
1574
+ for name, prompt in personalities.items():
1575
+ if isinstance(prompt, dict):
1576
+ preview = prompt.get("description") or prompt.get("system_prompt", "")[:50]
1577
+ else:
1578
+ preview = prompt[:50] + "..." if len(prompt) > 50 else prompt
1579
+ lines.append(t("gateway.personality.item", name=name, preview=preview))
1580
+ lines.append(t("gateway.personality.usage"))
1581
+ return "\n".join(lines)
1582
+
1583
+ def _resolve_prompt(value):
1584
+ if isinstance(value, dict):
1585
+ parts = [value.get("system_prompt", "")]
1586
+ if value.get("tone"):
1587
+ parts.append(f'Tone: {value["tone"]}')
1588
+ if value.get("style"):
1589
+ parts.append(f'Style: {value["style"]}')
1590
+ return "\n".join(p for p in parts if p)
1591
+ return str(value)
1592
+
1593
+ if args in {"none", "default", "neutral"}:
1594
+ try:
1595
+ if "agent" not in config or not isinstance(config.get("agent"), dict):
1596
+ config["agent"] = {}
1597
+ config["agent"]["system_prompt"] = ""
1598
+ atomic_yaml_write(config_path, config)
1599
+ except Exception as e:
1600
+ return t("gateway.personality.save_failed", error=str(e))
1601
+ self._ephemeral_system_prompt = ""
1602
+ return t("gateway.personality.cleared")
1603
+ elif args in personalities:
1604
+ new_prompt = _resolve_prompt(personalities[args])
1605
+
1606
+ # Write to config.yaml, same pattern as CLI save_config_value.
1607
+ try:
1608
+ if "agent" not in config or not isinstance(config.get("agent"), dict):
1609
+ config["agent"] = {}
1610
+ config["agent"]["system_prompt"] = new_prompt
1611
+ atomic_yaml_write(config_path, config)
1612
+ except Exception as e:
1613
+ return t("gateway.personality.save_failed", error=str(e))
1614
+
1615
+ # Update in-memory so it takes effect on the very next message.
1616
+ self._ephemeral_system_prompt = new_prompt
1617
+
1618
+ return t("gateway.personality.set_to", name=args)
1619
+
1620
+ available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
1621
+ return t("gateway.personality.unknown", name=args, available=available)
1622
+
1623
+ async def _handle_retry_command(self, event: MessageEvent) -> str:
1624
+ """Handle /retry command - re-send the last user message."""
1625
+ source = event.source
1626
+ session_entry = self.session_store.get_or_create_session(source)
1627
+ history = self.session_store.load_transcript(session_entry.session_id)
1628
+
1629
+ # Find the last user message
1630
+ last_user_msg = None
1631
+ last_user_idx = None
1632
+ for i in range(len(history) - 1, -1, -1):
1633
+ if history[i].get("role") == "user":
1634
+ last_user_msg = history[i].get("content", "")
1635
+ last_user_idx = i
1636
+ break
1637
+
1638
+ if not last_user_msg:
1639
+ return t("gateway.retry.no_previous")
1640
+
1641
+ # Truncate history to before the last user message and persist
1642
+ truncated = history[:last_user_idx]
1643
+ self.session_store.rewrite_transcript(session_entry.session_id, truncated)
1644
+ # Reset stored token count — transcript was truncated
1645
+ session_entry.last_prompt_tokens = 0
1646
+
1647
+ # Re-send by creating a fake text event with the old message
1648
+ retry_event = MessageEvent(
1649
+ text=last_user_msg,
1650
+ message_type=MessageType.TEXT,
1651
+ source=source,
1652
+ raw_message=event.raw_message,
1653
+ channel_prompt=event.channel_prompt,
1654
+ )
1655
+
1656
+ # Let the normal message handler process it
1657
+ return await self._handle_message(retry_event)
1658
+
1659
+ async def _handle_goal_command(self, event: "MessageEvent") -> str:
1660
+ """Handle /goal for gateway platforms.
1661
+
1662
+ Subcommands: ``/goal`` / ``/goal status`` / ``/goal pause`` /
1663
+ ``/goal resume`` / ``/goal clear``. Any other text becomes the
1664
+ new goal.
1665
+
1666
+ Setting a new goal queues the goal text as the next turn so the
1667
+ agent starts working on it immediately — the post-turn
1668
+ continuation hook then takes over from there.
1669
+ """
1670
+ args = (event.get_command_args() or "").strip()
1671
+ lower = args.lower()
1672
+
1673
+ mgr, session_entry = self._get_goal_manager_for_event(event)
1674
+ if mgr is None:
1675
+ return t("gateway.goal.unavailable")
1676
+
1677
+ if not args or lower == "status":
1678
+ return mgr.status_line()
1679
+
1680
+ if lower == "pause":
1681
+ state = mgr.pause(reason="user-paused")
1682
+ if state is None:
1683
+ return t("gateway.goal.no_goal_set")
1684
+ try:
1685
+ adapter = self.adapters.get(event.source.platform) if event.source else None
1686
+ _quick_key = self._session_key_for_source(event.source) if event.source else None
1687
+ if adapter and _quick_key:
1688
+ self._clear_goal_pending_continuations(_quick_key, adapter)
1689
+ except Exception as exc:
1690
+ logger.debug("goal pause: pending continuation cleanup failed: %s", exc)
1691
+ return t("gateway.goal.paused", goal=state.goal)
1692
+
1693
+ if lower == "resume":
1694
+ state = mgr.resume()
1695
+ if state is None:
1696
+ return t("gateway.goal.no_resume")
1697
+ return t("gateway.goal.resumed", goal=state.goal)
1698
+
1699
+ if lower in {"clear", "stop", "done"}:
1700
+ had = mgr.has_goal()
1701
+ mgr.clear()
1702
+ try:
1703
+ adapter = self.adapters.get(event.source.platform) if event.source else None
1704
+ _quick_key = self._session_key_for_source(event.source) if event.source else None
1705
+ if adapter and _quick_key:
1706
+ self._clear_goal_pending_continuations(_quick_key, adapter)
1707
+ except Exception as exc:
1708
+ logger.debug("goal clear: pending continuation cleanup failed: %s", exc)
1709
+ return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
1710
+
1711
+ # Otherwise — treat the remaining text as the new goal.
1712
+ try:
1713
+ state = mgr.set(args)
1714
+ except ValueError as exc:
1715
+ return t("gateway.goal.invalid", error=str(exc))
1716
+
1717
+ # Queue the goal text as an immediate first turn so the agent
1718
+ # starts making progress. The post-turn hook takes over after.
1719
+ adapter = self.adapters.get(event.source.platform) if event.source else None
1720
+ _quick_key = self._session_key_for_source(event.source) if event.source else None
1721
+ if adapter and _quick_key:
1722
+ try:
1723
+ kickoff_event = MessageEvent(
1724
+ text=state.goal,
1725
+ message_type=MessageType.TEXT,
1726
+ source=event.source,
1727
+ message_id=event.message_id,
1728
+ channel_prompt=event.channel_prompt,
1729
+ )
1730
+ self._enqueue_fifo(_quick_key, kickoff_event, adapter)
1731
+ except Exception as exc:
1732
+ logger.debug("goal kickoff enqueue failed: %s", exc)
1733
+
1734
+ return t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
1735
+
1736
+ async def _handle_subgoal_command(self, event: "MessageEvent") -> str:
1737
+ """Handle /subgoal for gateway platforms (mirror of CLI handler).
1738
+
1739
+ Subgoals are extra criteria appended to the active goal mid-loop.
1740
+ They modify state read at the next turn boundary, so this is safe
1741
+ to invoke while the agent is running.
1742
+ """
1743
+ args = (event.get_command_args() or "").strip()
1744
+ mgr, _session_entry = self._get_goal_manager_for_event(event)
1745
+ if mgr is None:
1746
+ return t("gateway.goal.unavailable")
1747
+ if not mgr.has_goal():
1748
+ return "No active goal. Set one with /goal <text>."
1749
+
1750
+ # No args → list current subgoals.
1751
+ if not args:
1752
+ return f"{mgr.status_line()}\n{mgr.render_subgoals()}"
1753
+
1754
+ tokens = args.split(None, 1)
1755
+ verb = tokens[0].lower()
1756
+ rest = tokens[1].strip() if len(tokens) > 1 else ""
1757
+
1758
+ if verb == "remove":
1759
+ if not rest:
1760
+ return "Usage: /subgoal remove <n>"
1761
+ try:
1762
+ idx = int(rest.split()[0])
1763
+ except ValueError:
1764
+ return "/subgoal remove: <n> must be an integer (1-based index)."
1765
+ try:
1766
+ removed = mgr.remove_subgoal(idx)
1767
+ except (IndexError, RuntimeError) as exc:
1768
+ return f"/subgoal remove: {exc}"
1769
+ return f"✓ Removed subgoal {idx}: {removed}"
1770
+
1771
+ if verb == "clear":
1772
+ try:
1773
+ prev = mgr.clear_subgoals()
1774
+ except RuntimeError as exc:
1775
+ return f"/subgoal clear: {exc}"
1776
+ if prev:
1777
+ return f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}."
1778
+ return "No subgoals to clear."
1779
+
1780
+ try:
1781
+ text = mgr.add_subgoal(args)
1782
+ except (ValueError, RuntimeError) as exc:
1783
+ return f"/subgoal: {exc}"
1784
+ idx = len(mgr.state.subgoals) if mgr.state else 0
1785
+ return f"✓ Added subgoal {idx}: {text}"
1786
+
1787
+ async def _handle_undo_command(self, event: MessageEvent) -> str:
1788
+ """Handle /undo [N] — back up N user turns (default 1), soft-deleting
1789
+ the truncated rows on disk and echoing the backed-up message text so
1790
+ the user can copy/edit and resend.
1791
+
1792
+ Mirrors the CLI/TUI /undo: rewound rows stay in state.db (active=0)
1793
+ for audit and are hidden from re-prompts and search. The cached agent
1794
+ is evicted so the next message rebuilds context from the truncated
1795
+ (active-only) transcript — the gateway's equivalent of the CLI's
1796
+ in-place history surgery + memory-cache invalidation.
1797
+ """
1798
+ source = event.source
1799
+
1800
+ # Parse optional turn count: "/undo" → 1, "/undo 3" → 3.
1801
+ n = 1
1802
+ raw_args = event.get_command_args().strip()
1803
+ if raw_args:
1804
+ try:
1805
+ n = int(raw_args.split()[0])
1806
+ except (ValueError, IndexError):
1807
+ return t("gateway.undo.invalid_count", arg=raw_args.split()[0])
1808
+ if n < 1:
1809
+ n = 1
1810
+
1811
+ session_entry = self.session_store.get_or_create_session(source)
1812
+ result = self.session_store.rewind_session(session_entry.session_id, n)
1813
+
1814
+ if result is None:
1815
+ return t("gateway.undo.nothing")
1816
+
1817
+ # Reset stored token count — transcript was truncated.
1818
+ session_entry.last_prompt_tokens = 0
1819
+ # Evict the cached agent so the next turn rebuilds from the active-only
1820
+ # transcript and memory providers refresh their per-session caches.
1821
+ try:
1822
+ session_key = build_session_key(source)
1823
+ self._evict_cached_agent(session_key)
1824
+ except Exception as e:
1825
+ logger.debug("undo: cached-agent eviction skipped: %s", e)
1826
+
1827
+ target_text = result["target_text"]
1828
+ preview = target_text[:200] + "..." if len(target_text) > 200 else target_text
1829
+ return t(
1830
+ "gateway.undo.removed",
1831
+ turns=result["turns_undone"],
1832
+ count=result["rewound_count"],
1833
+ preview=preview,
1834
+ )
1835
+
1836
+ async def _handle_set_home_command(self, event: MessageEvent) -> str:
1837
+ """Handle /sethome command -- set the current chat as the platform's home channel."""
1838
+ from gateway.run import _home_target_env_var, _home_thread_env_var
1839
+ source = event.source
1840
+ platform_name = source.platform.value if source.platform else "unknown"
1841
+ chat_id = source.chat_id
1842
+ chat_name = source.chat_name or chat_id
1843
+
1844
+ env_key = _home_target_env_var(platform_name)
1845
+ thread_env_key = _home_thread_env_var(platform_name)
1846
+ thread_id = source.thread_id
1847
+
1848
+ # Save to .env so it persists across restarts
1849
+ try:
1850
+ from hermes_cli.config import save_env_value
1851
+ save_env_value(env_key, str(chat_id))
1852
+ # Keep thread/topic routing explicit and clear stale values when
1853
+ # /sethome is run from the parent chat instead of a thread.
1854
+ save_env_value(thread_env_key, str(thread_id or ""))
1855
+ except Exception as e:
1856
+ return t("gateway.set_home.save_failed", error=e)
1857
+
1858
+ # Keep the running gateway config in sync too. The pre-restart
1859
+ # notification path reads self.config before the process reloads env.
1860
+ if source.platform:
1861
+ platform_config = self.config.platforms.setdefault(
1862
+ source.platform,
1863
+ PlatformConfig(enabled=True),
1864
+ )
1865
+ platform_config.home_channel = HomeChannel(
1866
+ platform=source.platform,
1867
+ chat_id=str(chat_id),
1868
+ name=chat_name,
1869
+ thread_id=str(thread_id) if thread_id else None,
1870
+ )
1871
+
1872
+ return t("gateway.set_home.success", name=chat_name, chat_id=chat_id)
1873
+
1874
+ async def _handle_voice_command(self, event: MessageEvent) -> str:
1875
+ """Handle /voice [on|off|tts|channel|leave|status] command."""
1876
+ args = event.get_command_args().strip().lower()
1877
+ chat_id = event.source.chat_id
1878
+ platform = event.source.platform
1879
+ voice_key = self._voice_key(platform, chat_id)
1880
+
1881
+ adapter = self.adapters.get(platform)
1882
+
1883
+ if args in {"on", "enable"}:
1884
+ self._voice_mode[voice_key] = "voice_only"
1885
+ self._save_voice_modes()
1886
+ if adapter:
1887
+ self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
1888
+ return t("gateway.voice.enabled_voice_only")
1889
+ elif args in {"off", "disable"}:
1890
+ self._voice_mode[voice_key] = "off"
1891
+ self._save_voice_modes()
1892
+ if adapter:
1893
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
1894
+ return t("gateway.voice.disabled_text")
1895
+ elif args == "tts":
1896
+ self._voice_mode[voice_key] = "all"
1897
+ self._save_voice_modes()
1898
+ if adapter:
1899
+ self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
1900
+ return t("gateway.voice.tts_enabled")
1901
+ elif args in {"channel", "join"}:
1902
+ return await self._handle_voice_channel_join(event)
1903
+ elif args == "leave":
1904
+ return await self._handle_voice_channel_leave(event)
1905
+ elif args == "status":
1906
+ mode = self._voice_mode.get(voice_key, "off")
1907
+ labels = {
1908
+ "off": t("gateway.voice.label_off"),
1909
+ "voice_only": t("gateway.voice.label_voice_only"),
1910
+ "all": t("gateway.voice.label_all"),
1911
+ }
1912
+ # Append voice channel info if connected
1913
+ adapter = self.adapters.get(event.source.platform)
1914
+ guild_id = self._get_guild_id(event)
1915
+ if guild_id and hasattr(adapter, "get_voice_channel_info"):
1916
+ info = adapter.get_voice_channel_info(guild_id)
1917
+ if info:
1918
+ lines = [
1919
+ t("gateway.voice.status_mode", label=labels.get(mode, mode)),
1920
+ t("gateway.voice.status_channel", channel=info['channel_name']),
1921
+ t("gateway.voice.status_participants", count=info['member_count']),
1922
+ ]
1923
+ for m in info["members"]:
1924
+ status = t("gateway.voice.speaking") if m.get("is_speaking") else ""
1925
+ lines.append(t("gateway.voice.status_member", name=m['display_name'], status=status))
1926
+ return "\n".join(lines)
1927
+ return t("gateway.voice.status_mode", label=labels.get(mode, mode))
1928
+ else:
1929
+ # Toggle: off → on, on/all → off
1930
+ current = self._voice_mode.get(voice_key, "off")
1931
+ if current == "off":
1932
+ self._voice_mode[voice_key] = "voice_only"
1933
+ self._save_voice_modes()
1934
+ if adapter:
1935
+ self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
1936
+ toggle_line = t("gateway.voice.enabled_short")
1937
+ else:
1938
+ self._voice_mode[voice_key] = "off"
1939
+ self._save_voice_modes()
1940
+ if adapter:
1941
+ self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
1942
+ toggle_line = t("gateway.voice.disabled_short")
1943
+ # Bare /voice still toggles, but append an explainer so users
1944
+ # discover the on/off/tts/status subcommands (and, on Discord,
1945
+ # live voice-channel join/leave). The toggle result is shown
1946
+ # first via the {toggle} placeholder.
1947
+ supports_voice_channels = adapter is not None and hasattr(
1948
+ adapter, "join_voice_channel"
1949
+ )
1950
+ channels = (
1951
+ t("gateway.voice.help_channels") if supports_voice_channels else ""
1952
+ )
1953
+ return t("gateway.voice.help", toggle=toggle_line, channels=channels)
1954
+
1955
+ async def _handle_rollback_command(self, event: MessageEvent) -> str:
1956
+ """Handle /rollback command — list or restore filesystem checkpoints."""
1957
+ from gateway.run import _hermes_home
1958
+ from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list
1959
+
1960
+ # Read checkpoint config from config.yaml
1961
+ cp_cfg = {}
1962
+ try:
1963
+ import yaml as _y
1964
+ _cfg_path = _hermes_home / "config.yaml"
1965
+ if _cfg_path.exists():
1966
+ with open(_cfg_path, encoding="utf-8") as _f:
1967
+ _data = _y.safe_load(_f) or {}
1968
+ cp_cfg = _data.get("checkpoints", {})
1969
+ if isinstance(cp_cfg, bool):
1970
+ cp_cfg = {"enabled": cp_cfg}
1971
+ except Exception:
1972
+ pass
1973
+
1974
+ if not cp_cfg.get("enabled", False):
1975
+ return t("gateway.rollback.not_enabled")
1976
+
1977
+ mgr = CheckpointManager(
1978
+ enabled=True,
1979
+ max_snapshots=cp_cfg.get("max_snapshots", 50),
1980
+ max_total_size_mb=cp_cfg.get("max_total_size_mb", 500),
1981
+ max_file_size_mb=cp_cfg.get("max_file_size_mb", 10),
1982
+ )
1983
+
1984
+ cwd = os.getenv("TERMINAL_CWD", str(Path.home()))
1985
+ arg = event.get_command_args().strip()
1986
+
1987
+ if not arg:
1988
+ checkpoints = mgr.list_checkpoints(cwd)
1989
+ return format_checkpoint_list(checkpoints, cwd)
1990
+
1991
+ # Restore by number or hash
1992
+ checkpoints = mgr.list_checkpoints(cwd)
1993
+ if not checkpoints:
1994
+ return t("gateway.rollback.none_found", cwd=cwd)
1995
+
1996
+ target_hash = None
1997
+ try:
1998
+ idx = int(arg) - 1
1999
+ if 0 <= idx < len(checkpoints):
2000
+ target_hash = checkpoints[idx]["hash"]
2001
+ else:
2002
+ return t("gateway.rollback.invalid_number", max=len(checkpoints))
2003
+ except ValueError:
2004
+ target_hash = arg
2005
+
2006
+ result = mgr.restore(cwd, target_hash)
2007
+ if result["success"]:
2008
+ return t(
2009
+ "gateway.rollback.restored",
2010
+ hash=result["restored_to"],
2011
+ reason=result["reason"],
2012
+ )
2013
+ return t("gateway.rollback.restore_failed", error=result["error"])
2014
+
2015
+ async def _handle_background_command(self, event: MessageEvent) -> str:
2016
+ """Handle /background <prompt> — run a prompt in a separate background session.
2017
+
2018
+ Spawns a new AIAgent in a background thread with its own session.
2019
+ When it completes, sends the result back to the same chat without
2020
+ modifying the active session's conversation history.
2021
+ """
2022
+ prompt = event.get_command_args().strip()
2023
+ if not prompt:
2024
+ return t("gateway.background.usage")
2025
+
2026
+ source = event.source
2027
+ task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}"
2028
+
2029
+ event_message_id = self._reply_anchor_for_event(event)
2030
+
2031
+ # Forward image/audio attachments so the background agent can see them.
2032
+ media_urls = list(event.media_urls) if event.media_urls else []
2033
+ media_types = list(event.media_types) if event.media_types else []
2034
+
2035
+ # Fire-and-forget the background task
2036
+ _task = asyncio.create_task(
2037
+ self._run_background_task(
2038
+ prompt,
2039
+ source,
2040
+ task_id,
2041
+ event_message_id=event_message_id,
2042
+ media_urls=media_urls,
2043
+ media_types=media_types,
2044
+ )
2045
+ )
2046
+ self._background_tasks.add(_task)
2047
+ _task.add_done_callback(self._background_tasks.discard)
2048
+
2049
+ preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
2050
+ return t("gateway.background.started", preview=preview, task_id=task_id)
2051
+
2052
+ async def _handle_reasoning_command(self, event: MessageEvent) -> str:
2053
+ """Handle /reasoning command — manage reasoning effort and display toggle.
2054
+
2055
+ Usage:
2056
+ /reasoning Show current effort level and display state
2057
+ /reasoning <level> Set reasoning effort for this session only
2058
+ /reasoning <level> --global Persist reasoning effort to config.yaml
2059
+ /reasoning reset Clear this session's reasoning override
2060
+ /reasoning show|on Show model reasoning in responses
2061
+ /reasoning hide|off Hide model reasoning from responses
2062
+ """
2063
+ from gateway.run import _hermes_home, _platform_config_key
2064
+ import yaml
2065
+
2066
+ raw_args = event.get_command_args().strip()
2067
+ args, persist_global = self._parse_reasoning_command_args(raw_args)
2068
+ config_path = _hermes_home / "config.yaml"
2069
+ # Normalize the source (Telegram DM topic recovery) before deriving
2070
+ # the override key so storage matches the key the next message turn
2071
+ # reads — same fix as /model (#30479).
2072
+ _reasoning_source = self._normalize_source_for_session_key(event.source)
2073
+ session_key = self._session_key_for_source(_reasoning_source)
2074
+ self._show_reasoning = self._load_show_reasoning()
2075
+ self._reasoning_config = self._resolve_session_reasoning_config(
2076
+ source=event.source,
2077
+ session_key=session_key,
2078
+ )
2079
+
2080
+ def _save_config_key(key_path: str, value):
2081
+ """Save a dot-separated key to config.yaml."""
2082
+ try:
2083
+ user_config = {}
2084
+ if config_path.exists():
2085
+ with open(config_path, encoding="utf-8") as f:
2086
+ user_config = yaml.safe_load(f) or {}
2087
+ keys = key_path.split(".")
2088
+ current = user_config
2089
+ for k in keys[:-1]:
2090
+ if k not in current or not isinstance(current[k], dict):
2091
+ current[k] = {}
2092
+ current = current[k]
2093
+ current[keys[-1]] = value
2094
+ atomic_yaml_write(config_path, user_config)
2095
+ return True
2096
+ except Exception as e:
2097
+ logger.error("Failed to save config key %s: %s", key_path, e)
2098
+ return False
2099
+
2100
+ if not raw_args:
2101
+ # Show current state
2102
+ rc = self._reasoning_config
2103
+ if rc is None:
2104
+ level = t("gateway.reasoning.level_default")
2105
+ elif rc.get("enabled") is False:
2106
+ level = t("gateway.reasoning.level_disabled")
2107
+ else:
2108
+ level = rc.get("effort", "medium")
2109
+ display_state = (
2110
+ t("gateway.reasoning.display_on")
2111
+ if self._show_reasoning
2112
+ else t("gateway.reasoning.display_off")
2113
+ )
2114
+ has_session_override = session_key in (getattr(self, "_session_reasoning_overrides", {}) or {})
2115
+ scope = (
2116
+ t("gateway.reasoning.scope_session")
2117
+ if has_session_override
2118
+ else t("gateway.reasoning.scope_global")
2119
+ )
2120
+ return t(
2121
+ "gateway.reasoning.status",
2122
+ level=level,
2123
+ scope=scope,
2124
+ display=display_state,
2125
+ )
2126
+
2127
+ # Display toggle (per-platform)
2128
+ platform_key = _platform_config_key(event.source.platform)
2129
+ if args in {"show", "on"}:
2130
+ self._show_reasoning = True
2131
+ _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True)
2132
+ return t("gateway.reasoning.display_set_on", platform=platform_key)
2133
+
2134
+ if args in {"hide", "off"}:
2135
+ self._show_reasoning = False
2136
+ _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False)
2137
+ return t("gateway.reasoning.display_set_off", platform=platform_key)
2138
+
2139
+ # Effort level change
2140
+ effort = args.strip()
2141
+ if effort == "reset":
2142
+ if persist_global:
2143
+ return t("gateway.reasoning.reset_global_unsupported")
2144
+ self._set_session_reasoning_override(session_key, None)
2145
+ self._reasoning_config = self._load_reasoning_config()
2146
+ self._evict_cached_agent(session_key)
2147
+ return t("gateway.reasoning.reset_done")
2148
+ if effort == "none":
2149
+ parsed = {"enabled": False}
2150
+ elif effort in {"minimal", "low", "medium", "high", "xhigh"}:
2151
+ parsed = {"enabled": True, "effort": effort}
2152
+ else:
2153
+ return t(
2154
+ "gateway.reasoning.unknown_arg",
2155
+ arg=effort or raw_args.lower(),
2156
+ )
2157
+
2158
+ self._reasoning_config = parsed
2159
+ if persist_global:
2160
+ if _save_config_key("agent.reasoning_effort", effort):
2161
+ self._set_session_reasoning_override(session_key, None)
2162
+ self._evict_cached_agent(session_key)
2163
+ return t("gateway.reasoning.set_global", effort=effort)
2164
+ self._set_session_reasoning_override(session_key, parsed)
2165
+ self._evict_cached_agent(session_key)
2166
+ return t("gateway.reasoning.set_global_save_failed", effort=effort)
2167
+
2168
+ self._set_session_reasoning_override(session_key, parsed)
2169
+ self._evict_cached_agent(session_key)
2170
+ return t("gateway.reasoning.set_session", effort=effort)
2171
+
2172
+ async def _handle_memory_command(self, event: MessageEvent) -> str:
2173
+ """Handle /memory — review pending memory writes + toggle the approval gate.
2174
+
2175
+ Memory entries are small enough to review inline in a chat bubble, so
2176
+ the full pending/approve/reject/approval flow works on every platform.
2177
+ Gate changes persist to config.yaml and evict the cached agent so the
2178
+ new setting takes effect on the next message.
2179
+ """
2180
+ from gateway.run import _hermes_home
2181
+ from hermes_cli.write_approval_commands import handle_pending_subcommand
2182
+ from tools import write_approval as wa
2183
+ from tools.memory_tool import MemoryStore
2184
+
2185
+ raw_args = event.get_command_args().strip()
2186
+ args = raw_args.split() if raw_args else []
2187
+ session_key = self._session_key_for_source(event.source)
2188
+ config_path = _hermes_home / "config.yaml"
2189
+
2190
+ def _set_approval(enabled: bool):
2191
+ import yaml
2192
+ user_config = {}
2193
+ if config_path.exists():
2194
+ with open(config_path, encoding="utf-8") as f:
2195
+ user_config = yaml.safe_load(f) or {}
2196
+ user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
2197
+ atomic_yaml_write(config_path, user_config)
2198
+ # New setting must take effect next message → drop cached agent.
2199
+ self._evict_cached_agent(session_key)
2200
+
2201
+ # Apply approved writes against a fresh on-disk store (the gateway has
2202
+ # no long-lived agent; the store persists to the same MEMORY/USER.md).
2203
+ store = MemoryStore()
2204
+ store.load_from_disk()
2205
+
2206
+ out = handle_pending_subcommand(
2207
+ wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
2208
+ )
2209
+ if out is None:
2210
+ out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
2211
+ "reject <id>, approval <on|off>.")
2212
+ return out
2213
+
2214
+ async def _handle_skills_command(self, event: MessageEvent) -> str:
2215
+ """Handle /skills on the gateway — pending skill-write review only.
2216
+
2217
+ The full skills hub (search/browse/install) stays CLI-only; this
2218
+ handler covers the write-approval review surface (pending / approve /
2219
+ reject / diff / approval) so a skill staged from a gateway session can
2220
+ be reviewed from that same session. Gated by ``skills.write_approval``
2221
+ via the CommandDef's ``gateway_config_gate``; also answers when staged
2222
+ writes still exist after the gate was turned off (so they are never
2223
+ stranded).
2224
+
2225
+ ``diff`` output is truncated for chat bubbles — the full diff lives in
2226
+ the pending JSON file under ``~/.hermes/pending/skills/``. (Note this is
2227
+ the write-approval ``diff <id>``; the CLI also has an unrelated
2228
+ ``hermes skills diff <name>`` that diffs a bundled skill vs stock.)
2229
+ """
2230
+ from gateway.run import _hermes_home
2231
+ from hermes_cli.write_approval_commands import handle_pending_subcommand
2232
+ from tools import write_approval as wa
2233
+
2234
+ raw_args = event.get_command_args().strip()
2235
+ args = raw_args.split() if raw_args else []
2236
+ session_key = self._session_key_for_source(event.source)
2237
+ config_path = _hermes_home / "config.yaml"
2238
+
2239
+ gate_on = wa.write_approval_enabled(wa.SKILLS)
2240
+ wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
2241
+ if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
2242
+ return ("Skill write approval is off (skills.write_approval). "
2243
+ "Enable it with /skills approval on, then review staged "
2244
+ "writes here with /skills pending.")
2245
+
2246
+ def _set_approval(enabled: bool):
2247
+ import yaml
2248
+ user_config = {}
2249
+ if config_path.exists():
2250
+ with open(config_path, encoding="utf-8") as f:
2251
+ user_config = yaml.safe_load(f) or {}
2252
+ user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
2253
+ atomic_yaml_write(config_path, user_config)
2254
+ # New setting must take effect next message → drop cached agent.
2255
+ self._evict_cached_agent(session_key)
2256
+
2257
+ out = handle_pending_subcommand(
2258
+ wa.SKILLS, args, set_mode_fn=_set_approval,
2259
+ )
2260
+ if out is None:
2261
+ return ("Unknown /skills subcommand on this platform. Use: pending, "
2262
+ "approve <id>, reject <id>, diff <id>, approval <on|off>. "
2263
+ "(Search/install are CLI-only.)")
2264
+
2265
+ # Chat bubbles can't hold a full skill diff — truncate and point at
2266
+ # the real review surface. (Note: `hermes skills diff <name>` is a
2267
+ # *different* command — it diffs a bundled skill against its stock
2268
+ # version — so we point at the pending JSON file, not that command.)
2269
+ if args and args[0].lower() == "diff" and len(out) > 3000:
2270
+ pending_id = args[1] if len(args) > 1 else "<id>"
2271
+ out = (out[:3000]
2272
+ + "\n… (truncated — full diff in "
2273
+ f"~/.hermes/pending/skills/{pending_id}.json)")
2274
+ return out
2275
+
2276
+ async def _handle_fast_command(self, event: MessageEvent) -> str:
2277
+ """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
2278
+ from gateway.run import _hermes_home, _load_gateway_config, _resolve_gateway_model
2279
+ import yaml
2280
+ from hermes_cli.models import model_supports_fast_mode
2281
+
2282
+ args = event.get_command_args().strip().lower()
2283
+ config_path = _hermes_home / "config.yaml"
2284
+ self._service_tier = self._load_service_tier()
2285
+
2286
+ user_config = _load_gateway_config()
2287
+ model = _resolve_gateway_model(user_config)
2288
+ if not model_supports_fast_mode(model):
2289
+ return t("gateway.fast.not_supported")
2290
+
2291
+ def _save_config_key(key_path: str, value):
2292
+ """Save a dot-separated key to config.yaml."""
2293
+ try:
2294
+ user_config = {}
2295
+ if config_path.exists():
2296
+ with open(config_path, encoding="utf-8") as f:
2297
+ user_config = yaml.safe_load(f) or {}
2298
+ keys = key_path.split(".")
2299
+ current = user_config
2300
+ for k in keys[:-1]:
2301
+ if k not in current or not isinstance(current[k], dict):
2302
+ current[k] = {}
2303
+ current = current[k]
2304
+ current[keys[-1]] = value
2305
+ atomic_yaml_write(config_path, user_config)
2306
+ return True
2307
+ except Exception as e:
2308
+ logger.error("Failed to save config key %s: %s", key_path, e)
2309
+ return False
2310
+
2311
+ if not args or args == "status":
2312
+ status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal")
2313
+ return t("gateway.fast.status", mode=status)
2314
+
2315
+ if args in {"fast", "on"}:
2316
+ self._service_tier = "priority"
2317
+ saved_value = "fast"
2318
+ label = t("gateway.fast.label_fast")
2319
+ elif args in {"normal", "off"}:
2320
+ self._service_tier = None
2321
+ saved_value = "normal"
2322
+ label = t("gateway.fast.label_normal")
2323
+ else:
2324
+ return t("gateway.fast.unknown_arg", arg=args)
2325
+
2326
+ if _save_config_key("agent.service_tier", saved_value):
2327
+ return t("gateway.fast.saved", label=label)
2328
+ return t("gateway.fast.session_only", label=label)
2329
+
2330
+ async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
2331
+ """Handle /yolo — toggle dangerous command approval bypass for this session only."""
2332
+ from tools.approval import (
2333
+ disable_session_yolo,
2334
+ enable_session_yolo,
2335
+ is_session_yolo_enabled,
2336
+ )
2337
+
2338
+ session_key = self._session_key_for_source(event.source)
2339
+ current = is_session_yolo_enabled(session_key)
2340
+ if current:
2341
+ disable_session_yolo(session_key)
2342
+ return EphemeralReply(t("gateway.yolo.disabled"))
2343
+ else:
2344
+ enable_session_yolo(session_key)
2345
+ return EphemeralReply(t("gateway.yolo.enabled"))
2346
+
2347
+ async def _handle_verbose_command(self, event: MessageEvent) -> str:
2348
+ """Handle /verbose command — cycle tool progress display mode.
2349
+
2350
+ Gated by ``display.tool_progress_command`` in config.yaml (default off).
2351
+ When enabled, cycles the tool progress mode through off → new → all →
2352
+ verbose → off for the *current platform*. The setting is saved to
2353
+ ``display.platforms.<platform>.tool_progress`` so each channel can
2354
+ have its own verbosity level independently.
2355
+ """
2356
+ from gateway.run import _hermes_home, _load_gateway_config, _platform_config_key
2357
+
2358
+ config_path = _hermes_home / "config.yaml"
2359
+ platform_key = _platform_config_key(event.source.platform)
2360
+
2361
+ # --- check config gate ------------------------------------------------
2362
+ try:
2363
+ user_config = _load_gateway_config()
2364
+ gate_enabled = is_truthy_value(
2365
+ cfg_get(user_config, "display", "tool_progress_command"),
2366
+ default=False,
2367
+ )
2368
+ except Exception:
2369
+ gate_enabled = False
2370
+
2371
+ if not gate_enabled:
2372
+ return t("gateway.verbose.not_enabled")
2373
+
2374
+ # --- cycle mode (per-platform) ----------------------------------------
2375
+ cycle = ["off", "new", "all", "verbose"]
2376
+ descriptions = {
2377
+ "off": t("gateway.verbose.mode_off"),
2378
+ "new": t("gateway.verbose.mode_new"),
2379
+ "all": t("gateway.verbose.mode_all"),
2380
+ "verbose": t("gateway.verbose.mode_verbose"),
2381
+ }
2382
+
2383
+ # Read current effective mode for this platform via the resolver
2384
+ from gateway.display_config import resolve_display_setting
2385
+ current = resolve_display_setting(user_config, platform_key, "tool_progress", "all")
2386
+ if current not in cycle:
2387
+ current = "all"
2388
+ idx = (cycle.index(current) + 1) % len(cycle)
2389
+ new_mode = cycle[idx]
2390
+
2391
+ # Save to display.platforms.<platform>.tool_progress
2392
+ try:
2393
+ if "display" not in user_config or not isinstance(user_config.get("display"), dict):
2394
+ user_config["display"] = {}
2395
+ display = user_config["display"]
2396
+ if "platforms" not in display or not isinstance(display.get("platforms"), dict):
2397
+ display["platforms"] = {}
2398
+ if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict):
2399
+ display["platforms"][platform_key] = {}
2400
+ display["platforms"][platform_key]["tool_progress"] = new_mode
2401
+ atomic_yaml_write(config_path, user_config)
2402
+ return (
2403
+ f"{descriptions[new_mode]}\n"
2404
+ + t("gateway.verbose.saved_suffix", platform=platform_key)
2405
+ )
2406
+ except Exception as e:
2407
+ logger.warning("Failed to save tool_progress mode: %s", e)
2408
+ return f"{descriptions[new_mode]}\n" + t("gateway.verbose.save_failed", error=e)
2409
+
2410
+ async def _handle_footer_command(self, event: MessageEvent) -> str:
2411
+ """Handle /footer command — toggle the runtime-metadata footer.
2412
+
2413
+ Usage:
2414
+ /footer → toggle on/off
2415
+ /footer on → enable globally
2416
+ /footer off → disable globally
2417
+ /footer status → show current state + fields
2418
+
2419
+ The footer is saved to ``display.runtime_footer.enabled`` (global).
2420
+ Per-platform overrides under ``display.platforms.<platform>.runtime_footer``
2421
+ are respected but not modified here — edit config.yaml directly for
2422
+ per-platform control.
2423
+ """
2424
+ from gateway.run import _hermes_home, _load_gateway_config, _platform_config_key, _resolve_gateway_model
2425
+ from gateway.runtime_footer import resolve_footer_config
2426
+
2427
+ config_path = _hermes_home / "config.yaml"
2428
+ platform_key = _platform_config_key(event.source.platform)
2429
+
2430
+ # --- parse argument -------------------------------------------------
2431
+ arg = ""
2432
+ try:
2433
+ text = (getattr(event, "message", None) or "").strip()
2434
+ if text.startswith("/"):
2435
+ parts = text.split(None, 1)
2436
+ if len(parts) > 1:
2437
+ arg = parts[1].strip().lower()
2438
+ except Exception:
2439
+ arg = ""
2440
+
2441
+ # --- load config ----------------------------------------------------
2442
+ try:
2443
+ user_config: dict = _load_gateway_config()
2444
+ except Exception as e:
2445
+ return t("gateway.config_read_failed", error=e)
2446
+
2447
+ effective = resolve_footer_config(user_config, platform_key)
2448
+
2449
+ if arg in {"status", "?"}:
2450
+ state = t("gateway.footer.state_on") if effective["enabled"] else t("gateway.footer.state_off")
2451
+ fields = ", ".join(effective.get("fields") or [])
2452
+ return t(
2453
+ "gateway.footer.status",
2454
+ state=state,
2455
+ fields=fields,
2456
+ platform=platform_key,
2457
+ )
2458
+
2459
+ if arg in {"on", "enable", "true", "1"}:
2460
+ new_state = True
2461
+ elif arg in {"off", "disable", "false", "0"}:
2462
+ new_state = False
2463
+ elif arg == "":
2464
+ new_state = not effective["enabled"]
2465
+ else:
2466
+ return t("gateway.footer.usage")
2467
+
2468
+ # --- write global flag ---------------------------------------------
2469
+ try:
2470
+ if not isinstance(user_config.get("display"), dict):
2471
+ user_config["display"] = {}
2472
+ display = user_config["display"]
2473
+ if not isinstance(display.get("runtime_footer"), dict):
2474
+ display["runtime_footer"] = {}
2475
+ display["runtime_footer"]["enabled"] = new_state
2476
+ atomic_yaml_write(config_path, user_config)
2477
+ except Exception as e:
2478
+ logger.warning("Failed to save runtime_footer.enabled: %s", e)
2479
+ return t("gateway.config_save_failed", error=e)
2480
+
2481
+ state = t("gateway.footer.state_on") if new_state else t("gateway.footer.state_off")
2482
+ example = ""
2483
+ if new_state:
2484
+ # Show a preview using current agent state if available.
2485
+ from gateway.runtime_footer import format_runtime_footer
2486
+ preview = format_runtime_footer(
2487
+ model=_resolve_gateway_model(user_config) or None,
2488
+ context_tokens=0,
2489
+ context_length=None,
2490
+ fields=effective.get("fields") or ["model", "context_pct", "cwd"],
2491
+ )
2492
+ if preview:
2493
+ example = t("gateway.footer.example_line", preview=preview)
2494
+ return t("gateway.footer.saved", state=state, example=example)
2495
+
2496
+ async def _handle_compress_command(self, event: MessageEvent) -> str:
2497
+ """Handle /compress command -- manually compress conversation context.
2498
+
2499
+ Accepts an optional focus topic: ``/compress <focus>`` guides the
2500
+ summariser to preserve information related to *focus* while being
2501
+ more aggressive about discarding everything else.
2502
+
2503
+ Also accepts the boundary-aware form ``/compress here [N]``:
2504
+ summarize everything except the most recent ``N`` exchanges
2505
+ (default 2), kept verbatim. Inspired by Claude Code's Rewind
2506
+ "Summarize up to here" action (v2.1.139, May 2026,
2507
+ https://code.claude.com/docs/en/whats-new/2026-w20).
2508
+ """
2509
+ source = event.source
2510
+ session_entry = self.session_store.get_or_create_session(source)
2511
+ history = self.session_store.load_transcript(session_entry.session_id)
2512
+
2513
+ if not history or len(history) < 4:
2514
+ return t("gateway.compress.not_enough")
2515
+
2516
+ # Parse args: either a focus topic (full compress) or the
2517
+ # boundary-aware "here [N]" form (partial compress).
2518
+ from hermes_cli.partial_compress import (
2519
+ parse_partial_compress_args,
2520
+ rejoin_compressed_head_and_tail,
2521
+ split_history_for_partial_compress,
2522
+ )
2523
+ _raw_args = (event.get_command_args() or "").strip()
2524
+ partial, keep_last, focus_topic = parse_partial_compress_args(_raw_args)
2525
+
2526
+ try:
2527
+ from run_agent import AIAgent
2528
+ from agent.manual_compression_feedback import summarize_manual_compression
2529
+ from agent.model_metadata import estimate_request_tokens_rough
2530
+
2531
+ session_key = self._session_key_for_source(source)
2532
+ model, runtime_kwargs = self._resolve_session_agent_runtime(
2533
+ source=source,
2534
+ session_key=session_key,
2535
+ )
2536
+ if not runtime_kwargs.get("api_key"):
2537
+ return t("gateway.compress.no_provider")
2538
+
2539
+ msgs = [
2540
+ {"role": m.get("role"), "content": m.get("content")}
2541
+ for m in history
2542
+ if m.get("role") in {"user", "assistant"} and m.get("content")
2543
+ ]
2544
+
2545
+ # Boundary-aware split: only the head is summarized; the most
2546
+ # recent `keep_last` exchanges are preserved verbatim. The
2547
+ # split snaps the tail to a user-turn start so the rejoined
2548
+ # transcript keeps role alternation valid.
2549
+ tail: list = []
2550
+ head = msgs
2551
+ if partial:
2552
+ head, tail = split_history_for_partial_compress(msgs, keep_last)
2553
+ if not tail:
2554
+ # Degenerate split — fall back to full compression.
2555
+ partial = False
2556
+ head = msgs
2557
+
2558
+ tmp_agent = AIAgent(
2559
+ **runtime_kwargs,
2560
+ model=model,
2561
+ max_iterations=4,
2562
+ quiet_mode=True,
2563
+ skip_memory=True,
2564
+ enabled_toolsets=["memory"],
2565
+ session_id=session_entry.session_id,
2566
+ )
2567
+ try:
2568
+ tmp_agent._print_fn = lambda *a, **kw: None
2569
+
2570
+ # Estimate with system prompt + tool schemas included so the
2571
+ # figure reflects real request pressure, not a transcript-only
2572
+ # underestimate (#6217). Must be computed after tmp_agent is
2573
+ # built so _cached_system_prompt/tools are populated.
2574
+ _sys_prompt = getattr(tmp_agent, "_cached_system_prompt", "") or ""
2575
+ _tools = getattr(tmp_agent, "tools", None) or None
2576
+ approx_tokens = estimate_request_tokens_rough(
2577
+ msgs, system_prompt=_sys_prompt, tools=_tools
2578
+ )
2579
+
2580
+ compressor = tmp_agent.context_compressor
2581
+ if not compressor.has_content_to_compress(head):
2582
+ return t("gateway.compress.nothing_to_do")
2583
+
2584
+ loop = asyncio.get_running_loop()
2585
+ compressed, _ = await loop.run_in_executor(
2586
+ None,
2587
+ lambda: tmp_agent._compress_context(head, "", approx_tokens=approx_tokens, focus_topic=focus_topic, force=True)
2588
+ )
2589
+
2590
+ # Re-append the verbatim tail after the compressed head,
2591
+ # guarding the seam against illegal role adjacency.
2592
+ if partial and tail:
2593
+ compressed = rejoin_compressed_head_and_tail(compressed, tail)
2594
+
2595
+ # _compress_context already calls end_session() on the old session
2596
+ # (preserving its full transcript in SQLite) and creates a new
2597
+ # session_id for the continuation. Write the compressed messages
2598
+ # into the NEW session so the original history stays searchable.
2599
+ new_session_id = tmp_agent.session_id
2600
+ rotated = new_session_id != session_entry.session_id
2601
+ if rotated:
2602
+ session_entry.session_id = new_session_id
2603
+ self.session_store._save()
2604
+ self._sync_telegram_topic_binding(
2605
+ source, session_entry, reason="compress-command",
2606
+ )
2607
+
2608
+ # Only rewrite the transcript when rotation actually produced a
2609
+ # NEW session id. If _compress_context could not rotate (e.g.
2610
+ # _session_db unavailable, or the DB split raised), session_id
2611
+ # is unchanged and rewrite_transcript() would DELETE the
2612
+ # original messages and replace them with only the compressed
2613
+ # summary — permanent data loss (#44794, #39704). In that case
2614
+ # leave the original transcript intact.
2615
+ if rotated:
2616
+ self.session_store.rewrite_transcript(new_session_id, compressed)
2617
+ else:
2618
+ logger.warning(
2619
+ "Manual /compress: session rotation did not occur "
2620
+ "(session_id unchanged) — preserving original transcript "
2621
+ "instead of overwriting it (#44794)."
2622
+ )
2623
+ # Reset stored token count — transcript changed, old value is stale
2624
+ self.session_store.update_session(
2625
+ session_entry.session_key, last_prompt_tokens=0
2626
+ )
2627
+ new_tokens = estimate_request_tokens_rough(
2628
+ compressed, system_prompt=_sys_prompt, tools=_tools
2629
+ )
2630
+ summary = summarize_manual_compression(
2631
+ msgs,
2632
+ compressed,
2633
+ approx_tokens,
2634
+ new_tokens,
2635
+ )
2636
+ # Detect summary-generation failure so we can surface a
2637
+ # visible warning to the user even on the manual /compress
2638
+ # path (otherwise the failure is silently logged).
2639
+ # _last_compress_aborted means the aux LLM returned no
2640
+ # usable summary and the compressor preserved messages
2641
+ # unchanged (no drop, no placeholder). force=True was
2642
+ # passed above so any active cooldown is bypassed.
2643
+ _summary_aborted = bool(getattr(compressor, "_last_compress_aborted", False))
2644
+ _summary_err = getattr(compressor, "_last_summary_error", None)
2645
+ # Separately: did the user's CONFIGURED aux model fail
2646
+ # and we recovered via main? Surface that as an info
2647
+ # note so they can fix their config.
2648
+ _aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
2649
+ _aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None)
2650
+ finally:
2651
+ # Evict cached agent so next turn rebuilds system prompt
2652
+ # from current files (SOUL.md, memory, etc.).
2653
+ self._evict_cached_agent(session_key)
2654
+ self._cleanup_agent_resources(tmp_agent)
2655
+ lines = [f"🗜️ {summary['headline']}"]
2656
+ if focus_topic:
2657
+ lines.append(t("gateway.compress.focus_line", topic=focus_topic))
2658
+ lines.append(summary["token_line"])
2659
+ if summary["note"]:
2660
+ lines.append(summary["note"])
2661
+ if _summary_aborted:
2662
+ lines.append(
2663
+ t(
2664
+ "gateway.compress.aborted",
2665
+ error=(_summary_err or "unknown error"),
2666
+ )
2667
+ )
2668
+ elif _aux_fail_model:
2669
+ lines.append(
2670
+ t(
2671
+ "gateway.compress.aux_failed",
2672
+ model=_aux_fail_model,
2673
+ error=(_aux_fail_err or "unknown error"),
2674
+ )
2675
+ )
2676
+ return "\n".join(lines)
2677
+ except Exception as e:
2678
+ logger.warning("Manual compress failed: %s", e)
2679
+ return t("gateway.compress.failed", error=e)
2680
+
2681
+ async def _handle_topic_command(self, event: MessageEvent, args: str = "") -> str:
2682
+ """Handle /topic for Telegram DM user-managed topic sessions."""
2683
+ source = event.source
2684
+ if source.platform != Platform.TELEGRAM or source.chat_type != "dm":
2685
+ return t("gateway.topic.not_telegram_dm")
2686
+ if not self._session_db:
2687
+ from hermes_state import format_session_db_unavailable
2688
+ return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
2689
+
2690
+ # Authorization: /topic activates multi-session mode and mutates
2691
+ # SQLite side tables. Unauthorized senders (not in allowlist) must
2692
+ # not be able to do that. Gateway routes already authorize the
2693
+ # message before reaching here, but defense in depth.
2694
+ auth_fn = getattr(self, "_is_user_authorized", None)
2695
+ if callable(auth_fn):
2696
+ try:
2697
+ if not auth_fn(source):
2698
+ return t("gateway.topic.unauthorized")
2699
+ except Exception:
2700
+ logger.debug("Topic auth check failed", exc_info=True)
2701
+
2702
+ args = event.get_command_args().strip()
2703
+
2704
+ # /topic help — inline usage without leaving the bot.
2705
+ if args.lower() in {"help", "?", "-h", "--help"}:
2706
+ return self._telegram_topic_help_text()
2707
+
2708
+ # /topic off — clean disable path so users don't have to edit the DB.
2709
+ if args.lower() in {"off", "disable", "stop"}:
2710
+ return self._disable_telegram_topic_mode_for_chat(source)
2711
+
2712
+ if args:
2713
+ if not source.thread_id:
2714
+ return t("gateway.topic.restore_needs_topic")
2715
+ return await self._restore_telegram_topic_session(event, args)
2716
+
2717
+ capabilities = await self._get_telegram_topic_capabilities(source)
2718
+ if capabilities.get("checked"):
2719
+ if capabilities.get("has_topics_enabled") is False:
2720
+ # Debounce the BotFather screenshot: don't re-send on every
2721
+ # /topic while threads are still disabled.
2722
+ if self._should_send_telegram_capability_hint(source):
2723
+ await self._send_telegram_topic_setup_image(source)
2724
+ return t("gateway.topic.topics_disabled")
2725
+ if capabilities.get("allows_users_to_create_topics") is False:
2726
+ if self._should_send_telegram_capability_hint(source):
2727
+ await self._send_telegram_topic_setup_image(source)
2728
+ return t("gateway.topic.topics_user_disallowed")
2729
+
2730
+ try:
2731
+ self._session_db.enable_telegram_topic_mode(
2732
+ chat_id=str(source.chat_id),
2733
+ user_id=str(source.user_id),
2734
+ has_topics_enabled=capabilities.get("has_topics_enabled"),
2735
+ allows_users_to_create_topics=capabilities.get("allows_users_to_create_topics"),
2736
+ )
2737
+ except Exception as exc:
2738
+ logger.exception("Failed to enable Telegram topic mode")
2739
+ return t("gateway.topic.enable_failed", error=exc)
2740
+
2741
+ if not source.thread_id:
2742
+ await self._ensure_telegram_system_topic(source)
2743
+
2744
+ if source.thread_id:
2745
+ try:
2746
+ binding = self._session_db.get_telegram_topic_binding(
2747
+ chat_id=str(source.chat_id),
2748
+ thread_id=str(source.thread_id),
2749
+ )
2750
+ except Exception:
2751
+ logger.debug("Failed to read Telegram topic binding", exc_info=True)
2752
+ binding = None
2753
+ if binding:
2754
+ session_id = str(binding.get("session_id") or "")
2755
+ title = None
2756
+ try:
2757
+ title = self._session_db.get_session_title(session_id)
2758
+ except Exception:
2759
+ title = None
2760
+ session_label = title or t("gateway.topic.untitled_session")
2761
+ return t(
2762
+ "gateway.topic.bound_status",
2763
+ label=session_label,
2764
+ session_id=session_id,
2765
+ )
2766
+ return t("gateway.topic.thread_ready")
2767
+
2768
+ return self._telegram_topic_root_status_message(source)
2769
+
2770
+ async def _handle_title_command(self, event: MessageEvent) -> str:
2771
+ """Handle /title command — set or show the current session's title."""
2772
+ source = event.source
2773
+ session_entry = self.session_store.get_or_create_session(source)
2774
+ session_id = session_entry.session_id
2775
+
2776
+ if not self._session_db:
2777
+ from hermes_state import format_session_db_unavailable
2778
+ return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
2779
+
2780
+ # Ensure session exists in SQLite DB (it may only exist in session_store
2781
+ # if this is the first command in a new session)
2782
+ existing_title = self._session_db.get_session_title(session_id)
2783
+ if existing_title is None:
2784
+ # Session doesn't exist in DB yet — create it
2785
+ try:
2786
+ self._session_db.create_session(
2787
+ session_id=session_id,
2788
+ source=source.platform.value if source.platform else "unknown",
2789
+ user_id=source.user_id,
2790
+ )
2791
+ except Exception:
2792
+ pass # Session might already exist, ignore errors
2793
+
2794
+ title_arg = event.get_command_args().strip()
2795
+ if title_arg:
2796
+ # Sanitize the title before setting
2797
+ try:
2798
+ sanitized = self._session_db.sanitize_title(title_arg)
2799
+ except ValueError as e:
2800
+ return t("gateway.shared.warn_passthrough", error=e)
2801
+ if not sanitized:
2802
+ return t("gateway.title.empty_after_clean")
2803
+ # Set the title
2804
+ try:
2805
+ if self._session_db.set_session_title(session_id, sanitized):
2806
+ return t("gateway.title.set_to", title=sanitized)
2807
+ else:
2808
+ return t("gateway.title.not_found")
2809
+ except ValueError as e:
2810
+ return t("gateway.shared.warn_passthrough", error=e)
2811
+ else:
2812
+ # Show the current title and session ID
2813
+ title = self._session_db.get_session_title(session_id)
2814
+ if title:
2815
+ return t("gateway.title.current_with_title", session_id=session_id, title=title)
2816
+ else:
2817
+ return t("gateway.title.current_no_title", session_id=session_id)
2818
+
2819
+ async def _handle_resume_command(self, event: MessageEvent) -> str:
2820
+ """Handle /resume command — list or switch to a previous session."""
2821
+ if not self._session_db:
2822
+ from hermes_state import format_session_db_unavailable
2823
+ return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
2824
+
2825
+ source = event.source
2826
+ session_key = self._session_key_for_source(source)
2827
+ raw_args = event.get_command_args().strip()
2828
+ try:
2829
+ parts = shlex.split(raw_args)
2830
+ except ValueError as exc:
2831
+ return t("gateway.resume.parse_error", error=exc)
2832
+ allow_all = "--all" in parts
2833
+ allow_cross_room = "--cross-room" in parts
2834
+ name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip()
2835
+
2836
+ # Strip common outer brackets/quotes users may type literally from the
2837
+ # usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior.
2838
+ if len(name) >= 2 and (
2839
+ (name[0] == "<" and name[-1] == ">")
2840
+ or (name[0] == "[" and name[-1] == "]")
2841
+ or (name[0] == '"' and name[-1] == '"')
2842
+ or (name[0] == "'" and name[-1] == "'")
2843
+ ):
2844
+ name = name[1:-1].strip()
2845
+
2846
+ def _list_titled_sessions() -> list[dict]:
2847
+ user_source = source.platform.value if source.platform else None
2848
+ sessions = self._session_db.list_sessions_rich(source=user_source, limit=10)
2849
+ return [s for s in sessions if s.get("title")][:10]
2850
+
2851
+ if not name:
2852
+ # List recent titled sessions for this user/platform
2853
+ try:
2854
+ titled = _list_titled_sessions()
2855
+ if source.platform == Platform.MATRIX and not allow_all:
2856
+ scoped = []
2857
+ for s in titled:
2858
+ origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
2859
+ if self._same_matrix_room(source, origin):
2860
+ scoped.append(s)
2861
+ titled = scoped
2862
+ if not titled:
2863
+ if source.platform == Platform.MATRIX and not allow_all:
2864
+ return t("gateway.resume.matrix_no_named_sessions")
2865
+ return t("gateway.resume.no_named_sessions")
2866
+ lines = [t("gateway.resume.list_header")]
2867
+ for idx, s in enumerate(titled[:10], start=1):
2868
+ title = s["title"]
2869
+ if source.platform == Platform.MATRIX and allow_all:
2870
+ origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
2871
+ if origin:
2872
+ title = f"{title} — {origin.chat_name or origin.chat_id}"
2873
+ preview = s.get("preview", "")[:40]
2874
+ preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
2875
+ lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
2876
+ lines.append(t("gateway.resume.list_footer_numbered"))
2877
+ return "\n".join(lines)
2878
+ except Exception as e:
2879
+ logger.debug("Failed to list titled sessions: %s", e)
2880
+ return t("gateway.resume.list_failed", error=e)
2881
+
2882
+ # Resolve a numbered choice or a title to a session ID.
2883
+ if name.isdigit():
2884
+ try:
2885
+ titled = _list_titled_sessions()
2886
+ if source.platform == Platform.MATRIX and not allow_all:
2887
+ scoped = []
2888
+ for s in titled:
2889
+ origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
2890
+ if self._same_matrix_room(source, origin):
2891
+ scoped.append(s)
2892
+ titled = scoped
2893
+ except Exception as e:
2894
+ logger.debug("Failed to list titled sessions for numeric resume: %s", e)
2895
+ return t("gateway.resume.list_failed", error=e)
2896
+ index = int(name)
2897
+ if index < 1 or index > len(titled):
2898
+ return t("gateway.resume.out_of_range", index=index)
2899
+ target = titled[index - 1]
2900
+ target_id = target.get("id")
2901
+ name = target.get("title") or name
2902
+ else:
2903
+ # Try direct session ID lookup first (so `/resume <session_id>`
2904
+ # works in the gateway, not just `/resume <title>`).
2905
+ session = self._session_db.get_session(name)
2906
+ if session:
2907
+ target_id = session["id"]
2908
+ else:
2909
+ target_id = self._session_db.resolve_session_by_title(name)
2910
+ if not target_id:
2911
+ return t("gateway.resume.not_found", name=name)
2912
+ # Compression creates child continuations that hold the live transcript.
2913
+ # Follow that chain so gateway /resume matches CLI behavior (#15000).
2914
+ try:
2915
+ target_id = self._session_db.resolve_resume_session_id(target_id)
2916
+ except Exception as e:
2917
+ logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e)
2918
+
2919
+ if source.platform == Platform.MATRIX:
2920
+ target_origin = self._gateway_session_origin_for_id(target_id)
2921
+ if not self._same_matrix_room(source, target_origin) and not allow_cross_room:
2922
+ if target_origin is None:
2923
+ return t("gateway.resume.matrix_blocked_no_origin", name=name)
2924
+ return t(
2925
+ "gateway.resume.matrix_blocked_other_room",
2926
+ room=target_origin.chat_name or target_origin.chat_id,
2927
+ name=name,
2928
+ )
2929
+
2930
+ # Check if already on that session
2931
+ current_entry = self.session_store.get_or_create_session(source)
2932
+ if current_entry.session_id == target_id:
2933
+ return t("gateway.resume.already_on", name=name)
2934
+
2935
+ # Clear any running agent for this session key
2936
+ self._release_running_agent_state(session_key)
2937
+
2938
+ # Switch the session entry to point at the old session
2939
+ new_entry = self.session_store.switch_session(session_key, target_id)
2940
+ if not new_entry:
2941
+ return t("gateway.resume.switch_failed")
2942
+ self._clear_session_boundary_security_state(session_key)
2943
+
2944
+ # Evict any cached agent for this session so the next message
2945
+ # rebuilds with the correct session_id end-to-end — mirrors
2946
+ # /branch and /reset. Without this, the cached AIAgent (and its
2947
+ # memory provider, which cached `_session_id` during initialize())
2948
+ # keeps writing into the wrong session's record. See #6672.
2949
+ self._evict_cached_agent(session_key)
2950
+
2951
+ # Get the title for confirmation
2952
+ title = self._session_db.get_session_title(target_id) or name
2953
+
2954
+ # Count messages for context
2955
+ history = self.session_store.load_transcript(target_id)
2956
+ msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
2957
+ msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
2958
+
2959
+ if source.platform == Platform.MATRIX and allow_cross_room:
2960
+ return t(
2961
+ "gateway.resume.matrix_cross_room_success",
2962
+ title=title,
2963
+ room=source.chat_name or source.chat_id,
2964
+ msg_part=msg_part,
2965
+ )
2966
+ if not msg_count:
2967
+ return t("gateway.resume.resumed_no_count", title=title)
2968
+ if msg_count == 1:
2969
+ return t("gateway.resume.resumed_one", title=title, count=msg_count)
2970
+ return t("gateway.resume.resumed_many", title=title, count=msg_count)
2971
+
2972
+ async def _handle_sessions_command(self, event: MessageEvent) -> str:
2973
+ """Handle /sessions — list previous sessions for gateway chats."""
2974
+ if not self._session_db:
2975
+ from hermes_state import format_session_db_unavailable
2976
+ return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
2977
+
2978
+ from hermes_cli.session_listing import (
2979
+ format_gateway_session_listing,
2980
+ parse_session_listing_args,
2981
+ query_session_listing,
2982
+ )
2983
+
2984
+ source = event.source
2985
+ raw_args = event.get_command_args().strip()
2986
+ try:
2987
+ include_all, include_unnamed, target = parse_session_listing_args(raw_args)
2988
+ except ValueError as exc:
2989
+ return t("gateway.resume.parse_error", error=exc)
2990
+
2991
+ if target:
2992
+ resume_event = dataclasses.replace(event, text=f"/resume {target}")
2993
+ return await self._handle_resume_command(resume_event)
2994
+
2995
+ current_entry = self.session_store.get_or_create_session(source)
2996
+ rows = query_session_listing(
2997
+ self._session_db,
2998
+ source=source.platform.value if source.platform else None,
2999
+ current_session_id=current_entry.session_id,
3000
+ include_all_sources=include_all,
3001
+ include_unnamed=include_unnamed,
3002
+ limit=10,
3003
+ exclude_sources=["tool"],
3004
+ )
3005
+ if source.platform == Platform.MATRIX and not include_all:
3006
+ rows = [
3007
+ row for row in rows
3008
+ if self._same_matrix_room(
3009
+ source, self._gateway_session_origin_for_id(str(row.get("id") or ""))
3010
+ )
3011
+ ]
3012
+ return format_gateway_session_listing(
3013
+ rows,
3014
+ include_source=include_all,
3015
+ title="Sessions" if include_unnamed else "Named Sessions",
3016
+ )
3017
+
3018
+ async def _handle_branch_command(self, event: MessageEvent) -> str:
3019
+ """Handle /branch [name] — fork the current session into a new independent copy.
3020
+
3021
+ Copies conversation history to a new session so the user can explore
3022
+ a different approach without losing the original.
3023
+ Inspired by Claude Code's /branch command.
3024
+ """
3025
+ import uuid as _uuid
3026
+
3027
+ if not self._session_db:
3028
+ from hermes_state import format_session_db_unavailable
3029
+ return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
3030
+
3031
+ source = event.source
3032
+ session_key = self._session_key_for_source(source)
3033
+
3034
+ # Load the current session and its transcript
3035
+ current_entry = self.session_store.get_or_create_session(source)
3036
+ history = self.session_store.load_transcript(current_entry.session_id)
3037
+ if not history:
3038
+ return t("gateway.branch.no_conversation")
3039
+
3040
+ branch_name = event.get_command_args().strip()
3041
+
3042
+ # Generate the new session ID
3043
+ from datetime import datetime as _dt
3044
+ now = _dt.now()
3045
+ timestamp_str = now.strftime("%Y%m%d_%H%M%S")
3046
+ short_uuid = _uuid.uuid4().hex[:6]
3047
+ new_session_id = f"{timestamp_str}_{short_uuid}"
3048
+
3049
+ # Determine branch title
3050
+ if branch_name:
3051
+ branch_title = branch_name
3052
+ else:
3053
+ current_title = self._session_db.get_session_title(current_entry.session_id)
3054
+ base = current_title or "branch"
3055
+ branch_title = self._session_db.get_next_title_in_lineage(base)
3056
+
3057
+ parent_session_id = current_entry.session_id
3058
+
3059
+ # Create the new session with parent link.
3060
+ # Persist a stable ``_branched_from`` marker in model_config so
3061
+ # list_sessions_rich() keeps the branch visible in /resume and
3062
+ # /sessions even after the parent is reopened and re-ended with a
3063
+ # different end_reason (e.g. tui_shutdown overwriting 'branched').
3064
+ try:
3065
+ self._session_db.create_session(
3066
+ session_id=new_session_id,
3067
+ source=source.platform.value if source.platform else "gateway",
3068
+ model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None,
3069
+ model_config={"_branched_from": parent_session_id},
3070
+ parent_session_id=parent_session_id,
3071
+ )
3072
+ except Exception as e:
3073
+ logger.error("Failed to create branch session: %s", e)
3074
+ return t("gateway.branch.create_failed", error=e)
3075
+
3076
+ # Copy conversation history to the new session
3077
+ for msg in history:
3078
+ try:
3079
+ self._session_db.append_message(
3080
+ session_id=new_session_id,
3081
+ role=msg.get("role", "user"),
3082
+ content=msg.get("content"),
3083
+ tool_name=msg.get("tool_name") or msg.get("name"),
3084
+ tool_calls=msg.get("tool_calls"),
3085
+ tool_call_id=msg.get("tool_call_id"),
3086
+ finish_reason=msg.get("finish_reason"),
3087
+ reasoning=msg.get("reasoning"),
3088
+ reasoning_content=msg.get("reasoning_content"),
3089
+ reasoning_details=msg.get("reasoning_details"),
3090
+ codex_reasoning_items=msg.get("codex_reasoning_items"),
3091
+ codex_message_items=msg.get("codex_message_items"),
3092
+ )
3093
+ except Exception:
3094
+ pass # Best-effort copy
3095
+
3096
+ # Set title
3097
+ try:
3098
+ self._session_db.set_session_title(new_session_id, branch_title)
3099
+ except Exception:
3100
+ pass
3101
+
3102
+ # Switch the session store entry to the new session
3103
+ new_entry = self.session_store.switch_session(session_key, new_session_id)
3104
+ if not new_entry:
3105
+ return t("gateway.branch.switch_failed")
3106
+ self._clear_session_boundary_security_state(session_key)
3107
+
3108
+ # Evict any cached agent for this session
3109
+ self._evict_cached_agent(session_key)
3110
+
3111
+ msg_count = len([m for m in history if m.get("role") == "user"])
3112
+ key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
3113
+ return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
3114
+
3115
+ async def _handle_credits_command(self, event: MessageEvent) -> str:
3116
+ """Handle /credits -- show Nous credit balance and the top-up handoff.
3117
+
3118
+ Renders the balance block + identity line + a tappable top-up URL that
3119
+ opens the portal billing page with the modal open. The terminal does NOT
3120
+ confirm, poll, or track payment (billing phase 2a) — checkout happens in
3121
+ the browser and the next /credits shows the new balance. The tappable URL
3122
+ is the affordance: it works on every platform (button-capable or plain
3123
+ text like SMS/email). Fetched off the event loop; fail-open.
3124
+ """
3125
+ from agent.account_usage import build_credits_view
3126
+
3127
+ try:
3128
+ view = await asyncio.to_thread(build_credits_view, markdown=True)
3129
+ except Exception:
3130
+ view = None
3131
+
3132
+ if view is None or not view.logged_in:
3133
+ return t("gateway.credits.not_logged_in")
3134
+
3135
+ lines: list[str] = ["💳 **Nous credits**"]
3136
+ for line in view.balance_lines:
3137
+ if line.lstrip().startswith("📈"):
3138
+ continue # drop the helper's header; we print our own
3139
+ lines.append(line)
3140
+ if view.identity_line:
3141
+ lines.append("")
3142
+ lines.append(view.identity_line)
3143
+ if view.topup_url:
3144
+ lines.append("")
3145
+ lines.append(f"Top up: {view.topup_url}")
3146
+ lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
3147
+ return "\n".join(lines)
3148
+
3149
+ async def _handle_usage_command(self, event: MessageEvent) -> str:
3150
+ """Handle /usage command -- show token usage for the current session.
3151
+
3152
+ Checks both _running_agents (mid-turn) and _agent_cache (between turns)
3153
+ so that rate limits, cost estimates, and detailed token breakdowns are
3154
+ available whenever the user asks, not only while the agent is running.
3155
+ """
3156
+ from gateway.run import _AGENT_PENDING_SENTINEL
3157
+ source = event.source
3158
+ session_key = self._session_key_for_source(source)
3159
+
3160
+ # Try running agent first (mid-turn), then cached agent (between turns)
3161
+ agent = self._running_agents.get(session_key)
3162
+ if not agent or agent is _AGENT_PENDING_SENTINEL:
3163
+ _cache_lock = getattr(self, "_agent_cache_lock", None)
3164
+ _cache = getattr(self, "_agent_cache", None)
3165
+ if _cache_lock and _cache is not None:
3166
+ with _cache_lock:
3167
+ cached = _cache.get(session_key)
3168
+ if cached:
3169
+ agent = cached[0]
3170
+
3171
+ # Resolve provider/base_url/api_key for the account-usage fetch.
3172
+ # Prefer the live agent; fall back to persisted billing data on the
3173
+ # SessionDB row so `/usage` still returns account info between turns
3174
+ # when no agent is resident.
3175
+ provider = getattr(agent, "provider", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None
3176
+ base_url = getattr(agent, "base_url", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None
3177
+ api_key = getattr(agent, "api_key", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None
3178
+ if not provider and getattr(self, "_session_db", None) is not None:
3179
+ try:
3180
+ _entry_for_billing = self.session_store.get_or_create_session(source)
3181
+ persisted = self._session_db.get_session(_entry_for_billing.session_id) or {}
3182
+ except Exception:
3183
+ persisted = {}
3184
+ provider = provider or persisted.get("billing_provider")
3185
+ base_url = base_url or persisted.get("billing_base_url")
3186
+
3187
+ # Fetch account usage off the event loop so slow provider APIs don't
3188
+ # block the gateway. Failures are non-fatal -- account_lines stays [].
3189
+ account_lines: list[str] = []
3190
+ credits_lines: list[str] = []
3191
+ if provider:
3192
+ try:
3193
+ account_snapshot = await asyncio.to_thread(
3194
+ fetch_account_usage,
3195
+ provider,
3196
+ base_url=base_url,
3197
+ api_key=api_key,
3198
+ )
3199
+ except Exception:
3200
+ account_snapshot = None
3201
+ if account_snapshot:
3202
+ account_lines = render_account_usage_lines(account_snapshot, markdown=True)
3203
+
3204
+ # ── Nous credits magnitudes + monthly-grant % gauge ─────────────
3205
+ # Shared with the CLI / TUI /usage block via nous_credits_lines(): a single
3206
+ # auth-gate + portal-fetch + render path (which also honors the dev fixture).
3207
+ # Run off the event loop. The helper gates on "a Nous account is logged in"
3208
+ # — NOT the inference provider and NOT nested under `if provider:` — so a
3209
+ # Nous-credentialled user running inference elsewhere (or with none resident)
3210
+ # still sees their balance. NO recovery trigger: messaging binds no notice
3211
+ # consumer, so /usage only displays. Fail-open: never break /usage.
3212
+ try:
3213
+ from agent.account_usage import nous_credits_lines
3214
+
3215
+ credits_lines = await asyncio.to_thread(nous_credits_lines, markdown=True)
3216
+ except Exception:
3217
+ credits_lines = [] # fail-open: never break /usage
3218
+
3219
+ if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0:
3220
+ lines = []
3221
+
3222
+ # Rate limits (when available from provider headers)
3223
+ rl_state = agent.get_rate_limit_state()
3224
+ if rl_state and rl_state.has_data:
3225
+ from agent.rate_limit_tracker import format_rate_limit_compact
3226
+ lines.append(t("gateway.usage.rate_limits", state=format_rate_limit_compact(rl_state)))
3227
+ lines.append("")
3228
+
3229
+ # Session token usage — detailed breakdown matching CLI
3230
+ input_tokens = getattr(agent, "session_input_tokens", 0) or 0
3231
+ output_tokens = getattr(agent, "session_output_tokens", 0) or 0
3232
+ cache_read = getattr(agent, "session_cache_read_tokens", 0) or 0
3233
+ cache_write = getattr(agent, "session_cache_write_tokens", 0) or 0
3234
+
3235
+ lines.append(t("gateway.usage.header_session"))
3236
+ lines.append(t("gateway.usage.label_model", model=agent.model))
3237
+ lines.append(t("gateway.usage.label_input_tokens", count=f"{input_tokens:,}"))
3238
+ if cache_read:
3239
+ lines.append(t("gateway.usage.label_cache_read", count=f"{cache_read:,}"))
3240
+ if cache_write:
3241
+ lines.append(t("gateway.usage.label_cache_write", count=f"{cache_write:,}"))
3242
+ lines.append(t("gateway.usage.label_output_tokens", count=f"{output_tokens:,}"))
3243
+ lines.append(t("gateway.usage.label_total", count=f"{agent.session_total_tokens:,}"))
3244
+ lines.append(t("gateway.usage.label_api_calls", count=agent.session_api_calls))
3245
+
3246
+ # Cost estimation
3247
+ try:
3248
+ from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
3249
+ cost_result = estimate_usage_cost(
3250
+ agent.model,
3251
+ CanonicalUsage(
3252
+ input_tokens=input_tokens,
3253
+ output_tokens=output_tokens,
3254
+ cache_read_tokens=cache_read,
3255
+ cache_write_tokens=cache_write,
3256
+ ),
3257
+ provider=getattr(agent, "provider", None),
3258
+ base_url=getattr(agent, "base_url", None),
3259
+ )
3260
+ if cost_result.amount_usd is not None:
3261
+ prefix = "~" if cost_result.status == "estimated" else ""
3262
+ lines.append(t("gateway.usage.label_cost", prefix=prefix, amount=f"{float(cost_result.amount_usd):.4f}"))
3263
+ elif cost_result.status == "included":
3264
+ lines.append(t("gateway.usage.label_cost_included"))
3265
+ except Exception:
3266
+ pass
3267
+
3268
+ # Context window and compressions
3269
+ ctx = agent.context_compressor
3270
+ if ctx.last_prompt_tokens:
3271
+ pct = min(100, ctx.last_prompt_tokens / ctx.context_length * 100) if ctx.context_length else 0
3272
+ lines.append(t("gateway.usage.label_context", used=f"{ctx.last_prompt_tokens:,}", total=f"{ctx.context_length:,}", pct=f"{pct:.0f}"))
3273
+ if ctx.compression_count:
3274
+ lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count))
3275
+
3276
+ if account_lines:
3277
+ lines.append("")
3278
+ lines.extend(account_lines)
3279
+ if credits_lines:
3280
+ lines.append("")
3281
+ lines.extend(credits_lines)
3282
+
3283
+ return "\n".join(lines)
3284
+
3285
+ # No agent at all -- check session history for a rough count
3286
+ session_entry = self.session_store.get_or_create_session(source)
3287
+ history = self.session_store.load_transcript(session_entry.session_id)
3288
+ if history:
3289
+ from agent.model_metadata import estimate_messages_tokens_rough
3290
+ msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")]
3291
+ approx = estimate_messages_tokens_rough(msgs)
3292
+ lines = [
3293
+ t("gateway.usage.header_session_info"),
3294
+ t("gateway.usage.label_messages", count=len(msgs)),
3295
+ t("gateway.usage.label_estimated_context", count=f"{approx:,}"),
3296
+ t("gateway.usage.detailed_after_first"),
3297
+ ]
3298
+ if account_lines:
3299
+ lines.append("")
3300
+ lines.extend(account_lines)
3301
+ if credits_lines:
3302
+ lines.append("")
3303
+ lines.extend(credits_lines)
3304
+ return "\n".join(lines)
3305
+ if account_lines or credits_lines:
3306
+ # account-only, credits-only, or both — joined with a blank divider.
3307
+ parts = list(account_lines)
3308
+ if credits_lines:
3309
+ if parts:
3310
+ parts.append("")
3311
+ parts.extend(credits_lines)
3312
+ return "\n".join(parts)
3313
+ return t("gateway.usage.no_data")
3314
+
3315
+ async def _handle_insights_command(self, event: MessageEvent) -> str:
3316
+ """Handle /insights command -- show usage insights and analytics."""
3317
+ args = event.get_command_args().strip()
3318
+
3319
+ # Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash)
3320
+ args = re.sub(r'[\u2012\u2013\u2014\u2015](days|source)', r'--\1', args)
3321
+
3322
+ days = 30
3323
+ source = None
3324
+
3325
+ # Parse simple args: /insights 7 or /insights --days 7
3326
+ if args:
3327
+ parts = args.split()
3328
+ i = 0
3329
+ while i < len(parts):
3330
+ if parts[i] == "--days" and i + 1 < len(parts):
3331
+ try:
3332
+ days = int(parts[i + 1])
3333
+ except ValueError:
3334
+ return t("gateway.insights.invalid_days", value=parts[i + 1])
3335
+ i += 2
3336
+ elif parts[i] == "--source" and i + 1 < len(parts):
3337
+ source = parts[i + 1]
3338
+ i += 2
3339
+ elif parts[i].isdigit():
3340
+ days = int(parts[i])
3341
+ i += 1
3342
+ else:
3343
+ i += 1
3344
+
3345
+ try:
3346
+ from hermes_state import SessionDB
3347
+ from agent.insights import InsightsEngine
3348
+
3349
+ loop = asyncio.get_running_loop()
3350
+
3351
+ def _run_insights():
3352
+ db = SessionDB()
3353
+ engine = InsightsEngine(db)
3354
+ report = engine.generate(days=days, source=source)
3355
+ result = engine.format_gateway(report)
3356
+ db.close()
3357
+ return result
3358
+
3359
+ return await loop.run_in_executor(None, _run_insights)
3360
+ except Exception as e:
3361
+ logger.error("Insights command error: %s", e, exc_info=True)
3362
+ return t("gateway.insights.error", error=e)
3363
+
3364
+ async def _handle_reload_mcp_command(self, event: MessageEvent) -> Optional[str]:
3365
+ """Handle /reload-mcp — reconnect MCP servers and rebuild the cached agent.
3366
+
3367
+ Reloading MCP tools invalidates the provider prompt cache for the
3368
+ active session (tool schemas are baked into the system prompt). The
3369
+ next message re-sends full input tokens, which is expensive on
3370
+ long-context or high-reasoning models.
3371
+
3372
+ To surface that cost, the command routes through the slash-confirm
3373
+ primitive: users get an Approve Once / Always Approve / Cancel
3374
+ prompt before the reload actually runs. "Always Approve" persists
3375
+ ``approvals.mcp_reload_confirm: false`` so the prompt is silenced
3376
+ for subsequent reloads in any session.
3377
+
3378
+ Users can also skip the confirm by flipping the config key directly.
3379
+ """
3380
+ source = event.source
3381
+ session_key = self._session_key_for_source(source)
3382
+
3383
+ # Read the gate fresh from disk so a prior "always" click takes
3384
+ # effect on the next invocation without restarting the gateway.
3385
+ user_config = self._read_user_config()
3386
+ approvals = user_config.get("approvals") if isinstance(user_config, dict) else None
3387
+ confirm_required = True
3388
+ if isinstance(approvals, dict):
3389
+ confirm_required = bool(approvals.get("mcp_reload_confirm", True))
3390
+
3391
+ if not confirm_required:
3392
+ return await self._execute_mcp_reload(event)
3393
+
3394
+ # Route through slash-confirm. The primitive sends the prompt and
3395
+ # stores the resume handler; the button/text response triggers
3396
+ # ``_resolve_slash_confirm`` which invokes the handler with the
3397
+ # chosen outcome.
3398
+ async def _on_confirm(choice: str) -> Optional[str]:
3399
+ if choice == "cancel":
3400
+ return t("gateway.reload_mcp.cancelled")
3401
+ if choice == "always":
3402
+ # Persist the opt-out and run the reload.
3403
+ try:
3404
+ from cli import save_config_value
3405
+ save_config_value("approvals.mcp_reload_confirm", False)
3406
+ logger.info(
3407
+ "User opted out of /reload-mcp confirmation (session=%s)",
3408
+ session_key,
3409
+ )
3410
+ except Exception as exc:
3411
+ logger.warning("Failed to persist mcp_reload_confirm=false: %s", exc)
3412
+ # once / always → run the reload
3413
+ result = await self._execute_mcp_reload(event)
3414
+ if choice == "always":
3415
+ return f"{result}\n\n" + t("gateway.reload_mcp.always_followup")
3416
+ return result
3417
+
3418
+ prompt_message = t("gateway.reload_mcp.confirm_prompt")
3419
+ return await self._request_slash_confirm(
3420
+ event=event,
3421
+ command="reload-mcp",
3422
+ title="/reload-mcp",
3423
+ message=prompt_message,
3424
+ handler=_on_confirm,
3425
+ )
3426
+
3427
+ async def _handle_reload_skills_command(self, event: MessageEvent) -> str:
3428
+ """Handle /reload-skills — rescan skills dir, queue a note for next turn.
3429
+
3430
+ Skills don't need to be in the system prompt for the model to use
3431
+ them (they're invoked via ``/skill-name``, ``skills_list``, or
3432
+ ``skill_view`` at runtime), so this does NOT clear the prompt cache
3433
+ — prefix caching stays intact.
3434
+
3435
+ If any skills were added or removed, a one-shot note is queued on
3436
+ ``self._pending_skills_reload_notes[session_key]``. The gateway
3437
+ prepends it to the NEXT user message in this session (see the
3438
+ consumer at ~L11025 in ``_run_agent_turn``), then clears it. Nothing
3439
+ is written to the session transcript out-of-band, so message
3440
+ alternation is preserved.
3441
+ """
3442
+ loop = asyncio.get_running_loop()
3443
+ try:
3444
+ from agent.skill_commands import reload_skills
3445
+
3446
+ result = await loop.run_in_executor(None, reload_skills)
3447
+ added = result.get("added", []) # [{"name", "description"}, ...]
3448
+ removed = result.get("removed", []) # [{"name", "description"}, ...]
3449
+ total = result.get("total", 0)
3450
+
3451
+ # Let each connected adapter refresh any platform-side state
3452
+ # that cached the skill list at startup. Today that's the
3453
+ # Discord /skill autocomplete (registered once per connect);
3454
+ # without this call, new skills stay invisible in the
3455
+ # dropdown and deleted skills error out when clicked. Other
3456
+ # adapters that don't override refresh_skill_group (Telegram's
3457
+ # BotCommand menu, Slack subcommand map, etc.) are silently
3458
+ # skipped — the in-process reload above is enough for them.
3459
+ for adapter in list(self.adapters.values()):
3460
+ refresh = getattr(adapter, "refresh_skill_group", None)
3461
+ if not callable(refresh):
3462
+ continue
3463
+ try:
3464
+ maybe = refresh()
3465
+ if inspect.isawaitable(maybe):
3466
+ await maybe
3467
+ except Exception as exc:
3468
+ logger.warning(
3469
+ "Adapter %s refresh_skill_group raised: %s",
3470
+ getattr(adapter, "name", adapter), exc,
3471
+ )
3472
+
3473
+ lines = [t("gateway.reload_skills.header")]
3474
+ if not added and not removed:
3475
+ lines.append(t("gateway.reload_skills.no_new"))
3476
+ lines.append(t("gateway.reload_skills.total", count=total))
3477
+ return "\n".join(lines)
3478
+
3479
+ def _fmt_line(item: dict) -> str:
3480
+ nm = item.get("name", "")
3481
+ desc = item.get("description", "")
3482
+ if desc:
3483
+ return t("gateway.reload_skills.item_with_desc", name=nm, desc=desc)
3484
+ return t("gateway.reload_skills.item_no_desc", name=nm)
3485
+
3486
+ if added:
3487
+ lines.append(t("gateway.reload_skills.added_header"))
3488
+ for item in added:
3489
+ lines.append(_fmt_line(item))
3490
+ if removed:
3491
+ lines.append(t("gateway.reload_skills.removed_header"))
3492
+ for item in removed:
3493
+ lines.append(_fmt_line(item))
3494
+ lines.append(t("gateway.reload_skills.total", count=total))
3495
+
3496
+ # Queue the one-shot note for the next user turn in this session.
3497
+ # Format matches how the system prompt renders pre-existing
3498
+ # skills (`` - name: description``) so the model reads the
3499
+ # diff in the same shape as its original skill catalog.
3500
+ sections = ["[USER INITIATED SKILLS RELOAD:"]
3501
+ if added:
3502
+ sections.append("")
3503
+ sections.append("Added Skills:")
3504
+ for item in added:
3505
+ sections.append(_fmt_line(item))
3506
+ if removed:
3507
+ sections.append("")
3508
+ sections.append("Removed Skills:")
3509
+ for item in removed:
3510
+ sections.append(_fmt_line(item))
3511
+ sections.append("")
3512
+ sections.append("Use skills_list to see the updated catalog.]")
3513
+ note = "\n".join(sections)
3514
+
3515
+ session_key = self._session_key_for_source(event.source)
3516
+ if not hasattr(self, "_pending_skills_reload_notes"):
3517
+ self._pending_skills_reload_notes = {}
3518
+ if session_key:
3519
+ self._pending_skills_reload_notes[session_key] = note
3520
+
3521
+ return "\n".join(lines)
3522
+
3523
+ except Exception as e:
3524
+ logger.warning("Skills reload failed: %s", e)
3525
+ return t("gateway.reload_skills.failed", error=e)
3526
+
3527
+ async def _handle_bundles_command(self, event: MessageEvent) -> str:
3528
+ """Handle /bundles — list installed skill bundles.
3529
+
3530
+ Mirrors the CLI ``/bundles`` handler. Returns a single text
3531
+ message suitable for any gateway adapter; bundles are loaded by
3532
+ invoking the bundle's own ``/<slug>`` command, not by this one.
3533
+ """
3534
+ try:
3535
+ from agent.skill_bundles import list_bundles, _bundles_dir
3536
+ except Exception as exc:
3537
+ logger.warning("Bundles command unavailable: %s", exc)
3538
+ return f"Bundles subsystem unavailable: {exc}"
3539
+
3540
+ bundles = list_bundles()
3541
+ if not bundles:
3542
+ return (
3543
+ "No skill bundles installed.\n"
3544
+ "Create one on the host with:\n"
3545
+ " `hermes bundles create <name> --skill <s1> --skill <s2>`\n"
3546
+ f"Directory: `{_bundles_dir()}`"
3547
+ )
3548
+
3549
+ lines = [f"**Skill Bundles** ({len(bundles)} installed):", ""]
3550
+ for info in bundles:
3551
+ skill_count = len(info.get("skills", []))
3552
+ desc = info.get("description") or f"Load {skill_count} skills"
3553
+ lines.append(
3554
+ f"• `/{info['slug']}` — {desc} _({skill_count} skills)_"
3555
+ )
3556
+ for s in info.get("skills", []):
3557
+ lines.append(f" · {s}")
3558
+ lines.append("")
3559
+ lines.append("Invoke a bundle with `/<slug>` to load all its skills.")
3560
+ return "\n".join(lines)
3561
+
3562
+ async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
3563
+ """Handle /approve command — unblock waiting agent thread(s).
3564
+
3565
+ The agent thread(s) are blocked inside tools/approval.py waiting for
3566
+ the user to respond. This handler signals the event so the agent
3567
+ resumes and the terminal_tool executes the command inline — the same
3568
+ flow as the CLI's synchronous input() approval.
3569
+
3570
+ Supports multiple concurrent approvals (parallel subagents,
3571
+ execute_code). ``/approve`` resolves the oldest pending command;
3572
+ ``/approve all`` resolves every pending command at once.
3573
+
3574
+ Usage:
3575
+ /approve — approve oldest pending command once
3576
+ /approve all — approve ALL pending commands at once
3577
+ /approve session — approve oldest + remember for session
3578
+ /approve all session — approve all + remember for session
3579
+ /approve always — approve oldest + remember permanently
3580
+ /approve all always — approve all + remember permanently
3581
+ """
3582
+ source = event.source
3583
+ session_key = self._session_key_for_source(source)
3584
+
3585
+ from tools.approval import (
3586
+ resolve_gateway_approval, has_blocking_approval,
3587
+ )
3588
+
3589
+ if not has_blocking_approval(session_key):
3590
+ if session_key in self._pending_approvals:
3591
+ self._pending_approvals.pop(session_key)
3592
+ return t("gateway.approval_expired")
3593
+ return t("gateway.approve.no_pending")
3594
+
3595
+ # Parse args: support "all", "all session", "all always", "session", "always"
3596
+ args = event.get_command_args().strip().lower().split()
3597
+ resolve_all = "all" in args
3598
+ remaining = [a for a in args if a != "all"]
3599
+
3600
+ if any(a in {"always", "permanent", "permanently"} for a in remaining):
3601
+ choice = "always"
3602
+ elif any(a in {"session", "ses"} for a in remaining):
3603
+ choice = "session"
3604
+ else:
3605
+ choice = "once"
3606
+
3607
+ count = resolve_gateway_approval(session_key, choice, resolve_all=resolve_all)
3608
+ if not count:
3609
+ return t("gateway.approve.no_pending")
3610
+
3611
+ # Resume typing indicator — agent is about to continue processing.
3612
+ _adapter = self.adapters.get(source.platform)
3613
+ if _adapter:
3614
+ _adapter.resume_typing_for_chat(source.chat_id)
3615
+
3616
+ logger.info("User approved %d dangerous command(s) via /approve (%s)", count, choice)
3617
+ plural = "plural" if count > 1 else "singular"
3618
+ return t(f"gateway.approve.{choice}_{plural}", count=count)
3619
+
3620
+ async def _handle_deny_command(self, event: MessageEvent) -> str:
3621
+ """Handle /deny command — reject pending dangerous command(s).
3622
+
3623
+ Signals blocked agent thread(s) with a 'deny' result so they receive
3624
+ a definitive BLOCKED message, same as the CLI deny flow.
3625
+
3626
+ ``/deny`` denies the oldest; ``/deny all`` denies everything.
3627
+ """
3628
+ source = event.source
3629
+ session_key = self._session_key_for_source(source)
3630
+
3631
+ from tools.approval import (
3632
+ resolve_gateway_approval, has_blocking_approval,
3633
+ )
3634
+
3635
+ if not has_blocking_approval(session_key):
3636
+ if session_key in self._pending_approvals:
3637
+ self._pending_approvals.pop(session_key)
3638
+ return t("gateway.deny.stale")
3639
+ return t("gateway.deny.no_pending")
3640
+
3641
+ args = event.get_command_args().strip().lower()
3642
+ resolve_all = "all" in args
3643
+
3644
+ count = resolve_gateway_approval(session_key, "deny", resolve_all=resolve_all)
3645
+ if not count:
3646
+ return t("gateway.deny.no_pending")
3647
+
3648
+ # Resume typing indicator — agent continues (with BLOCKED result).
3649
+ _adapter = self.adapters.get(source.platform)
3650
+ if _adapter:
3651
+ _adapter.resume_typing_for_chat(source.chat_id)
3652
+
3653
+ logger.info("User denied %d dangerous command(s) via /deny", count)
3654
+ if count > 1:
3655
+ return t("gateway.deny.denied_plural", count=count)
3656
+ return t("gateway.deny.denied_singular")
3657
+
3658
+ async def _handle_debug_command(self, event: MessageEvent) -> str:
3659
+ """Handle /debug — upload debug report (summary only) and return paste URLs.
3660
+
3661
+ Gateway uploads ONLY the summary report (system info + log tails),
3662
+ NOT full log files, to protect conversation privacy. Users who need
3663
+ full log uploads should use ``hermes debug share`` from the CLI.
3664
+ """
3665
+ import asyncio
3666
+ from hermes_cli.debug import (
3667
+ _capture_dump, collect_debug_report,
3668
+ upload_to_pastebin, _schedule_auto_delete,
3669
+ _GATEWAY_PRIVACY_NOTICE, _best_effort_sweep_expired_pastes,
3670
+ )
3671
+
3672
+ loop = asyncio.get_running_loop()
3673
+
3674
+ # Run blocking I/O (dump capture, log reads, uploads) in a thread.
3675
+ def _collect_and_upload():
3676
+ _best_effort_sweep_expired_pastes()
3677
+ dump_text = _capture_dump()
3678
+ report = collect_debug_report(log_lines=200, dump_text=dump_text)
3679
+
3680
+ urls = {}
3681
+ try:
3682
+ urls["Report"] = upload_to_pastebin(report)
3683
+ except Exception as exc:
3684
+ return t("gateway.debug.upload_failed", error=exc)
3685
+
3686
+ # Schedule auto-deletion after 6 hours
3687
+ _schedule_auto_delete(list(urls.values()))
3688
+
3689
+ lines = [_GATEWAY_PRIVACY_NOTICE, "", t("gateway.debug.header"), ""]
3690
+ label_width = max(len(k) for k in urls)
3691
+ for label, url in urls.items():
3692
+ lines.append(f"`{label:<{label_width}}` {url}")
3693
+
3694
+ lines.append("")
3695
+ lines.append(t("gateway.debug.auto_delete"))
3696
+ lines.append(t("gateway.debug.full_logs_hint"))
3697
+ lines.append(t("gateway.debug.share_hint"))
3698
+ return "\n".join(lines)
3699
+
3700
+ return await loop.run_in_executor(None, _collect_and_upload)
3701
+
3702
+ async def _handle_update_command(self, event: MessageEvent) -> str:
3703
+ """Handle /update command — update Hermes Agent to the latest version.
3704
+
3705
+ Spawns ``hermes update`` in a detached session (via ``setsid``) so it
3706
+ survives the gateway restart that ``hermes update`` may trigger. Marker
3707
+ files are written so either the current gateway process or the next one
3708
+ can notify the user when the update finishes.
3709
+ """
3710
+ from gateway.run import _hermes_home, _resolve_hermes_bin
3711
+ import json
3712
+ import shutil
3713
+ import subprocess
3714
+ from datetime import datetime
3715
+ from hermes_cli.config import is_managed, format_managed_message
3716
+
3717
+ # Block non-messaging platforms (API server, webhooks, ACP)
3718
+ platform = event.source.platform
3719
+ _allowed = self._UPDATE_ALLOWED_PLATFORMS
3720
+ # Plugin platforms with allow_update_command=True are also allowed
3721
+ if platform not in _allowed:
3722
+ try:
3723
+ from gateway.platform_registry import platform_registry
3724
+ entry = platform_registry.get(platform.value)
3725
+ if not entry or not entry.allow_update_command:
3726
+ return t("gateway.update.platform_not_messaging")
3727
+ except Exception:
3728
+ return t("gateway.update.platform_not_messaging")
3729
+
3730
+ if is_managed():
3731
+ return f"✗ {format_managed_message('update Hermes Agent')}"
3732
+
3733
+ project_root = Path(__file__).parent.parent.resolve()
3734
+ git_dir = project_root / '.git'
3735
+
3736
+ if not git_dir.exists():
3737
+ return t("gateway.update.not_git_repo")
3738
+
3739
+ hermes_cmd = _resolve_hermes_bin()
3740
+ if not hermes_cmd:
3741
+ return t("gateway.update.hermes_cmd_not_found")
3742
+
3743
+ pending_path = _hermes_home / ".update_pending.json"
3744
+ output_path = _hermes_home / ".update_output.txt"
3745
+ exit_code_path = _hermes_home / ".update_exit_code"
3746
+ session_key = self._session_key_for_source(event.source)
3747
+ pending = {
3748
+ "platform": event.source.platform.value,
3749
+ "chat_id": event.source.chat_id,
3750
+ "chat_type": event.source.chat_type,
3751
+ "user_id": event.source.user_id,
3752
+ "session_key": session_key,
3753
+ "timestamp": datetime.now().isoformat(),
3754
+ }
3755
+ if event.source.thread_id:
3756
+ pending["thread_id"] = event.source.thread_id
3757
+ if event.message_id:
3758
+ pending["message_id"] = event.message_id
3759
+ _tmp_pending = pending_path.with_suffix(".tmp")
3760
+ _tmp_pending.write_text(json.dumps(pending))
3761
+ _tmp_pending.replace(pending_path)
3762
+ exit_code_path.unlink(missing_ok=True)
3763
+
3764
+ # Spawn `hermes update --gateway` detached so it survives gateway restart.
3765
+ # --gateway enables file-based IPC for interactive prompts (stash
3766
+ # restore, config migration) so the gateway can forward them to the
3767
+ # user instead of silently skipping them.
3768
+ # Use setsid for portable session detach (works under system services
3769
+ # where systemd-run --user fails due to missing D-Bus session).
3770
+ # PYTHONUNBUFFERED ensures output is flushed line-by-line so the
3771
+ # gateway can stream it to the messenger in near-real-time.
3772
+ # Spawn `hermes update --gateway` detached so it survives gateway restart.
3773
+ # --gateway enables file-based IPC for interactive prompts (stash
3774
+ # restore, config migration) so the gateway can forward them to the
3775
+ # user instead of silently skipping them.
3776
+ # Use setsid for portable session detach (works under system services
3777
+ # where systemd-run --user fails due to missing D-Bus session).
3778
+ # PYTHONUNBUFFERED ensures output is flushed line-by-line so the
3779
+ # gateway can stream it to the messenger in near-real-time.
3780
+ #
3781
+ # Windows: no bash/setsid chain. Run `hermes update --gateway`
3782
+ # directly via sys.executable; redirect stdout/stderr to the same
3783
+ # output files via Popen file handles; write the exit code in a
3784
+ # follow-up write. A tiny Python watcher would be cleaner but
3785
+ # we're already inside gateway/run.py's update path which is async,
3786
+ # so the simplest correct thing is: launch an inline Python helper
3787
+ # that runs the command and writes both outputs.
3788
+ try:
3789
+ if sys.platform == "win32":
3790
+ import textwrap
3791
+ from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
3792
+
3793
+ # hermes_cmd is a list of argv parts we can pass directly
3794
+ # (no shell-quoting needed).
3795
+ helper = textwrap.dedent(
3796
+ """
3797
+ import os, subprocess, sys
3798
+ output_path = sys.argv[1]
3799
+ exit_code_path = sys.argv[2]
3800
+ cmd = sys.argv[3:]
3801
+ env = dict(os.environ)
3802
+ env["PYTHONUNBUFFERED"] = "1"
3803
+ with open(output_path, "wb") as f:
3804
+ proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT, env=env)
3805
+ rc = proc.wait(timeout=3600)
3806
+ with open(exit_code_path, "w") as f:
3807
+ f.write(str(rc))
3808
+ """
3809
+ ).strip()
3810
+ subprocess.Popen(
3811
+ [
3812
+ sys.executable, "-c", helper,
3813
+ str(output_path), str(exit_code_path),
3814
+ *hermes_cmd, "update", "--gateway",
3815
+ ],
3816
+ stdout=subprocess.DEVNULL,
3817
+ stderr=subprocess.DEVNULL,
3818
+ **windows_detach_popen_kwargs(),
3819
+ )
3820
+ else:
3821
+ hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd)
3822
+ update_cmd = (
3823
+ f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway"
3824
+ f" > {shlex.quote(str(output_path))} 2>&1; "
3825
+ # Avoid `status=$?`: `status` is a read-only special parameter
3826
+ # in zsh, and this command string is copied/reused in macOS/zsh
3827
+ # operator wrappers. Keep the template zsh-safe even though this
3828
+ # specific subprocess currently runs under bash.
3829
+ f"rc=$?; printf '%s' \"$rc\" > {shlex.quote(str(exit_code_path))}"
3830
+ )
3831
+ setsid_bin = shutil.which("setsid")
3832
+ if setsid_bin:
3833
+ # Preferred: setsid creates a new session, fully detached
3834
+ subprocess.Popen(
3835
+ [setsid_bin, "bash", "-c", update_cmd],
3836
+ stdout=subprocess.DEVNULL,
3837
+ stderr=subprocess.DEVNULL,
3838
+ start_new_session=True,
3839
+ )
3840
+ else:
3841
+ # Fallback: start_new_session=True calls os.setsid() in child
3842
+ subprocess.Popen(
3843
+ ["bash", "-c", update_cmd],
3844
+ stdout=subprocess.DEVNULL,
3845
+ stderr=subprocess.DEVNULL,
3846
+ start_new_session=True,
3847
+ )
3848
+ except Exception as e:
3849
+ pending_path.unlink(missing_ok=True)
3850
+ exit_code_path.unlink(missing_ok=True)
3851
+ return t("gateway.update.start_failed", error=e)
3852
+
3853
+ self._schedule_update_notification_watch()
3854
+ return t("gateway.update.starting")