@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,160 @@
1
+ """Slow request diagnostics for latency-sensitive browser API paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ import threading
10
+ import time
11
+ import traceback
12
+ import uuid
13
+ from typing import Any
14
+
15
+
16
+ DEFAULT_SLOW_REQUEST_SECONDS = 5.0
17
+ MAX_STACK_FRAMES_PER_THREAD = 40
18
+
19
+
20
+ def _slow_request_seconds() -> float:
21
+ raw = os.getenv("HERMES_WEBUI_SLOW_REQUEST_SECONDS", "").strip()
22
+ if not raw:
23
+ return DEFAULT_SLOW_REQUEST_SECONDS
24
+ try:
25
+ value = float(raw)
26
+ except ValueError:
27
+ return DEFAULT_SLOW_REQUEST_SECONDS
28
+ return max(0.0, value)
29
+
30
+
31
+ class RequestDiagnostics:
32
+ """Track request stages and emit a watchdog record if a request wedges."""
33
+
34
+ def __init__(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ *,
39
+ logger: logging.Logger | None = None,
40
+ timeout_seconds: float | None = None,
41
+ auto_start: bool = True,
42
+ ) -> None:
43
+ self.request_id = uuid.uuid4().hex[:10]
44
+ self.method = str(method or "-")
45
+ self.path = str(path or "-").split("?", 1)[0]
46
+ self.logger = logger or logging.getLogger(__name__)
47
+ self.timeout_seconds = _slow_request_seconds() if timeout_seconds is None else max(0.0, float(timeout_seconds))
48
+ self.started_monotonic = time.monotonic()
49
+ self.started_wall = time.time()
50
+ self._lock = threading.Lock()
51
+ self._stages: list[dict[str, Any]] = []
52
+ self._current_stage = "start"
53
+ self._current_stage_started = self.started_monotonic
54
+ self._finished = False
55
+ self._watchdog_logged = False
56
+ self._timer: threading.Timer | None = None
57
+ if auto_start and self.timeout_seconds > 0:
58
+ self._timer = threading.Timer(self.timeout_seconds, self._on_timeout)
59
+ self._timer.daemon = True
60
+ self._timer.start()
61
+
62
+ @classmethod
63
+ def maybe_start(
64
+ cls,
65
+ method: str,
66
+ path: str,
67
+ *,
68
+ logger: logging.Logger | None = None,
69
+ ) -> "RequestDiagnostics | None":
70
+ clean_path = str(path or "").split("?", 1)[0]
71
+ if (method.upper(), clean_path) not in {
72
+ ("GET", "/api/sessions"),
73
+ ("POST", "/api/chat/start"),
74
+ }:
75
+ return None
76
+ return cls(method, clean_path, logger=logger)
77
+
78
+ def stage(self, name: str) -> None:
79
+ now = time.monotonic()
80
+ clean = str(name or "unknown").strip() or "unknown"
81
+ with self._lock:
82
+ if self._finished:
83
+ return
84
+ self._stages.append(
85
+ {
86
+ "name": self._current_stage,
87
+ "ms": round((now - self._current_stage_started) * 1000, 1),
88
+ }
89
+ )
90
+ self._current_stage = clean
91
+ self._current_stage_started = now
92
+
93
+ def finish(self) -> None:
94
+ timer = None
95
+ record = None
96
+ with self._lock:
97
+ if self._finished:
98
+ return
99
+ self._finished = True
100
+ timer = self._timer
101
+ record = self._build_record_locked(include_stacks=False)
102
+ if timer is not None:
103
+ timer.cancel()
104
+ if record and self.timeout_seconds > 0 and record["elapsed_ms"] >= self.timeout_seconds * 1000:
105
+ self.logger.warning(
106
+ "Slow WebUI request completed: %s",
107
+ json.dumps(record, sort_keys=True),
108
+ )
109
+
110
+ def _on_timeout(self) -> None:
111
+ with self._lock:
112
+ if self._finished or self._watchdog_logged:
113
+ return
114
+ self._watchdog_logged = True
115
+ record = self._build_record_locked(include_stacks=True)
116
+ self.logger.warning(
117
+ "Slow WebUI request still running: %s",
118
+ json.dumps(record, sort_keys=True),
119
+ )
120
+
121
+ def _build_record_locked(self, *, include_stacks: bool) -> dict[str, Any]:
122
+ now = time.monotonic()
123
+ stages = list(self._stages)
124
+ stages.append(
125
+ {
126
+ "name": self._current_stage,
127
+ "ms": round((now - self._current_stage_started) * 1000, 1),
128
+ }
129
+ )
130
+ record: dict[str, Any] = {
131
+ "request_id": self.request_id,
132
+ "method": self.method,
133
+ "path": self.path,
134
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.started_wall)),
135
+ "elapsed_ms": round((now - self.started_monotonic) * 1000, 1),
136
+ "current_stage": self._current_stage,
137
+ "stages": stages,
138
+ }
139
+ if include_stacks:
140
+ record["thread_stacks"] = _thread_stack_snapshot()
141
+ return record
142
+
143
+
144
+ def _thread_stack_snapshot() -> list[dict[str, Any]]:
145
+ frames = sys._current_frames()
146
+ threads = {thread.ident: thread for thread in threading.enumerate()}
147
+ snapshot: list[dict[str, Any]] = []
148
+ for ident, frame in frames.items():
149
+ thread = threads.get(ident)
150
+ stack = traceback.format_stack(frame, limit=MAX_STACK_FRAMES_PER_THREAD)
151
+ snapshot.append(
152
+ {
153
+ "thread_id": ident,
154
+ "thread_name": thread.name if thread else "",
155
+ "daemon": bool(thread.daemon) if thread else None,
156
+ "stack": [line.rstrip() for line in stack],
157
+ }
158
+ )
159
+ snapshot.sort(key=lambda item: str(item.get("thread_name") or ""))
160
+ return snapshot
@@ -0,0 +1,320 @@
1
+ """
2
+ Hermes Web UI -- Filesystem checkpoint (rollback) API.
3
+
4
+ Provides endpoints to list, diff, and restore filesystem checkpoints
5
+ created by the Hermes agent's CheckpointManager. Checkpoints live at
6
+ ``{hermes_home}/checkpoints/<hash>/`` as shadow git repositories.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Checkpoint identifiers are SHA-style hex hashes from the agent's
23
+ # CheckpointManager. We only allow [A-Za-z0-9_.-]{1,64} (no '/' so the
24
+ # value cannot be a path separator, no leading '.' so it cannot escape
25
+ # upward via '..'/'.'). This is defense-in-depth: the workspace arg is
26
+ # already allowlisted, but ``Path() / "../escape"`` does not normalize,
27
+ # so without this guard a `checkpoint` value of `../<other-ws-hash>/<sha>`
28
+ # would let any authenticated caller diff or restore from another
29
+ # allowlisted workspace's checkpoint store. (Opus pre-release advisor.)
30
+ _CHECKPOINT_ID_RE = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$")
31
+
32
+
33
+ def _validate_checkpoint_id(checkpoint: str) -> str:
34
+ cid = str(checkpoint or "").strip()
35
+ if not cid or cid in (".", "..") or not _CHECKPOINT_ID_RE.fullmatch(cid):
36
+ raise ValueError(
37
+ "checkpoint id must match [A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}"
38
+ )
39
+ return cid
40
+
41
+
42
+ def _hermes_home() -> Path:
43
+ """Return the active Hermes home directory."""
44
+ try:
45
+ from api.profiles import get_active_hermes_home
46
+ return Path(get_active_hermes_home())
47
+ except Exception:
48
+ return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
49
+
50
+
51
+ def _workspace_hash(workspace: str) -> str:
52
+ """Derive the checkpoint directory name from a workspace path.
53
+
54
+ Matches the agent's CheckpointManager._get_checkpoint_dir logic:
55
+ SHA-256 of the canonical workspace path.
56
+ """
57
+ try:
58
+ canonical = os.path.realpath(workspace)
59
+ except (OSError, ValueError):
60
+ canonical = workspace
61
+ return hashlib.sha256(canonical.encode()).hexdigest()[:12]
62
+
63
+
64
+ def _checkpoint_root() -> Path:
65
+ return _hermes_home() / "checkpoints"
66
+
67
+
68
+ def _resolve_workspace(workspace: str) -> str:
69
+ """Validate and return the canonical workspace path.
70
+
71
+ Security: workspace must match a known configured workspace
72
+ (from workspaces.json or session-attached workspaces).
73
+ """
74
+ if not workspace or not isinstance(workspace, str):
75
+ raise ValueError("workspace is required")
76
+ # Basic path validation
77
+ resolved = os.path.realpath(workspace)
78
+ if not os.path.isdir(resolved):
79
+ raise ValueError(f"Workspace does not exist: {workspace}")
80
+ # Security: confirm workspace is in the known list
81
+ try:
82
+ from api.workspace import load_workspaces
83
+ known_paths = set()
84
+ for ws in load_workspaces():
85
+ p = ws.get("path", "")
86
+ if p:
87
+ known_paths.add(os.path.realpath(p))
88
+ if resolved not in known_paths:
89
+ raise ValueError(f"Workspace not in configured list: {workspace}")
90
+ except ImportError:
91
+ logger.warning("Could not load workspace list for rollback validation")
92
+ return resolved
93
+
94
+
95
+ def _find_git() -> str:
96
+ """Return the path to the git binary."""
97
+ return shutil.which("git") or "git"
98
+
99
+
100
+ # ── Public API functions (called from routes.py) ────────────────────────────
101
+
102
+
103
+ def list_checkpoints(workspace: str) -> dict[str, Any]:
104
+ """List all checkpoints for a workspace.
105
+
106
+ Returns a dict with:
107
+ checkpoints: list of checkpoint objects
108
+ workspace: resolved workspace path
109
+ checkpoint_dir: the checkpoint directory path
110
+ """
111
+ resolved = _resolve_workspace(workspace)
112
+ ws_hash = _workspace_hash(resolved)
113
+ ckpt_dir = _checkpoint_root() / ws_hash
114
+
115
+ checkpoints = []
116
+ if not ckpt_dir.is_dir():
117
+ return {"checkpoints": [], "workspace": resolved, "checkpoint_dir": str(ckpt_dir)}
118
+
119
+ # Each checkpoint is a git repo in <ckpt_dir>/<commit_hash>/
120
+ git = _find_git()
121
+ for entry in sorted(ckpt_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.is_dir() else 0, reverse=True):
122
+ if not entry.is_dir():
123
+ continue
124
+ ckpt_info = _inspect_checkpoint(entry, git)
125
+ if ckpt_info:
126
+ checkpoints.append(ckpt_info)
127
+
128
+ return {
129
+ "checkpoints": checkpoints,
130
+ "workspace": resolved,
131
+ "checkpoint_dir": str(ckpt_dir),
132
+ }
133
+
134
+
135
+ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
136
+ """Extract metadata from a single checkpoint directory."""
137
+ git_dir = ckpt_path / ".git"
138
+ if not git_dir.is_dir():
139
+ return None
140
+
141
+ name = ckpt_path.name
142
+ try:
143
+ result = subprocess.run(
144
+ [git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
145
+ capture_output=True, text=True, timeout=5,
146
+ )
147
+ if result.returncode != 0 or not result.stdout.strip():
148
+ return None
149
+
150
+ lines = result.stdout.strip().split("\n")
151
+ commit_hash = lines[0] if len(lines) > 0 else name
152
+ message = lines[1] if len(lines) > 1 else "checkpoint"
153
+ date_str = lines[2] if len(lines) > 2 else ""
154
+
155
+ # Parse date for display
156
+ date_display = ""
157
+ if date_str:
158
+ try:
159
+ dt = datetime.fromisoformat(date_str)
160
+ date_display = dt.strftime("%Y-%m-%d %H:%M")
161
+ except (ValueError, TypeError):
162
+ date_display = date_str
163
+
164
+ # Count files
165
+ files_result = subprocess.run(
166
+ [git, "-C", str(ckpt_path), "ls-files"],
167
+ capture_output=True, text=True, timeout=5,
168
+ )
169
+ file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
170
+
171
+ return {
172
+ "id": name,
173
+ "commit": commit_hash[:12],
174
+ "message": message,
175
+ "date": date_str,
176
+ "date_display": date_display,
177
+ "files": file_count,
178
+ "path": str(ckpt_path),
179
+ }
180
+ except (subprocess.TimeoutExpired, OSError) as e:
181
+ logger.debug("Failed to inspect checkpoint %s: %s", ckpt_path, e)
182
+ return None
183
+
184
+
185
+ def get_checkpoint_diff(workspace: str, checkpoint: str) -> dict[str, Any]:
186
+ """Show the diff between a checkpoint and the current workspace state.
187
+
188
+ Returns a dict with:
189
+ diff: unified diff text
190
+ files_changed: list of changed file paths
191
+ """
192
+ resolved = _resolve_workspace(workspace)
193
+ checkpoint = _validate_checkpoint_id(checkpoint)
194
+ ws_hash = _workspace_hash(resolved)
195
+ ckpt_dir = _checkpoint_root() / ws_hash / checkpoint
196
+
197
+ if not ckpt_dir.is_dir():
198
+ raise ValueError(f"Checkpoint not found: {checkpoint}")
199
+
200
+ git = _find_git()
201
+
202
+ # Get list of files in the checkpoint
203
+ ls_result = subprocess.run(
204
+ [git, "-C", str(ckpt_dir), "ls-files"],
205
+ capture_output=True, text=True, timeout=10,
206
+ )
207
+ if ls_result.returncode != 0:
208
+ raise ValueError("Failed to list checkpoint files")
209
+
210
+ ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f]
211
+ files_changed = []
212
+ diff_lines = []
213
+
214
+ for rel_path in ckpt_files:
215
+ ckpt_file = ckpt_dir / rel_path
216
+ ws_file = Path(resolved) / rel_path
217
+
218
+ if not ckpt_file.is_file():
219
+ continue
220
+
221
+ # Read checkpoint version
222
+ try:
223
+ ckpt_content = ckpt_file.read_text(errors="replace")
224
+ except OSError:
225
+ continue
226
+
227
+ # Read workspace version (if exists)
228
+ if ws_file.is_file():
229
+ try:
230
+ ws_content = ws_file.read_text(errors="replace")
231
+ except OSError:
232
+ ws_content = ""
233
+ else:
234
+ ws_content = None # File was deleted in workspace
235
+
236
+ if ws_content is None:
237
+ # File exists in checkpoint but not in workspace (deleted)
238
+ files_changed.append({"file": rel_path, "status": "deleted"})
239
+ diff_lines.append(f"--- a/{rel_path}")
240
+ diff_lines.append(f"+++ /dev/null")
241
+ diff_lines.append("@@ -1,{lines} +0,0 @@".format(lines=len(ckpt_content.splitlines())))
242
+ for line in ckpt_content.splitlines():
243
+ diff_lines.append(f"-{line}")
244
+ elif ckpt_content != ws_content:
245
+ # File changed
246
+ import difflib
247
+ ckpt_lines = ckpt_content.splitlines(keepends=True)
248
+ ws_lines = ws_content.splitlines(keepends=True)
249
+ diff = list(difflib.unified_diff(ckpt_lines, ws_lines, fromfile=f"a/{rel_path}", tofile=f"b/{rel_path}", lineterm=""))
250
+ if diff:
251
+ files_changed.append({"file": rel_path, "status": "modified"})
252
+ diff_lines.extend(diff)
253
+
254
+ # Check for new files in workspace that aren't in checkpoint
255
+ # (skip for performance — diff is primarily for seeing what the checkpoint captures)
256
+
257
+ return {
258
+ "checkpoint": checkpoint,
259
+ "workspace": resolved,
260
+ "diff": "\n".join(diff_lines) if diff_lines else "",
261
+ "files_changed": files_changed,
262
+ "total_changes": len(files_changed),
263
+ }
264
+
265
+
266
+ def restore_checkpoint(workspace: str, checkpoint: str) -> dict[str, Any]:
267
+ """Restore a checkpoint by copying files back to the workspace.
268
+
269
+ Only restores files that exist in the checkpoint. Does NOT delete
270
+ files that were added after the checkpoint was created.
271
+
272
+ Returns a dict with:
273
+ ok: True
274
+ files_restored: list of restored file paths
275
+ """
276
+ resolved = _resolve_workspace(workspace)
277
+ checkpoint = _validate_checkpoint_id(checkpoint)
278
+ ws_hash = _workspace_hash(resolved)
279
+ ckpt_dir = _checkpoint_root() / ws_hash / checkpoint
280
+
281
+ if not ckpt_dir.is_dir():
282
+ raise ValueError(f"Checkpoint not found: {checkpoint}")
283
+
284
+ git = _find_git()
285
+
286
+ # Get list of files in the checkpoint
287
+ ls_result = subprocess.run(
288
+ [git, "-C", str(ckpt_dir), "ls-files"],
289
+ capture_output=True, text=True, timeout=10,
290
+ )
291
+ if ls_result.returncode != 0:
292
+ raise ValueError("Failed to list checkpoint files")
293
+
294
+ ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f]
295
+ restored = []
296
+ errors = []
297
+
298
+ for rel_path in ckpt_files:
299
+ ckpt_file = ckpt_dir / rel_path
300
+ ws_file = Path(resolved) / rel_path
301
+
302
+ if not ckpt_file.is_file():
303
+ continue
304
+
305
+ try:
306
+ ws_file.parent.mkdir(parents=True, exist_ok=True)
307
+ shutil.copy2(str(ckpt_file), str(ws_file))
308
+ restored.append(rel_path)
309
+ except OSError as e:
310
+ errors.append({"file": rel_path, "error": str(e)})
311
+ logger.warning("Failed to restore %s: %s", rel_path, e)
312
+
313
+ return {
314
+ "ok": True,
315
+ "checkpoint": checkpoint,
316
+ "workspace": resolved,
317
+ "files_restored": restored,
318
+ "files_restored_count": len(restored),
319
+ "errors": errors,
320
+ }