@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,167 @@
1
+ """Safe aggregate host resource metrics for the WebUI VPS panel (#693).
2
+
3
+ The browser only needs coarse CPU/RAM/disk usage. Keep this module intentionally
4
+ small and dependency-free: no process lists, command strings, user identities,
5
+ environment variables, or filesystem topology leave the server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ _PROC_STAT = Path("/proc/stat")
18
+ _PROC_MEMINFO = Path("/proc/meminfo")
19
+ _CPU_SAMPLE_SECONDS = 0.05
20
+
21
+
22
+ def _checked_at() -> str:
23
+ return datetime.now(timezone.utc).isoformat()
24
+
25
+
26
+ def _clamp_percent(value: Any) -> float:
27
+ try:
28
+ numeric = float(value)
29
+ except (TypeError, ValueError):
30
+ return 0.0
31
+ if numeric < 0:
32
+ numeric = 0.0
33
+ if numeric > 100:
34
+ numeric = 100.0
35
+ return round(numeric, 1)
36
+
37
+
38
+ def _read_proc_stat_cpu() -> tuple[int, int]:
39
+ """Return (idle_ticks, total_ticks) from Linux /proc/stat."""
40
+ with _PROC_STAT.open("r", encoding="utf-8") as handle:
41
+ first = handle.readline().strip().split()
42
+ if not first or first[0] != "cpu":
43
+ raise RuntimeError("proc_stat_unavailable")
44
+ values = [int(part) for part in first[1:]]
45
+ if len(values) < 4:
46
+ raise RuntimeError("proc_stat_unavailable")
47
+ idle = values[3] + (values[4] if len(values) > 4 else 0)
48
+ total = sum(values)
49
+ if total <= 0:
50
+ raise RuntimeError("proc_stat_unavailable")
51
+ return idle, total
52
+
53
+
54
+ def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float:
55
+ idle_delta = end[0] - start[0]
56
+ total_delta = end[1] - start[1]
57
+ if total_delta <= 0:
58
+ return 0.0
59
+ busy_delta = max(0, total_delta - max(0, idle_delta))
60
+ return _clamp_percent((busy_delta / total_delta) * 100.0)
61
+
62
+
63
+ def _cpu_percent() -> float:
64
+ """Sample aggregate CPU usage without psutil.
65
+
66
+ A short local sample avoids storing cross-request state and returns a stable
67
+ percentage on the first poll. Unsupported platforms raise a safe error code.
68
+ """
69
+ start = _read_proc_stat_cpu()
70
+ time.sleep(_CPU_SAMPLE_SECONDS)
71
+ end = _read_proc_stat_cpu()
72
+ return _cpu_delta_percent(start, end)
73
+
74
+
75
+ def _read_meminfo_kib() -> dict[str, int]:
76
+ data: dict[str, int] = {}
77
+ with _PROC_MEMINFO.open("r", encoding="utf-8") as handle:
78
+ for line in handle:
79
+ key, _, rest = line.partition(":")
80
+ if not key or not rest:
81
+ continue
82
+ parts = rest.strip().split()
83
+ if not parts:
84
+ continue
85
+ try:
86
+ data[key] = int(parts[0])
87
+ except ValueError:
88
+ continue
89
+ return data
90
+
91
+
92
+ def _memory_usage() -> dict[str, int | float]:
93
+ meminfo = _read_meminfo_kib()
94
+ total = int(meminfo.get("MemTotal") or 0) * 1024
95
+ if total <= 0:
96
+ raise RuntimeError("meminfo_unavailable")
97
+ available_kib = meminfo.get("MemAvailable")
98
+ if available_kib is None:
99
+ available_kib = (
100
+ meminfo.get("MemFree", 0)
101
+ + meminfo.get("Buffers", 0)
102
+ + meminfo.get("Cached", 0)
103
+ + meminfo.get("SReclaimable", 0)
104
+ - meminfo.get("Shmem", 0)
105
+ )
106
+ available = max(0, int(available_kib) * 1024)
107
+ used = max(0, min(total, total - available))
108
+ return {
109
+ "used_bytes": used,
110
+ "total_bytes": total,
111
+ "percent": _clamp_percent((used / total) * 100.0),
112
+ }
113
+
114
+
115
+ def _disk_usage() -> dict[str, int | float]:
116
+ usage = shutil.disk_usage("/")
117
+ total = int(usage.total)
118
+ if total <= 0:
119
+ raise RuntimeError("disk_unavailable")
120
+ used = int(usage.used)
121
+ return {
122
+ "used_bytes": used,
123
+ "total_bytes": total,
124
+ "percent": _clamp_percent((used / total) * 100.0),
125
+ }
126
+
127
+
128
+ def _safe_error(metric: str, exc: Exception) -> dict[str, str]:
129
+ # Keep this intentionally coarse. Exception messages can contain local paths
130
+ # on unusual platforms; the browser only needs a safe unavailable reason.
131
+ return {"metric": metric, "code": type(exc).__name__}
132
+
133
+
134
+ def build_system_health_payload() -> dict[str, Any]:
135
+ metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None}
136
+ errors: list[dict[str, str]] = []
137
+
138
+ collectors = {
139
+ "cpu": _cpu_percent,
140
+ "memory": _memory_usage,
141
+ "disk": _disk_usage,
142
+ }
143
+ for name, collect in collectors.items():
144
+ try:
145
+ value = collect()
146
+ if name == "cpu":
147
+ metrics[name] = {"percent": _clamp_percent(value)}
148
+ else:
149
+ metrics[name] = {
150
+ "used_bytes": max(0, int(value["used_bytes"])),
151
+ "total_bytes": max(0, int(value["total_bytes"])),
152
+ "percent": _clamp_percent(value["percent"]),
153
+ }
154
+ except Exception as exc:
155
+ errors.append(_safe_error(name, exc))
156
+
157
+ available = any(metrics[name] is not None for name in metrics)
158
+ status = "ok" if available and not errors else "partial" if available else "unavailable"
159
+ return {
160
+ "status": status,
161
+ "available": available,
162
+ "checked_at": _checked_at(),
163
+ "cpu": metrics["cpu"],
164
+ "memory": metrics["memory"],
165
+ "disk": metrics["disk"],
166
+ "errors": errors,
167
+ }
@@ -0,0 +1,410 @@
1
+ """Embedded workspace terminal support for Hermes Web UI.
2
+
3
+ The terminal is intentionally independent from the agent execution path. It
4
+ starts a shell with an explicit cwd/env per process and never mutates
5
+ process-global os.environ, which avoids expanding the session-env race tracked
6
+ in the agent execution layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import errno
12
+ import atexit
13
+ import codecs
14
+ import fcntl
15
+ import os
16
+ import queue
17
+ import select
18
+ import shutil
19
+ import signal
20
+ import struct
21
+ import subprocess
22
+ import termios
23
+ import threading
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+
29
+ def _set_nonblocking(fd: int) -> None:
30
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
31
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
32
+
33
+
34
+ def _winsize(rows: int, cols: int) -> bytes:
35
+ rows = max(8, min(int(rows or 24), 80))
36
+ cols = max(20, min(int(cols or 80), 240))
37
+ return struct.pack("HHHH", rows, cols, 0, 0)
38
+
39
+
40
+ def _safe_close_fd(fd: int) -> None:
41
+ try:
42
+ os.close(fd)
43
+ except OSError:
44
+ pass
45
+
46
+
47
+ @dataclass
48
+ class TerminalSession:
49
+ session_id: str
50
+ workspace: str
51
+ proc: subprocess.Popen
52
+ master_fd: int
53
+ rows: int = 24
54
+ cols: int = 80
55
+ output: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=2000))
56
+ closed: threading.Event = field(default_factory=threading.Event)
57
+ reader: threading.Thread | None = None
58
+
59
+ def is_alive(self) -> bool:
60
+ return not self.closed.is_set() and self.proc.poll() is None
61
+
62
+ def put_output(self, event: str, payload: dict) -> None:
63
+ try:
64
+ self.output.put_nowait((event, payload))
65
+ except queue.Full:
66
+ # Keep the terminal responsive by dropping the oldest queued chunk.
67
+ try:
68
+ self.output.get_nowait()
69
+ except queue.Empty:
70
+ pass
71
+ try:
72
+ self.output.put_nowait((event, payload))
73
+ except queue.Full:
74
+ pass
75
+
76
+
77
+ _TERMINALS: dict[str, TerminalSession] = {}
78
+ _LOCK = threading.RLock()
79
+ _spawn_queue: queue.Queue = queue.Queue()
80
+ _spawn_supervisor_started = False
81
+ _spawn_supervisor_lock = threading.Lock()
82
+ _spawn_supervisor_thread: threading.Thread | None = None
83
+
84
+
85
+ @dataclass
86
+ class _SpawnRequest:
87
+ kwargs: dict
88
+ done: threading.Event = field(default_factory=threading.Event)
89
+ timed_out: threading.Event = field(default_factory=threading.Event)
90
+ lock: threading.Lock = field(default_factory=threading.Lock)
91
+ proc: subprocess.Popen | None = None
92
+ error: BaseException | None = None
93
+
94
+
95
+ def _reap_abandoned_spawn(proc: subprocess.Popen) -> bool:
96
+ if proc.poll() is not None:
97
+ return True
98
+ try:
99
+ os.killpg(proc.pid, signal.SIGHUP)
100
+ except (OSError, ProcessLookupError):
101
+ try:
102
+ proc.terminate()
103
+ except (OSError, ProcessLookupError):
104
+ pass
105
+ try:
106
+ proc.wait(timeout=1.0)
107
+ except subprocess.TimeoutExpired:
108
+ try:
109
+ os.killpg(proc.pid, signal.SIGKILL)
110
+ except (OSError, ProcessLookupError):
111
+ try:
112
+ proc.kill()
113
+ except (OSError, ProcessLookupError):
114
+ pass
115
+ try:
116
+ proc.wait(timeout=1.0)
117
+ except (subprocess.TimeoutExpired, ProcessLookupError):
118
+ pass
119
+ if proc.poll() is None:
120
+ print("terminal abandoned spawn cleanup failed", flush=True)
121
+ return False
122
+ return True
123
+
124
+
125
+ def _spawn_supervisor_loop() -> None:
126
+ while True:
127
+ request = None
128
+ try:
129
+ request = _spawn_queue.get()
130
+ try:
131
+ proc = subprocess.Popen(**request.kwargs)
132
+ with request.lock:
133
+ if request.timed_out.is_set():
134
+ _reap_abandoned_spawn(proc)
135
+ else:
136
+ request.proc = proc
137
+ request.done.set()
138
+ except BaseException as exc:
139
+ with request.lock:
140
+ try:
141
+ request.error = exc
142
+ except BaseException:
143
+ pass
144
+ request.done.set()
145
+ except BaseException as exc:
146
+ if request is not None:
147
+ try:
148
+ request.error = exc
149
+ except BaseException:
150
+ pass
151
+ try:
152
+ request.done.set()
153
+ except BaseException:
154
+ pass
155
+ time.sleep(0.01)
156
+
157
+
158
+ def _spawn_supervisor_entry() -> None:
159
+ while True:
160
+ try:
161
+ _spawn_supervisor_loop()
162
+ except BaseException:
163
+ time.sleep(0.01)
164
+ pass
165
+
166
+
167
+ def _ensure_spawn_supervisor() -> None:
168
+ global _spawn_supervisor_started, _spawn_supervisor_thread
169
+ with _spawn_supervisor_lock:
170
+ if _spawn_supervisor_started and _spawn_supervisor_thread and _spawn_supervisor_thread.is_alive():
171
+ return
172
+ thread = threading.Thread(target=_spawn_supervisor_entry, daemon=True)
173
+ thread.start()
174
+ _spawn_supervisor_thread = thread
175
+ _spawn_supervisor_started = True
176
+
177
+
178
+ _ensure_spawn_supervisor()
179
+
180
+
181
+ # NOTE on parent-death-signal: a previous version of this module set
182
+ # PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the
183
+ # WebUI process crashed. That broke every Linux user (#2853): WebUI runs a
184
+ # ThreadingHTTPServer, so the Popen call happens on a short-lived per-request
185
+ # thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the
186
+ # spawning thread as its "parent" and was killed with SIGTERM the instant that
187
+ # thread joined — within ~10 ms of opening the terminal — surfacing as the
188
+ # `[terminal closed]` banner. The graceful path is covered by
189
+ # `atexit.register(close_all_terminals)` and the explicit `close_terminal`
190
+ # call sites; hard kills of the WebUI process leak the shell, which is the
191
+ # tradeoff for working on Linux at all.
192
+
193
+
194
+ def _decode_terminal_output(decoder, data: bytes) -> str:
195
+ """Decode PTY bytes without stripping terminal control sequences."""
196
+ return decoder.decode(data)
197
+
198
+
199
+ def _shell_path() -> str:
200
+ shell = os.environ.get("SHELL") or ""
201
+ if shell and Path(shell).exists():
202
+ return shell
203
+ return shutil.which("zsh") or shutil.which("bash") or shutil.which("sh") or "/bin/sh"
204
+
205
+
206
+ def _shell_argv(shell: str) -> list[str]:
207
+ name = Path(shell).name
208
+ if name in {"zsh", "bash", "sh"}:
209
+ return [shell, "-i"]
210
+ return [shell]
211
+
212
+
213
+ def _reader_loop(term: TerminalSession) -> None:
214
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
215
+ try:
216
+ while not term.closed.is_set():
217
+ if term.proc.poll() is not None:
218
+ break
219
+ try:
220
+ ready, _, _ = select.select([term.master_fd], [], [], 0.25)
221
+ except (OSError, ValueError):
222
+ break
223
+ if not ready:
224
+ continue
225
+ try:
226
+ data = os.read(term.master_fd, 8192)
227
+ except OSError as exc:
228
+ if exc.errno in (errno.EIO, errno.EBADF):
229
+ break
230
+ raise
231
+ if not data:
232
+ break
233
+ text = _decode_terminal_output(decoder, data)
234
+ if text:
235
+ term.put_output("output", {"text": text})
236
+ except Exception as exc:
237
+ term.put_output("terminal_error", {"error": str(exc)})
238
+ finally:
239
+ term.closed.set()
240
+ code = term.proc.poll()
241
+ term.put_output("terminal_closed", {"exit_code": code})
242
+
243
+
244
+ def _set_size(term: TerminalSession, rows: int, cols: int) -> None:
245
+ term.rows = max(8, min(int(rows or term.rows or 24), 80))
246
+ term.cols = max(20, min(int(cols or term.cols or 80), 240))
247
+ try:
248
+ fcntl.ioctl(term.master_fd, termios.TIOCSWINSZ, _winsize(term.rows, term.cols))
249
+ except OSError:
250
+ pass
251
+ try:
252
+ if term.proc.poll() is None:
253
+ os.killpg(term.proc.pid, signal.SIGWINCH)
254
+ except (OSError, ProcessLookupError):
255
+ pass
256
+
257
+
258
+ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
259
+ """Start or return the embedded terminal for a WebUI session."""
260
+ sid = str(session_id or "").strip()
261
+ if not sid:
262
+ raise ValueError("session_id is required")
263
+ cwd = str(Path(workspace).expanduser().resolve())
264
+ if not Path(cwd).is_dir():
265
+ raise ValueError("workspace is not a directory")
266
+
267
+ with _LOCK:
268
+ current = _TERMINALS.get(sid)
269
+ if current and current.is_alive() and not restart and current.workspace == cwd:
270
+ _set_size(current, rows, cols)
271
+ return current
272
+ if current:
273
+ close_terminal(sid)
274
+
275
+ master_fd, slave_fd = os.openpty()
276
+ # Build a safe env: allowlist common shell vars, strip API keys and secrets.
277
+ # The PTY shell is an interactive UI surface — do not leak server credentials.
278
+ _SAFE_ENV_KEYS = {
279
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL",
280
+ "LC_CTYPE", "LC_MESSAGES", "LANGUAGE", "TZ", "TMPDIR", "TEMP",
281
+ "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
282
+ }
283
+ env = {k: v for k, v in os.environ.items() if k in _SAFE_ENV_KEYS}
284
+ env.update(
285
+ {
286
+ "TERM": "xterm-256color",
287
+ "COLORTERM": "truecolor",
288
+ "COLUMNS": str(cols),
289
+ "LINES": str(rows),
290
+ "PWD": cwd,
291
+ "HERMES_WEBUI_TERMINAL": "1",
292
+ }
293
+ )
294
+ shell = _shell_path()
295
+ # Keep the shell in its own process group for explicit cleanup via
296
+ # close_terminal()/close_all_terminals(); do not use PDEATHSIG here.
297
+ request = _SpawnRequest(
298
+ {
299
+ "args": _shell_argv(shell),
300
+ "cwd": cwd,
301
+ "env": env,
302
+ "stdin": slave_fd,
303
+ "stdout": slave_fd,
304
+ "stderr": slave_fd,
305
+ "close_fds": True,
306
+ # Required so cleanup can signal the whole interactive shell tree.
307
+ "start_new_session": True,
308
+ }
309
+ )
310
+ _ensure_spawn_supervisor()
311
+ _spawn_queue.put(request)
312
+ try:
313
+ if not request.done.wait(timeout=5.0):
314
+ timed_out = False
315
+ with request.lock:
316
+ if not request.done.is_set():
317
+ request.timed_out.set()
318
+ timed_out = True
319
+ if timed_out:
320
+ raise TimeoutError("terminal spawn timeout - supervisor unresponsive")
321
+ if request.error:
322
+ raise request.error
323
+ proc = request.proc
324
+ if proc is None:
325
+ raise RuntimeError("terminal spawn failed without process")
326
+ except BaseException:
327
+ _safe_close_fd(master_fd)
328
+ _safe_close_fd(slave_fd)
329
+ raise
330
+ os.close(slave_fd)
331
+ _set_nonblocking(master_fd)
332
+
333
+ term = TerminalSession(
334
+ session_id=sid,
335
+ workspace=cwd,
336
+ proc=proc,
337
+ master_fd=master_fd,
338
+ rows=rows,
339
+ cols=cols,
340
+ )
341
+ _set_size(term, rows, cols)
342
+ term.reader = threading.Thread(target=_reader_loop, args=(term,), daemon=True)
343
+ term.reader.start()
344
+ _TERMINALS[sid] = term
345
+ return term
346
+
347
+
348
+ def get_terminal(session_id: str) -> TerminalSession | None:
349
+ with _LOCK:
350
+ term = _TERMINALS.get(str(session_id or ""))
351
+ if term and term.is_alive():
352
+ return term
353
+ return term
354
+
355
+
356
+ def write_terminal(session_id: str, data: str) -> None:
357
+ term = get_terminal(session_id)
358
+ if not term or not term.is_alive():
359
+ raise KeyError("terminal not running")
360
+ os.write(term.master_fd, str(data or "").encode("utf-8", errors="replace"))
361
+
362
+
363
+ def resize_terminal(session_id: str, rows: int, cols: int) -> None:
364
+ term = get_terminal(session_id)
365
+ if not term:
366
+ raise KeyError("terminal not running")
367
+ _set_size(term, rows, cols)
368
+
369
+
370
+ def close_terminal(session_id: str) -> bool:
371
+ sid = str(session_id or "")
372
+ with _LOCK:
373
+ term = _TERMINALS.pop(sid, None)
374
+ if not term:
375
+ return False
376
+ term.closed.set()
377
+ try:
378
+ if term.proc.poll() is None:
379
+ try:
380
+ os.killpg(term.proc.pid, signal.SIGHUP)
381
+ except ProcessLookupError:
382
+ pass
383
+ try:
384
+ term.proc.wait(timeout=1.5)
385
+ except subprocess.TimeoutExpired:
386
+ try:
387
+ os.killpg(term.proc.pid, signal.SIGKILL)
388
+ except ProcessLookupError:
389
+ pass
390
+ try:
391
+ term.proc.wait(timeout=1.0)
392
+ except (subprocess.TimeoutExpired, ProcessLookupError):
393
+ pass
394
+ finally:
395
+ try:
396
+ os.close(term.master_fd)
397
+ except OSError:
398
+ pass
399
+ return True
400
+
401
+
402
+ def close_all_terminals() -> None:
403
+ """Best-effort reap of embedded shells during graceful WebUI shutdown."""
404
+ with _LOCK:
405
+ session_ids = list(_TERMINALS)
406
+ for session_id in session_ids:
407
+ close_terminal(session_id)
408
+
409
+
410
+ atexit.register(close_all_terminals)