@bitseek/hermes-webui 0.1.0-beta.0

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 (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,435 @@
1
+ """Default-off Hermes Gateway bridge for browser-originated chat turns."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import threading
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any
12
+
13
+ from api.config import (
14
+ CANCEL_FLAGS,
15
+ STREAMS,
16
+ STREAMS_LOCK,
17
+ STREAM_LAST_EVENT_ID,
18
+ STREAM_LIVE_TOOL_CALLS,
19
+ STREAM_PARTIAL_TEXT,
20
+ STREAM_REASONING_TEXT,
21
+ _get_session_agent_lock,
22
+ register_active_run,
23
+ unregister_active_run,
24
+ update_active_run,
25
+ )
26
+ from api.helpers import _redact_text, redact_session_data
27
+ from api.models import get_session
28
+ from api.run_journal import RunJournalWriter
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND"
33
+ _WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
34
+ _WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY"
35
+ _GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
36
+
37
+
38
+ def webui_chat_backend_mode(config_data=None, environ: dict[str, str] | None = None) -> str:
39
+ """Return the explicitly selected browser chat backend.
40
+
41
+ The default remains the in-process WebUI runtime. Only explicit gateway
42
+ values opt browser chat into the Hermes API server bridge; generic truthy
43
+ strings are deliberately ignored so deployments do not change execution
44
+ ownership by accident.
45
+ """
46
+ source = os.environ if environ is None else environ
47
+ cfg = config_data if isinstance(config_data, dict) else {}
48
+ raw = str(
49
+ source.get(_WEBUI_CHAT_BACKEND_ENV)
50
+ or cfg.get("webui_chat_backend")
51
+ or ""
52
+ ).strip().lower()
53
+ if raw in _GATEWAY_CHAT_BACKENDS:
54
+ return "gateway"
55
+ return "legacy"
56
+
57
+
58
+ def webui_gateway_chat_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
59
+ return webui_chat_backend_mode(config_data, environ) == "gateway"
60
+
61
+
62
+ def _gateway_base_url(config_data=None, environ: dict[str, str] | None = None) -> str:
63
+ source = os.environ if environ is None else environ
64
+ cfg = config_data if isinstance(config_data, dict) else {}
65
+ raw = str(
66
+ source.get(_WEBUI_GATEWAY_BASE_URL_ENV)
67
+ or cfg.get("webui_gateway_base_url")
68
+ or "http://127.0.0.1:8642"
69
+ ).strip()
70
+ return raw.rstrip("/") or "http://127.0.0.1:8642"
71
+
72
+
73
+ def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
74
+ source = os.environ if environ is None else environ
75
+ return str(
76
+ source.get(_WEBUI_GATEWAY_API_KEY_ENV)
77
+ or source.get("API_SERVER_KEY")
78
+ or ""
79
+ ).strip()
80
+
81
+
82
+ def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
83
+ """Return redacted Gateway-backed chat configuration status."""
84
+ mode = webui_chat_backend_mode(config_data, environ)
85
+ base_url = _gateway_base_url(config_data, environ)
86
+ return {
87
+ "enabled": mode == "gateway",
88
+ "backend": mode,
89
+ "base_url_configured": bool(base_url),
90
+ "api_key_configured": bool(_gateway_api_key(environ)),
91
+ }
92
+
93
+
94
+ def _gateway_http_error_event(exc: urllib.error.HTTPError, err_body: str, *, api_key_configured: bool) -> dict:
95
+ safe = _redact_text(err_body or str(exc))[:500]
96
+ if exc.code == 401:
97
+ return {
98
+ "label": "Gateway authentication failed",
99
+ "type": "gateway_auth_error",
100
+ "message": "Gateway rejected the WebUI API key (HTTP 401).",
101
+ "hint": (
102
+ "Set HERMES_WEBUI_GATEWAY_API_KEY to the same value as the Hermes Gateway "
103
+ "API_SERVER_KEY, or disable HERMES_WEBUI_CHAT_BACKEND=gateway."
104
+ if not api_key_configured
105
+ else "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
106
+ ),
107
+ }
108
+ return {
109
+ "label": "Gateway request failed",
110
+ "type": "gateway_http_error",
111
+ "message": f"Gateway returned HTTP {exc.code}.",
112
+ "hint": safe or "Check the configured Gateway API server.",
113
+ }
114
+
115
+
116
+ def _gateway_sse_delta(payload: dict) -> str:
117
+ """Extract assistant text from an OpenAI-compatible streaming chunk."""
118
+ try:
119
+ choices = payload.get("choices") or []
120
+ if not choices:
121
+ return ""
122
+ choice = choices[0] or {}
123
+ delta = choice.get("delta") or {}
124
+ content = delta.get("content")
125
+ if isinstance(content, str):
126
+ return content
127
+ message = choice.get("message") or {}
128
+ content = message.get("content")
129
+ return content if isinstance(content, str) else ""
130
+ except Exception:
131
+ return ""
132
+
133
+
134
+ def _gateway_stream_usage(payload: dict) -> dict:
135
+ usage = payload.get("usage") if isinstance(payload, dict) else None
136
+ if not isinstance(usage, dict):
137
+ return {}
138
+ return {
139
+ "input_tokens": int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0),
140
+ "output_tokens": int(usage.get("completion_tokens") or usage.get("output_tokens") or 0),
141
+ "estimated_cost": usage.get("estimated_cost") or usage.get("estimated_cost_usd") or 0,
142
+ }
143
+
144
+
145
+ def _gateway_tool_progress_event(payload: dict) -> tuple[str, dict] | None:
146
+ """Translate Hermes Gateway tool-progress SSE payloads to WebUI events."""
147
+ if not isinstance(payload, dict):
148
+ return None
149
+ name = str(payload.get("tool") or payload.get("name") or payload.get("function_name") or "").strip()
150
+ if not name or name.startswith("_"):
151
+ return None
152
+ status = str(payload.get("status") or "running").strip().lower()
153
+ tid = payload.get("toolCallId") or payload.get("tool_call_id") or payload.get("id")
154
+ is_complete = status in {"completed", "complete", "success", "error", "failed"}
155
+ event_payload = {
156
+ "event_type": "tool.completed" if is_complete else "tool.started",
157
+ "name": name,
158
+ "preview": payload.get("label") or payload.get("preview"),
159
+ "args": payload.get("args") if isinstance(payload.get("args"), dict) else {},
160
+ "is_error": status in {"error", "failed"},
161
+ }
162
+ if tid:
163
+ event_payload["tid"] = str(tid)
164
+ return ("tool_complete" if is_complete else "tool"), event_payload
165
+
166
+
167
+ def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
168
+ return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
169
+
170
+
171
+ def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
172
+ if not _stream_writeback_is_current(session, stream_id):
173
+ return
174
+ session.active_stream_id = None
175
+ session.pending_user_message = None
176
+ session.pending_attachments = None
177
+ session.pending_started_at = None
178
+ session.save()
179
+
180
+
181
+ def _run_gateway_chat_streaming(
182
+ session_id,
183
+ msg_text,
184
+ model,
185
+ workspace,
186
+ stream_id,
187
+ attachments=None,
188
+ *,
189
+ model_provider=None,
190
+ ):
191
+ """Bridge a WebUI chat turn through Hermes Gateway's API server.
192
+
193
+ This default-off path keeps the browser contract unchanged: /api/chat/start
194
+ still returns a local stream_id and /api/chat/stream still receives WebUI SSE
195
+ event names. The worker translates OpenAI-compatible streaming chunks from
196
+ the configured Gateway API server into those local events and persists the
197
+ final user/assistant turn back into the WebUI session.
198
+ """
199
+ q = STREAMS.get(stream_id)
200
+ if q is None:
201
+ return
202
+ register_active_run(
203
+ stream_id,
204
+ session_id=session_id,
205
+ started_at=time.time(),
206
+ phase="gateway-starting",
207
+ workspace=str(workspace),
208
+ model=model,
209
+ provider=model_provider,
210
+ backend="gateway",
211
+ )
212
+ try:
213
+ run_journal = RunJournalWriter(session_id, stream_id)
214
+ except Exception:
215
+ run_journal = None
216
+ logger.debug("Failed to initialize gateway run journal for stream %s", stream_id, exc_info=True)
217
+ cancel_event = threading.Event()
218
+ with STREAMS_LOCK:
219
+ CANCEL_FLAGS[stream_id] = cancel_event
220
+ STREAM_PARTIAL_TEXT[stream_id] = ""
221
+ STREAM_REASONING_TEXT[stream_id] = ""
222
+ STREAM_LIVE_TOOL_CALLS[stream_id] = []
223
+
224
+ def put_gateway_event(event, data):
225
+ if cancel_event.is_set() and event not in ("cancel", "error", "apperror"):
226
+ return
227
+ if run_journal is not None:
228
+ try:
229
+ journaled = run_journal.append_sse_event(event, data)
230
+ event_id = (journaled or {}).get("event_id") if isinstance(journaled, dict) else None
231
+ if event_id:
232
+ STREAM_LAST_EVENT_ID[stream_id] = event_id
233
+ except Exception:
234
+ logger.debug("Failed to append gateway event %s for stream %s", event, stream_id, exc_info=True)
235
+ try:
236
+ q.put_nowait((event, data))
237
+ except Exception:
238
+ logger.debug("Failed to put gateway event to queue")
239
+
240
+ s = None
241
+ final_text = ""
242
+ usage = {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0}
243
+ try:
244
+ s = get_session(session_id)
245
+ from api.config import get_config # imported lazily to avoid config-cycle churn
246
+
247
+ cfg = get_config()
248
+ try:
249
+ from api.streaming import (
250
+ _load_webui_prefill_context,
251
+ _prefill_messages_with_webui_context,
252
+ _public_prefill_context_status,
253
+ )
254
+
255
+ prefill_context = _load_webui_prefill_context(cfg)
256
+ prefill_messages = _prefill_messages_with_webui_context(prefill_context, cfg)
257
+ put_gateway_event("context_status", {
258
+ "session_id": session_id,
259
+ "prefill": _public_prefill_context_status(prefill_context),
260
+ })
261
+ except Exception:
262
+ logger.debug("Failed to load WebUI gateway prefill context", exc_info=True)
263
+ prefill_messages = []
264
+ base_url = _gateway_base_url(cfg)
265
+ api_key = _gateway_api_key()
266
+ url = f"{base_url}/v1/chat/completions"
267
+ headers = {
268
+ "Content-Type": "application/json",
269
+ "Accept": "text/event-stream",
270
+ "X-Hermes-Session-Id": session_id,
271
+ }
272
+ if api_key:
273
+ headers["Authorization"] = f"Bearer {api_key}"
274
+ # Scope Gateway long-term continuity to this WebUI conversation
275
+ # without exposing the browser's auth cookie or CSRF material.
276
+ headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
277
+ message_content: Any = str(msg_text or "")
278
+ if attachments:
279
+ try:
280
+ from api.streaming import _build_native_multimodal_message
281
+
282
+ message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
283
+ except Exception:
284
+ logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
285
+ message_content = str(msg_text or "")
286
+ body = {
287
+ "model": model or "default",
288
+ "stream": True,
289
+ "messages": [*prefill_messages, {"role": "user", "content": message_content}],
290
+ }
291
+ if model_provider:
292
+ body["provider"] = model_provider
293
+ req = urllib.request.Request(
294
+ url,
295
+ data=json.dumps(body).encode("utf-8"),
296
+ headers=headers,
297
+ method="POST",
298
+ )
299
+ update_active_run(stream_id, phase="gateway-request")
300
+ last_payload = {}
301
+ sse_event = "message"
302
+ with urllib.request.urlopen(req, timeout=600) as resp:
303
+ for raw_line in resp:
304
+ if cancel_event.is_set():
305
+ put_gateway_event("cancel", {"message": "Cancelled by user"})
306
+ return
307
+ line = raw_line.decode("utf-8", errors="replace").strip()
308
+ if not line:
309
+ sse_event = "message"
310
+ continue
311
+ if line.startswith("event:"):
312
+ sse_event = line[6:].strip() or "message"
313
+ continue
314
+ if not line.startswith("data:"):
315
+ continue
316
+ data = line[5:].strip()
317
+ if data == "[DONE]":
318
+ break
319
+ try:
320
+ payload = json.loads(data)
321
+ except json.JSONDecodeError:
322
+ continue
323
+ if sse_event == "hermes.tool.progress":
324
+ translated = _gateway_tool_progress_event(payload)
325
+ if translated:
326
+ event_name, event_payload = translated
327
+ if stream_id in STREAM_LIVE_TOOL_CALLS:
328
+ if event_name == "tool":
329
+ STREAM_LIVE_TOOL_CALLS[stream_id].append({
330
+ "name": event_payload.get("name"),
331
+ "args": event_payload.get("args") or {},
332
+ "done": False,
333
+ **({"tid": event_payload.get("tid")} if event_payload.get("tid") else {}),
334
+ })
335
+ else:
336
+ for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
337
+ if shared_tc.get("done"):
338
+ continue
339
+ if (
340
+ event_payload.get("tid") and shared_tc.get("tid") == event_payload.get("tid")
341
+ ) or shared_tc.get("name") == event_payload.get("name"):
342
+ shared_tc["done"] = True
343
+ shared_tc["is_error"] = bool(event_payload.get("is_error"))
344
+ break
345
+ put_gateway_event(event_name, event_payload)
346
+ update_active_run(stream_id, phase="gateway-tool", latest_tool=event_payload.get("name"))
347
+ sse_event = "message"
348
+ continue
349
+ last_payload = payload
350
+ delta = _gateway_sse_delta(payload)
351
+ if delta:
352
+ final_text += delta
353
+ if stream_id in STREAM_PARTIAL_TEXT:
354
+ STREAM_PARTIAL_TEXT[stream_id] += delta
355
+ put_gateway_event("token", {"text": delta})
356
+ usage.update({k: v for k, v in _gateway_stream_usage(payload).items() if v})
357
+ usage.update({k: v for k, v in _gateway_stream_usage(last_payload).items() if v})
358
+ assistant_text = final_text.strip()
359
+ if not assistant_text:
360
+ put_gateway_event("apperror", {
361
+ "label": "Gateway returned no response",
362
+ "type": "gateway_empty_response",
363
+ "message": "Gateway returned no assistant message for this turn.",
364
+ "hint": "Check that Hermes Gateway API server is running and reachable.",
365
+ })
366
+ return
367
+ with _get_session_agent_lock(session_id):
368
+ s = get_session(session_id)
369
+ if not _stream_writeback_is_current(s, stream_id):
370
+ return
371
+ now = time.time()
372
+ # Preserve subsecond ordering for gateway-backed turns. Using an
373
+ # integer seconds timestamp gives the user and assistant rows the
374
+ # same sort key; later transcript merges can then fall back to
375
+ # role/content ordering instead of turn order.
376
+ assistant_ts = now + 0.000001
377
+ user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
378
+ if attachments:
379
+ user_msg["attachments"] = list(attachments)
380
+ assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
381
+ previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
382
+ s.context_messages = previous_context + [user_msg, assistant_msg]
383
+ display = list(getattr(s, "messages", None) or [])
384
+ # Avoid duplicating the eager-save checkpointed user message.
385
+ if display:
386
+ latest = display[-1]
387
+ if isinstance(latest, dict) and latest.get("role") == "user":
388
+ latest_text = " ".join(str(latest.get("content") or "").split())
389
+ msg_norm = " ".join(str(msg_text or "").split())
390
+ if latest_text == msg_norm:
391
+ display = display[:-1]
392
+ s.messages = display + [user_msg, assistant_msg]
393
+ s.active_stream_id = None
394
+ s.pending_user_message = None
395
+ s.pending_attachments = None
396
+ s.pending_started_at = None
397
+ s.workspace = str(workspace)
398
+ s.model = model
399
+ s.model_provider = model_provider
400
+ s.save()
401
+ gateway_session_payload = s.compact() | {"messages": s.messages, "tool_calls": []}
402
+ put_gateway_event("done", {"session": redact_session_data(gateway_session_payload), "usage": usage})
403
+ put_gateway_event("stream_end", {"session_id": session_id})
404
+ except urllib.error.HTTPError as exc:
405
+ try:
406
+ err_body = exc.read(2048).decode("utf-8", errors="replace")
407
+ except Exception:
408
+ err_body = ""
409
+ put_gateway_event(
410
+ "apperror",
411
+ _gateway_http_error_event(exc, err_body, api_key_configured=bool(_gateway_api_key())),
412
+ )
413
+ except Exception as exc:
414
+ safe = _redact_text(str(exc))[:500]
415
+ put_gateway_event("apperror", {
416
+ "label": "Gateway request failed",
417
+ "type": "gateway_error",
418
+ "message": safe or "Gateway request failed.",
419
+ "hint": "Check HERMES_WEBUI_GATEWAY_BASE_URL and Gateway API server health.",
420
+ })
421
+ finally:
422
+ if s is not None:
423
+ try:
424
+ with _get_session_agent_lock(session_id):
425
+ _clear_gateway_pending_state(get_session(session_id), stream_id)
426
+ except Exception:
427
+ logger.debug("Failed to clear gateway stream state", exc_info=True)
428
+ with STREAMS_LOCK:
429
+ CANCEL_FLAGS.pop(stream_id, None)
430
+ STREAM_PARTIAL_TEXT.pop(stream_id, None)
431
+ STREAM_REASONING_TEXT.pop(stream_id, None)
432
+ STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)
433
+ STREAM_LAST_EVENT_ID.pop(stream_id, None)
434
+ STREAMS.pop(stream_id, None)
435
+ unregister_active_run(stream_id)
@@ -0,0 +1,230 @@
1
+ """
2
+ Hermes Web UI -- Gateway session watcher.
3
+
4
+ Background daemon thread that polls state.db every 5 seconds for changes
5
+ to gateway sessions (telegram, discord, slack, etc.). When changes are
6
+ detected, it pushes notifications to all subscribed SSE clients.
7
+
8
+ This enables real-time session list updates in the sidebar without
9
+ requiring any changes to hermes-agent.
10
+ """
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import queue
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from api.config import HOME
21
+ from api.agent_sessions import read_importable_agent_session_rows
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # ── State hash tracking ─────────────────────────────────────────────────────
27
+
28
+ def _snapshot_hash(sessions: list) -> str:
29
+ """Create a lightweight hash of session IDs and timestamps for change detection."""
30
+ key = '|'.join(
31
+ f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
32
+ for s in sorted(sessions, key=lambda x: x['session_id'])
33
+ )
34
+ return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
35
+
36
+
37
+ # ── DB resolution (shared pattern with state_sync.py) ──────────────────────
38
+
39
+ def _get_state_db_path() -> Path:
40
+ """Resolve state.db path for the active profile."""
41
+ try:
42
+ from api.profiles import get_active_hermes_home
43
+ hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
44
+ except Exception:
45
+ hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
46
+ return hermes_home / 'state.db'
47
+
48
+
49
+ def _get_agent_sessions_from_db() -> list:
50
+ """Read all non-webui sessions from state.db.
51
+ Returns list of session dicts, or empty list on any error.
52
+ """
53
+ db_path = _get_state_db_path()
54
+ if not db_path.exists():
55
+ return []
56
+
57
+ try:
58
+ sessions = []
59
+ for row in read_importable_agent_session_rows(db_path, limit=200, log=logger):
60
+ sessions.append({
61
+ 'session_id': row['id'],
62
+ 'title': row['title'] or 'Agent Session',
63
+ 'model': row['model'] or None,
64
+ 'message_count': row['message_count'] or row['actual_message_count'] or 0,
65
+ 'created_at': row['started_at'],
66
+ 'updated_at': row['last_activity'] or row['started_at'],
67
+ 'source': row['source'] or 'cli',
68
+ 'raw_source': row.get('raw_source'),
69
+ 'session_source': row.get('session_source'),
70
+ 'source_label': row.get('source_label'),
71
+ })
72
+ return sessions
73
+ except Exception:
74
+ return []
75
+
76
+
77
+ # ── GatewayWatcher ──────────────────────────────────────────────────────────
78
+
79
+ class GatewayWatcher:
80
+ """Background thread that polls state.db for agent session changes.
81
+
82
+ Usage:
83
+ watcher = GatewayWatcher()
84
+ watcher.start()
85
+ q = watcher.subscribe()
86
+ # ... receive change events via q.get() ...
87
+ watcher.unsubscribe(q)
88
+ watcher.stop()
89
+ """
90
+
91
+ POLL_INTERVAL = 5 # seconds between polls
92
+ SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
93
+
94
+ def __init__(self):
95
+ self._subscribers: list[queue.Queue] = []
96
+ self._sub_lock = threading.Lock()
97
+ self._stop_event = threading.Event()
98
+ self._thread: threading.Thread | None = None
99
+ self._last_hash: str = ''
100
+ self._last_sessions: list = []
101
+
102
+ def start(self):
103
+ """Start the watcher daemon thread."""
104
+ if self._thread and self._thread.is_alive():
105
+ return
106
+ self._stop_event.clear()
107
+ self._thread = threading.Thread(target=self._poll_loop, daemon=True, name='gateway-watcher')
108
+ self._thread.start()
109
+
110
+ def is_alive(self) -> bool:
111
+ """Return True when the poll thread is running.
112
+
113
+ Public accessor used by ``/api/sessions/gateway/stream`` probe mode and
114
+ the live SSE handler to detect a watcher instance whose poll thread
115
+ died silently (e.g. uncaught exception in ``_poll_loop``). Callers
116
+ use this to decide whether to return 503 and trigger the client-side
117
+ polling fallback, instead of handing out an SSE connection that would
118
+ never emit events.
119
+ """
120
+ t = self._thread
121
+ return t is not None and t.is_alive()
122
+
123
+ def stop(self):
124
+ """Stop the watcher thread."""
125
+ self._stop_event.set()
126
+ # Wake up any subscribers
127
+ with self._sub_lock:
128
+ for q in self._subscribers:
129
+ try:
130
+ q.put(None) # sentinel
131
+ except Exception:
132
+ logger.debug("Failed to send sentinel to subscriber")
133
+ if self._thread:
134
+ self._thread.join(timeout=3)
135
+ self._thread = None
136
+
137
+ def subscribe(self) -> queue.Queue:
138
+ """Subscribe to change events. Returns a queue.Queue.
139
+ Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
140
+ A None sentinel means the watcher is stopping.
141
+ """
142
+ q = queue.Queue(maxsize=10)
143
+ with self._sub_lock:
144
+ self._subscribers.append(q)
145
+ return q
146
+
147
+ def unsubscribe(self, q: queue.Queue):
148
+ """Remove a subscriber queue."""
149
+ with self._sub_lock:
150
+ try:
151
+ self._subscribers.remove(q)
152
+ except ValueError:
153
+ pass
154
+
155
+ def _notify_subscribers(self, sessions: list):
156
+ """Push change event to all subscribers."""
157
+ event = {
158
+ 'type': 'sessions_changed',
159
+ 'sessions': sessions,
160
+ }
161
+ with self._sub_lock:
162
+ dead = []
163
+ for q in self._subscribers:
164
+ try:
165
+ q.put_nowait(event)
166
+ except queue.Full:
167
+ dead.append(q) # remove slow consumers
168
+ except Exception:
169
+ dead.append(q)
170
+ for q in dead:
171
+ try:
172
+ self._subscribers.remove(q)
173
+ except ValueError:
174
+ pass
175
+ # Send a None sentinel so the SSE handler unblocks, closes,
176
+ # and lets the browser's EventSource auto-reconnect.
177
+ try:
178
+ q.put_nowait(None)
179
+ except Exception:
180
+ logger.debug("Failed to send sentinel to dead subscriber")
181
+
182
+ def _poll_loop(self):
183
+ """Main polling loop. Runs in a daemon thread."""
184
+ while not self._stop_event.is_set():
185
+ try:
186
+ sessions = _get_agent_sessions_from_db()
187
+ current_hash = _snapshot_hash(sessions)
188
+
189
+ if current_hash != self._last_hash:
190
+ self._last_hash = current_hash
191
+ self._last_sessions = sessions
192
+ self._notify_subscribers(sessions)
193
+ except Exception:
194
+ logger.debug("Error in gateway watcher poll loop", exc_info=True)
195
+
196
+ # Sleep in small increments so we can stop promptly
197
+ for _ in range(self.POLL_INTERVAL * 10):
198
+ if self._stop_event.is_set():
199
+ return
200
+ time.sleep(0.1)
201
+
202
+
203
+ # ── Module-level singleton ─────────────────────────────────────────────────
204
+
205
+ _watcher: GatewayWatcher | None = None
206
+ _watcher_lock = threading.Lock()
207
+
208
+
209
+ def start_watcher():
210
+ """Start the global gateway watcher (idempotent)."""
211
+ global _watcher
212
+ with _watcher_lock:
213
+ if _watcher is None:
214
+ _watcher = GatewayWatcher()
215
+ _watcher.start()
216
+
217
+
218
+ def stop_watcher():
219
+ """Stop the global gateway watcher."""
220
+ global _watcher
221
+ with _watcher_lock:
222
+ if _watcher is not None:
223
+ _watcher.stop()
224
+ _watcher = None
225
+
226
+
227
+ def get_watcher() -> GatewayWatcher | None:
228
+ """Get the global watcher instance (or None if not started)."""
229
+ with _watcher_lock:
230
+ return _watcher