@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,284 @@
1
+ """Append-only WebUI run event journal helpers.
2
+
3
+ This is the first #1925 journal/replay slice. It mirrors SSE events emitted by
4
+ the existing in-process streaming path without changing execution ownership.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Iterable
15
+
16
+ RUN_JOURNAL_DIR_NAME = "_run_journal"
17
+ _SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
18
+ _WRITER_LOCKS: dict[tuple[str, str, str], threading.Lock] = {}
19
+ _WRITER_LOCKS_GUARD = threading.Lock()
20
+ _TERMINAL_SSE_EVENTS = {"done", "cancel", "apperror", "error", "stream_end"}
21
+ _FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC"
22
+ _FSYNC_MODE_EAGER = "eager"
23
+ _FSYNC_MODE_TERMINAL_ONLY = "terminal-only"
24
+
25
+
26
+ def _default_session_dir() -> Path:
27
+ from api.models import SESSION_DIR
28
+
29
+ return Path(SESSION_DIR)
30
+
31
+
32
+ def _validate_id(value: str, field: str) -> str:
33
+ cleaned = str(value or "").strip()
34
+ if not cleaned or "/" in cleaned or "\\" in cleaned or not _SAFE_ID_RE.fullmatch(cleaned):
35
+ raise ValueError(f"invalid {field}")
36
+ return cleaned
37
+
38
+
39
+ def _run_path(session_id: str, run_id: str, session_dir: Path | None = None) -> Path:
40
+ sid = _validate_id(session_id, "session_id")
41
+ rid = _validate_id(run_id, "run_id")
42
+ root = Path(session_dir) if session_dir is not None else _default_session_dir()
43
+ return root / RUN_JOURNAL_DIR_NAME / sid / f"{rid}.jsonl"
44
+
45
+
46
+ def _lock_for(path: Path) -> threading.Lock:
47
+ key = (str(path.parent), path.name, str(os.getpid()))
48
+ with _WRITER_LOCKS_GUARD:
49
+ lock = _WRITER_LOCKS.get(key)
50
+ if lock is None:
51
+ lock = threading.Lock()
52
+ _WRITER_LOCKS[key] = lock
53
+ return lock
54
+
55
+
56
+ def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]:
57
+ events: list[dict] = []
58
+ malformed: list[dict] = []
59
+ try:
60
+ lines = path.read_text(encoding="utf-8").splitlines()
61
+ except FileNotFoundError:
62
+ return events, malformed
63
+ for line_no, raw in enumerate(lines, start=1):
64
+ if not raw.strip():
65
+ continue
66
+ try:
67
+ parsed = json.loads(raw)
68
+ except json.JSONDecodeError:
69
+ malformed.append({"line": line_no, "raw": raw})
70
+ continue
71
+ if isinstance(parsed, dict):
72
+ events.append(parsed)
73
+ else:
74
+ malformed.append({"line": line_no, "raw": raw})
75
+ return events, malformed
76
+
77
+
78
+ def _next_seq(path: Path) -> int:
79
+ events, _malformed = _read_jsonl(path)
80
+ seqs = [int(event.get("seq") or 0) for event in events if isinstance(event.get("seq"), int)]
81
+ return (max(seqs) + 1) if seqs else 1
82
+
83
+
84
+ def _terminal_state_for_event(event_name: str, payload) -> str | None:
85
+ name = str(event_name or "")
86
+ if name == "done" or name == "stream_end":
87
+ return "completed"
88
+ if name == "cancel":
89
+ return "interrupted-by-user"
90
+ if name in {"apperror", "error"}:
91
+ err_type = str((payload or {}).get("type") or "").strip().lower() if isinstance(payload, dict) else ""
92
+ if err_type in {"cancelled", "canceled"}:
93
+ return "interrupted-by-user"
94
+ if err_type == "interrupted":
95
+ return "interrupted-by-crash"
96
+ return "errored"
97
+ return None
98
+
99
+
100
+ def _run_journal_fsync_mode() -> str:
101
+ raw = os.environ.get(_FSYNC_MODE_ENV, _FSYNC_MODE_TERMINAL_ONLY)
102
+ mode = str(raw or "").strip().lower()
103
+ if mode in {_FSYNC_MODE_EAGER, _FSYNC_MODE_TERMINAL_ONLY}:
104
+ return mode
105
+ return _FSYNC_MODE_TERMINAL_ONLY
106
+
107
+
108
+ def _should_fsync_event(terminal_state: str | None) -> bool:
109
+ if _run_journal_fsync_mode() == _FSYNC_MODE_EAGER:
110
+ return True
111
+ return bool(terminal_state)
112
+
113
+
114
+ def _fsync_parent_dir(path: Path) -> None:
115
+ try:
116
+ dir_fd = os.open(path.parent, getattr(os, "O_DIRECTORY", 0))
117
+ try:
118
+ os.fsync(dir_fd)
119
+ finally:
120
+ os.close(dir_fd)
121
+ except OSError:
122
+ pass
123
+
124
+
125
+ def append_run_event(
126
+ session_id: str,
127
+ run_id: str,
128
+ event_name: str,
129
+ payload=None,
130
+ *,
131
+ session_dir: Path | None = None,
132
+ seq: int | None = None,
133
+ created_at: float | None = None,
134
+ ) -> dict:
135
+ """Append one durable run event and fsync it according to the journal policy."""
136
+ path = _run_path(session_id, run_id, session_dir=session_dir)
137
+ payload = payload if payload is not None else {}
138
+ event_name = str(event_name or "").strip()
139
+ if not event_name:
140
+ raise ValueError("event_name is required")
141
+ with _lock_for(path):
142
+ assigned_seq = int(seq) if seq is not None else _next_seq(path)
143
+ terminal_state = _terminal_state_for_event(event_name, payload)
144
+ event = {
145
+ "version": 1,
146
+ "event_id": f"{run_id}:{assigned_seq}",
147
+ "seq": assigned_seq,
148
+ "run_id": str(run_id),
149
+ "session_id": str(session_id),
150
+ "event": event_name,
151
+ "type": event_name,
152
+ "created_at": float(created_at if created_at is not None else time.time()),
153
+ "terminal": bool(terminal_state),
154
+ "terminal_state": terminal_state,
155
+ "payload": payload,
156
+ }
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ created_file = not path.exists()
159
+ line = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
160
+ fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
161
+ with os.fdopen(fd, "a", encoding="utf-8") as fh:
162
+ fh.write(line)
163
+ fh.flush()
164
+ if _should_fsync_event(terminal_state):
165
+ os.fsync(fh.fileno())
166
+ if created_file:
167
+ _fsync_parent_dir(path)
168
+ return event
169
+
170
+
171
+ class RunJournalWriter:
172
+ """Stateful writer for one WebUI stream/run."""
173
+
174
+ def __init__(self, session_id: str, run_id: str, *, session_dir: Path | None = None):
175
+ self.session_id = _validate_id(session_id, "session_id")
176
+ self.run_id = _validate_id(run_id, "run_id")
177
+ self.session_dir = Path(session_dir) if session_dir is not None else None
178
+ self._path = _run_path(self.session_id, self.run_id, session_dir=self.session_dir)
179
+ self._lock = _lock_for(self._path)
180
+ with self._lock:
181
+ self._next_seq = _next_seq(self._path)
182
+
183
+ def append_sse_event(self, event_name: str, payload=None) -> dict:
184
+ with self._lock:
185
+ seq = self._next_seq
186
+ self._next_seq += 1
187
+ return append_run_event(
188
+ self.session_id,
189
+ self.run_id,
190
+ event_name,
191
+ payload or {},
192
+ session_dir=self.session_dir,
193
+ seq=seq,
194
+ )
195
+
196
+
197
+ def read_run_events(
198
+ session_id: str,
199
+ run_id: str,
200
+ *,
201
+ after_seq: int | None = None,
202
+ session_dir: Path | None = None,
203
+ ) -> dict:
204
+ path = _run_path(session_id, run_id, session_dir=session_dir)
205
+ events, malformed = _read_jsonl(path)
206
+ if after_seq is not None:
207
+ events = [event for event in events if int(event.get("seq") or 0) > int(after_seq)]
208
+ return {
209
+ "session_id": str(session_id),
210
+ "run_id": str(run_id),
211
+ "events": events,
212
+ "malformed": malformed,
213
+ }
214
+
215
+
216
+ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
217
+ ordered = [event for event in events if isinstance(event, dict)]
218
+ last = ordered[-1] if ordered else None
219
+ terminal_events = [event for event in ordered if event.get("terminal")]
220
+ terminal = next(
221
+ (event for event in reversed(terminal_events) if event.get("event") != "stream_end"),
222
+ terminal_events[-1] if terminal_events else None,
223
+ )
224
+ status = terminal.get("terminal_state") if terminal else ("running" if ordered else "unknown")
225
+ return {
226
+ "session_id": str(session_id),
227
+ "run_id": str(run_id),
228
+ "stream_id": str(run_id),
229
+ "event_count": len(ordered),
230
+ "last_seq": int((last or {}).get("seq") or 0),
231
+ "last_event_id": (last or {}).get("event_id"),
232
+ "terminal": bool(terminal),
233
+ "terminal_state": status,
234
+ "last_event": (last or {}).get("event"),
235
+ }
236
+
237
+
238
+ def latest_run_summary(session_id: str, run_id: str, *, session_dir: Path | None = None) -> dict:
239
+ journal = read_run_events(session_id, run_id, session_dir=session_dir)
240
+ return _summary_from_events(session_id, run_id, journal.get("events") or [])
241
+
242
+
243
+ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict | None:
244
+ rid = _validate_id(run_id, "run_id")
245
+ root = Path(session_dir) if session_dir is not None else _default_session_dir()
246
+ journal_root = root / RUN_JOURNAL_DIR_NAME
247
+ for path in journal_root.glob(f"*/{rid}.jsonl"):
248
+ session_id = path.parent.name
249
+ events, _malformed = _read_jsonl(path)
250
+ summary = _summary_from_events(session_id, rid, events)
251
+ summary["path"] = str(path)
252
+ return summary
253
+ return None
254
+
255
+
256
+ def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | None = None) -> dict | None:
257
+ summary = latest_run_summary(session_id, run_id)
258
+ if summary.get("terminal") or not summary.get("event_count"):
259
+ return None
260
+ seq = int(summary.get("last_seq") or 0) + 1
261
+ if after_seq is not None and seq <= int(after_seq):
262
+ return None
263
+ payload = {
264
+ "type": "interrupted",
265
+ "message": "The live worker stopped before this run finished.",
266
+ "hint": "The transcript was restored to the last journaled event. Start a new turn if you still need the task to continue.",
267
+ "session_id": session_id,
268
+ "stream_id": run_id,
269
+ "journal_last_seq": summary.get("last_seq"),
270
+ }
271
+ return {
272
+ "version": 1,
273
+ "event_id": f"{run_id}:{seq}",
274
+ "seq": seq,
275
+ "run_id": run_id,
276
+ "session_id": session_id,
277
+ "event": "apperror",
278
+ "type": "apperror",
279
+ "created_at": time.time(),
280
+ "terminal": True,
281
+ "terminal_state": "lost-worker-bookkeeping",
282
+ "payload": payload,
283
+ "synthetic": True,
284
+ }
@@ -0,0 +1,156 @@
1
+ """HTTP client boundary for a supervised Hermes WebUI runner backend.
2
+
3
+ This module intentionally contains no process-local run maps, stream queues,
4
+ cancellation registries, approval/clarify queues, or cached agent instances. It
5
+ is only a JSON-over-HTTP transport used by ``RunnerRuntimeAdapter`` when an
6
+ operator explicitly configures a runner endpoint.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from typing import Any
16
+
17
+
18
+ _RUNNER_BASE_URL_ENV = "HERMES_WEBUI_RUNNER_BASE_URL"
19
+ _RUNNER_API_KEY_ENV = "HERMES_WEBUI_RUNNER_API_KEY"
20
+
21
+
22
+ class RunnerClientError(RuntimeError):
23
+ """Raised when a configured runner endpoint rejects or fails a request."""
24
+
25
+
26
+ def runner_client_configured(environ: dict[str, str] | None = None) -> bool:
27
+ source = os.environ if environ is None else environ
28
+ return bool(str(source.get(_RUNNER_BASE_URL_ENV) or "").strip())
29
+
30
+
31
+ class HttpRunnerClient:
32
+ """Small JSON HTTP client for the external/supervised runner boundary."""
33
+
34
+ def __init__(self, *, base_url: str, api_key: str = ""):
35
+ self.base_url = str(base_url or "").strip().rstrip("/")
36
+ if not self.base_url:
37
+ raise ValueError("runner base_url is required")
38
+ # Hardening: the runner endpoint is operator-configured, but reject any
39
+ # non-HTTP(S) scheme so a misconfigured HERMES_WEBUI_RUNNER_BASE_URL
40
+ # (e.g. file:///etc/passwd or ftp://) can never be handed to urlopen.
41
+ _scheme = urllib.parse.urlsplit(self.base_url).scheme.lower()
42
+ if _scheme not in ("http", "https"):
43
+ raise ValueError(
44
+ f"runner base_url must be http(s); got scheme '{_scheme or '(none)'}'"
45
+ )
46
+ self.api_key = str(api_key or "").strip()
47
+
48
+ @classmethod
49
+ def from_env(cls, environ: dict[str, str] | None = None) -> "HttpRunnerClient":
50
+ source = os.environ if environ is None else environ
51
+ base_url = str(source.get(_RUNNER_BASE_URL_ENV) or "").strip()
52
+ if not base_url:
53
+ raise NotImplementedError("runner-local chat backend is not configured")
54
+ return cls(base_url=base_url, api_key=str(source.get(_RUNNER_API_KEY_ENV) or ""))
55
+
56
+ def start_run(self, request) -> dict[str, Any]:
57
+ return self._post("/v1/runs", {
58
+ "session_id": request.session_id,
59
+ "message": request.message,
60
+ "attachments": list(request.attachments or []),
61
+ "workspace": request.workspace,
62
+ "profile": request.profile,
63
+ "provider": request.provider,
64
+ "model": request.model,
65
+ "toolsets": list(request.toolsets or []),
66
+ "source": request.source,
67
+ "metadata": dict(request.metadata or {}),
68
+ })
69
+
70
+ def observe_run(self, run_id: str, *, cursor: str | None = None) -> dict[str, Any]:
71
+ query = ""
72
+ if cursor not in (None, ""):
73
+ query = "?cursor=" + urllib.parse.quote(str(cursor), safe="")
74
+ return self._get(f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/events{query}")
75
+
76
+ def get_run(self, run_id: str) -> dict[str, Any]:
77
+ return self._get(f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}")
78
+
79
+ def cancel_run(self, run_id: str) -> dict[str, Any]:
80
+ return self._post(f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/cancel", {})
81
+
82
+ def respond_approval(self, run_id: str, approval_id: str, choice: str) -> dict[str, Any]:
83
+ return self._post(
84
+ f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/approvals/{urllib.parse.quote(str(approval_id), safe='')}/respond",
85
+ {"choice": choice},
86
+ )
87
+
88
+ def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> dict[str, Any]:
89
+ return self._post(
90
+ f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/clarifications/{urllib.parse.quote(str(clarify_id), safe='')}/respond",
91
+ {"response": response},
92
+ )
93
+
94
+ def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> dict[str, Any]:
95
+ return self._post(
96
+ f"/v1/runs/{urllib.parse.quote(str(run_id), safe='')}/messages",
97
+ {"message": message, "mode": mode},
98
+ )
99
+
100
+ def update_goal(self, session_id: str, action: str, text: str = "") -> dict[str, Any]:
101
+ return self._post(
102
+ f"/v1/sessions/{urllib.parse.quote(str(session_id), safe='')}/goal",
103
+ {"action": action, "text": text},
104
+ )
105
+
106
+ def _headers(self) -> dict[str, str]:
107
+ headers = {
108
+ "Accept": "application/json",
109
+ "Content-Type": "application/json",
110
+ "User-Agent": "Hermes-WebUI-RunnerClient",
111
+ }
112
+ if self.api_key:
113
+ headers["Authorization"] = f"Bearer {self.api_key}"
114
+ return headers
115
+
116
+ def _get(self, path: str) -> dict[str, Any]:
117
+ req = urllib.request.Request(self.base_url + path, headers=self._headers(), method="GET")
118
+ return self._request_json(req)
119
+
120
+ def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
121
+ req = urllib.request.Request(
122
+ self.base_url + path,
123
+ data=json.dumps(payload).encode("utf-8"),
124
+ headers=self._headers(),
125
+ method="POST",
126
+ )
127
+ return self._request_json(req)
128
+
129
+ def _opener(self) -> urllib.request.OpenerDirector:
130
+ # Hardening: do NOT follow redirects. A misbehaving/compromised runner
131
+ # returning 3xx Location could otherwise smuggle the Bearer token to
132
+ # another host. Treat any redirect as an error instead.
133
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
134
+ def redirect_request(self, *args, **kwargs):
135
+ return None
136
+ return urllib.request.build_opener(_NoRedirect)
137
+
138
+ def _request_json(self, req: urllib.request.Request) -> dict[str, Any]:
139
+ try:
140
+ with self._opener().open(req, timeout=60) as resp:
141
+ raw = resp.read().decode("utf-8", errors="replace")
142
+ except urllib.error.HTTPError as exc:
143
+ try:
144
+ detail = exc.read(2048).decode("utf-8", errors="replace")
145
+ except Exception:
146
+ detail = ""
147
+ raise RunnerClientError(f"Runner returned HTTP {exc.code}: {detail[:500]}") from exc
148
+ except Exception as exc:
149
+ raise RunnerClientError(f"Runner request failed: {exc}") from exc
150
+ try:
151
+ payload = json.loads(raw or "{}")
152
+ except json.JSONDecodeError as exc:
153
+ raise RunnerClientError("Runner returned invalid JSON") from exc
154
+ if not isinstance(payload, dict):
155
+ raise RunnerClientError("Runner returned a non-object JSON payload")
156
+ return payload