@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,640 @@
1
+ """Read-only sidebar discoverability audit for Hermes WebUI sessions.
2
+
3
+ This module does not repair or mutate session state. It cross-checks the four
4
+ places that decide whether a session can be found from the WebUI sidebar:
5
+
6
+ - JSON sidecars under the WebUI session directory
7
+ - ``_index.json`` sidebar metadata
8
+ - canonical ``state.db`` rows/messages
9
+ - the live ``api.models.all_sessions()`` sidebar response, when available
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import shutil
17
+ import sqlite3
18
+ from collections import Counter
19
+ from pathlib import Path
20
+ from typing import Iterable
21
+
22
+
23
+ def _safe_int(value, default: int = 0) -> int:
24
+ try:
25
+ if value is None:
26
+ return default
27
+ return int(value)
28
+ except (TypeError, ValueError):
29
+ return default
30
+
31
+
32
+ def _read_json(path: Path):
33
+ try:
34
+ return json.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, json.JSONDecodeError, ValueError):
36
+ return None
37
+
38
+
39
+ def _message_count_from_payload(payload: dict) -> int:
40
+ messages = payload.get("messages")
41
+ if isinstance(messages, list):
42
+ return len(messages)
43
+ return _safe_int(payload.get("message_count"), 0)
44
+
45
+
46
+ def _record_from_mapping(mapping: dict, source_name: str) -> dict:
47
+ sid = str(mapping.get("session_id") or mapping.get("id") or "").strip()
48
+ if not sid:
49
+ return {}
50
+ return {
51
+ "session_id": sid,
52
+ "title": mapping.get("title"),
53
+ "message_count": _message_count_from_payload(mapping),
54
+ "source_tag": mapping.get("source_tag"),
55
+ "session_source": mapping.get("session_source"),
56
+ "source": mapping.get("source"),
57
+ "is_cli_session": mapping.get("is_cli_session"),
58
+ "parent_session_id": mapping.get("parent_session_id"),
59
+ "pre_compression_snapshot": bool(mapping.get("pre_compression_snapshot")),
60
+ "_lineage_root_id": mapping.get("_lineage_root_id"),
61
+ "archived": bool(mapping.get("archived")),
62
+ "project_id": mapping.get("project_id"),
63
+ "workspace": mapping.get("workspace"),
64
+ "_source_name": source_name,
65
+ }
66
+
67
+
68
+ def _read_sidecars(session_dir: Path) -> dict[str, dict]:
69
+ records: dict[str, dict] = {}
70
+ if not session_dir.exists():
71
+ return records
72
+ for path in sorted(p for p in session_dir.glob("*.json") if not p.name.startswith("_")):
73
+ payload = _read_json(path)
74
+ if not isinstance(payload, dict):
75
+ continue
76
+ record = _record_from_mapping(payload, "sidecar")
77
+ if record:
78
+ records[record["session_id"]] = record
79
+ return records
80
+
81
+
82
+ def _read_index(session_dir: Path) -> dict[str, dict]:
83
+ payload = _read_json(session_dir / "_index.json")
84
+ records: dict[str, dict] = {}
85
+ if not isinstance(payload, list):
86
+ return records
87
+ for entry in payload:
88
+ if not isinstance(entry, dict):
89
+ continue
90
+ record = _record_from_mapping(entry, "index")
91
+ if record:
92
+ records[record["session_id"]] = record
93
+ return records
94
+
95
+
96
+ def _optional_expr(name: str, columns: set[str], fallback: str = "NULL") -> str:
97
+ return name if name in columns else f"{fallback} AS {name}"
98
+
99
+
100
+ def _read_state_db(state_db_path: Path | None) -> dict[str, dict]:
101
+ if state_db_path is None or not state_db_path.exists():
102
+ return {}
103
+ try:
104
+ with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
105
+ conn.row_factory = sqlite3.Row
106
+ tables = {row[0] for row in conn.execute("select name from sqlite_master where type='table'")}
107
+ if "sessions" not in tables:
108
+ return {}
109
+ session_cols = {row[1] for row in conn.execute("pragma table_info(sessions)")}
110
+ if "id" not in session_cols:
111
+ return {}
112
+ message_cols: set[str] = set()
113
+ if "messages" in tables:
114
+ message_cols = {row[1] for row in conn.execute("pragma table_info(messages)")}
115
+ title_expr = _optional_expr("title", session_cols)
116
+ source_expr = _optional_expr("source", session_cols)
117
+ parent_expr = _optional_expr("parent_session_id", session_cols)
118
+ msg_expr = _optional_expr("message_count", session_cols, "0")
119
+ workspace_expr = _optional_expr("workspace", session_cols)
120
+ rows = conn.execute(
121
+ f"""
122
+ SELECT id, {title_expr}, {source_expr}, {parent_expr}, {msg_expr}, {workspace_expr}
123
+ FROM sessions
124
+ """
125
+ ).fetchall()
126
+ message_counts: dict[str, int] = {}
127
+ if {"session_id"}.issubset(message_cols):
128
+ for row in conn.execute("SELECT session_id, COUNT(*) AS count FROM messages GROUP BY session_id"):
129
+ message_counts[str(row["session_id"])] = _safe_int(row["count"], 0)
130
+ records: dict[str, dict] = {}
131
+ for row in rows:
132
+ sid = str(row["id"] or "").strip()
133
+ if not sid:
134
+ continue
135
+ count = message_counts.get(sid, _safe_int(row["message_count"], 0))
136
+ records[sid] = {
137
+ "session_id": sid,
138
+ "title": row["title"],
139
+ "message_count": count,
140
+ "source": row["source"],
141
+ "source_tag": row["source"],
142
+ "session_source": row["source"],
143
+ "parent_session_id": row["parent_session_id"],
144
+ "workspace": row["workspace"],
145
+ "_source_name": "state_db",
146
+ }
147
+ return records
148
+ except Exception:
149
+ return {}
150
+
151
+
152
+ def _normalize_api_sessions(api_sessions: Iterable[dict] | None) -> dict[str, dict]:
153
+ records: dict[str, dict] = {}
154
+ if api_sessions is None:
155
+ try:
156
+ from api.models import all_sessions
157
+
158
+ api_sessions = all_sessions()
159
+ except Exception:
160
+ api_sessions = []
161
+ for entry in api_sessions or []:
162
+ if not isinstance(entry, dict):
163
+ continue
164
+ record = _record_from_mapping(entry, "api")
165
+ if record:
166
+ records[record["session_id"]] = record
167
+ return records
168
+
169
+
170
+ def _merged_field(sid: str, stores: list[dict[str, dict]], field: str):
171
+ for store in stores:
172
+ value = store.get(sid, {}).get(field)
173
+ if value not in (None, ""):
174
+ return value
175
+ return None
176
+
177
+
178
+ def _max_message_count(sid: str, stores: list[dict[str, dict]]) -> int:
179
+ return max((_safe_int(store.get(sid, {}).get("message_count"), 0) for store in stores), default=0)
180
+
181
+
182
+ def _lineage_root(sid: str, parent_by_id: dict[str, str | None]) -> str:
183
+ seen: set[str] = set()
184
+ current = sid
185
+ while current and current not in seen:
186
+ seen.add(current)
187
+ parent = parent_by_id.get(current)
188
+ if not parent:
189
+ return current
190
+ current = parent
191
+ return sid
192
+
193
+
194
+ def _webui_origin(*records: dict) -> bool:
195
+ values: list[str] = []
196
+ for record in records:
197
+ for key in ("source", "source_tag", "session_source"):
198
+ value = record.get(key)
199
+ if value is not None:
200
+ values.append(str(value).strip().lower())
201
+ return "webui" in values
202
+
203
+
204
+ def _computed_is_cli_session(row: dict) -> bool:
205
+ sources = {
206
+ str(row.get(key) or "").strip().lower()
207
+ for key in ("session_source", "source_tag", "raw_source", "source", "source_label")
208
+ }
209
+ if "webui" in sources:
210
+ return False
211
+ try:
212
+ from api.agent_sessions import is_cli_session_row
213
+
214
+ return is_cli_session_row(row)
215
+ except Exception:
216
+ source = str(row.get("session_source") or row.get("source_tag") or row.get("raw_source") or row.get("source") or "").strip().lower()
217
+ return source == "cli"
218
+
219
+
220
+ def _new_item(session_id: str, kind: str, category: str, recommendation: str, **extra) -> dict:
221
+ item = {
222
+ "session_id": session_id,
223
+ "kind": kind,
224
+ "category": category,
225
+ "recommendation": recommendation,
226
+ }
227
+ item.update(extra)
228
+ return item
229
+
230
+
231
+ def audit_session_discoverability(
232
+ session_dir: Path,
233
+ state_db_path: Path | None = None,
234
+ *,
235
+ api_sessions: Iterable[dict] | None = None,
236
+ ) -> dict:
237
+ """Return a read-only cross-store discoverability report.
238
+
239
+ The audit is intentionally diagnostic only. It reports cases where
240
+ messageful sessions have no visible API/sidebar representative, stale source
241
+ flags can put WebUI sessions into the CLI tab, and index/sidecar/state-db
242
+ drift can make a session harder to resolve.
243
+ """
244
+ session_dir = Path(session_dir)
245
+ sidecars = _read_sidecars(session_dir)
246
+ index = _read_index(session_dir)
247
+ state = _read_state_db(state_db_path)
248
+ api = _normalize_api_sessions(api_sessions)
249
+ stores = [sidecars, index, state, api]
250
+ all_ids = set().union(*(store.keys() for store in stores))
251
+
252
+ parent_by_id: dict[str, str | None] = {}
253
+ for sid in all_ids:
254
+ parent = _merged_field(sid, stores, "parent_session_id")
255
+ parent_by_id[sid] = str(parent) if parent else None
256
+ api_lineage_ids: set[str] = set()
257
+ api_lineage_representative_by_id: dict[str, str] = {}
258
+ for sid, row in api.items():
259
+ explicit_root = row.get("_lineage_root_id")
260
+ if explicit_root:
261
+ root_id = str(explicit_root)
262
+ api_lineage_ids.add(root_id)
263
+ api_lineage_representative_by_id.setdefault(root_id, sid)
264
+ current = sid
265
+ seen: set[str] = set()
266
+ while current and current not in seen:
267
+ seen.add(current)
268
+ api_lineage_ids.add(current)
269
+ api_lineage_representative_by_id.setdefault(current, sid)
270
+ current = parent_by_id.get(current) or ""
271
+
272
+ items: list[dict] = []
273
+ for sid in sorted(all_ids):
274
+ message_count = _max_message_count(sid, stores)
275
+ present_in = {
276
+ "sidecar": sid in sidecars,
277
+ "index": sid in index,
278
+ "state_db": sid in state,
279
+ "api": sid in api,
280
+ }
281
+ sidecar = sidecars.get(sid, {})
282
+ index_row = index.get(sid, {})
283
+ state_row = state.get(sid, {})
284
+ api_row = api.get(sid, {})
285
+ webui_origin = _webui_origin(sidecar, index_row, state_row, api_row)
286
+
287
+ api_is_cli = api_row.get("is_cli_session") is True
288
+ api_computed_is_cli = _computed_is_cli_session(api_row) if api_row else False
289
+ index_is_cli = index_row.get("is_cli_session") is True
290
+ sidecar_is_cli = sidecar.get("is_cli_session") is True
291
+ lineage_root = _lineage_root(sid, parent_by_id)
292
+ api_representative = api_lineage_representative_by_id.get(sid) or api_lineage_representative_by_id.get(lineage_root)
293
+ api_lineage_extra = {
294
+ "represented_by_api_lineage": bool(api_representative),
295
+ "api_representative_session_id": api_representative,
296
+ }
297
+ if webui_origin and api_is_cli and api_computed_is_cli:
298
+ items.append(_new_item(
299
+ sid,
300
+ "source_misclassified",
301
+ "warning",
302
+ "normalize_api_source_flags",
303
+ message_count=message_count,
304
+ state_source=state_row.get("source"),
305
+ api_is_cli_session=api_row.get("is_cli_session"),
306
+ api_computed_is_cli_session=api_computed_is_cli,
307
+ index_is_cli_session=index_row.get("is_cli_session"),
308
+ sidecar_is_cli_session=sidecar.get("is_cli_session"),
309
+ present_in=present_in,
310
+ **api_lineage_extra,
311
+ ))
312
+ elif webui_origin and (api_is_cli or index_is_cli or sidecar_is_cli):
313
+ items.append(_new_item(
314
+ sid,
315
+ "persisted_source_flag_stale",
316
+ "warning",
317
+ "rewrite_persisted_sidebar_source_flags_or_ignore_route_normalizes",
318
+ message_count=message_count,
319
+ state_source=state_row.get("source"),
320
+ api_is_cli_session=api_row.get("is_cli_session"),
321
+ api_computed_is_cli_session=api_computed_is_cli,
322
+ index_is_cli_session=index_row.get("is_cli_session"),
323
+ sidecar_is_cli_session=sidecar.get("is_cli_session"),
324
+ present_in=present_in,
325
+ **api_lineage_extra,
326
+ ))
327
+
328
+ if message_count <= 0 or sid in api:
329
+ continue
330
+
331
+ is_hidden_snapshot = bool(sidecar.get("pre_compression_snapshot") or index_row.get("pre_compression_snapshot"))
332
+ if sid in api_lineage_ids or lineage_root in api_lineage_ids:
333
+ continue
334
+
335
+ if is_hidden_snapshot:
336
+ items.append(_new_item(
337
+ sid,
338
+ "lineage_missing_visible_representative",
339
+ "warning",
340
+ "repair_lineage_or_expose_tip",
341
+ message_count=message_count,
342
+ lineage_root=lineage_root,
343
+ present_in=present_in,
344
+ ))
345
+ continue
346
+
347
+ if not present_in["sidecar"] and not present_in["index"] and present_in["state_db"]:
348
+ items.append(_new_item(
349
+ sid,
350
+ "state_db_messageful_missing_sidecar",
351
+ "warning",
352
+ "materialize_sidecar_or_archive_state_row",
353
+ message_count=message_count,
354
+ lineage_root=lineage_root,
355
+ present_in=present_in,
356
+ ))
357
+ continue
358
+
359
+ items.append(_new_item(
360
+ sid,
361
+ "api_missing_messageful",
362
+ "warning",
363
+ "investigate_sidebar_filters_or_api_merge",
364
+ message_count=message_count,
365
+ lineage_root=lineage_root,
366
+ present_in=present_in,
367
+ ))
368
+
369
+ summary = {
370
+ "sessions_seen": len(all_ids),
371
+ "messageful": sum(1 for sid in all_ids if _max_message_count(sid, stores) > 0),
372
+ "visible_api": len(api),
373
+ "warnings": sum(1 for item in items if item.get("category") == "warning"),
374
+ }
375
+ status = "warn" if summary["warnings"] else "ok"
376
+ return {
377
+ "status": status,
378
+ "summary": summary,
379
+ "stores": {
380
+ "sidecar": len(sidecars),
381
+ "index": len(index),
382
+ "state_db": len(state),
383
+ "api": len(api),
384
+ },
385
+ "items": items,
386
+ }
387
+
388
+
389
+ def _atomic_write_json(path: Path, payload) -> None:
390
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
391
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
392
+ os.replace(tmp, path)
393
+
394
+
395
+ def _backup_file(path: Path, backup_dir: Path, backed_up: dict[Path, str]) -> str | None:
396
+ if not path.exists():
397
+ return None
398
+ resolved = path.resolve()
399
+ if resolved in backed_up:
400
+ return backed_up[resolved]
401
+ backup_dir.mkdir(parents=True, exist_ok=True)
402
+ target = backup_dir / path.name
403
+ if target.exists():
404
+ stem = target.name
405
+ i = 1
406
+ while (backup_dir / f"{stem}.{i}").exists():
407
+ i += 1
408
+ target = backup_dir / f"{stem}.{i}"
409
+ shutil.copy2(path, target)
410
+ backed_up[resolved] = str(target)
411
+ return str(target)
412
+
413
+
414
+ def _plan_discoverability_repairs(report: dict) -> list[dict]:
415
+ actions: list[dict] = []
416
+ for item in report.get("items") or []:
417
+ sid = str(item.get("session_id") or "")
418
+ if not sid:
419
+ continue
420
+ if item.get("kind") == "persisted_source_flag_stale":
421
+ if item.get("sidecar_is_cli_session") is True:
422
+ actions.append({"session_id": sid, "action": "clear_sidecar_cli_flag"})
423
+ if item.get("index_is_cli_session") is True:
424
+ actions.append({"session_id": sid, "action": "clear_index_cli_flag"})
425
+ elif item.get("kind") == "state_db_messageful_missing_sidecar":
426
+ actions.append({"session_id": sid, "action": "materialize_sidecar_from_state_db"})
427
+ return actions
428
+
429
+
430
+ def _clear_sidecar_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
431
+ path = session_dir / f"{sid}.json"
432
+ payload = _read_json(path)
433
+ if not isinstance(payload, dict):
434
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "error": "sidecar_unreadable"}
435
+ if not _webui_origin(payload):
436
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "not_webui_origin"}
437
+ if payload.get("is_cli_session") is not True:
438
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "already_clear"}
439
+ backup = _backup_file(path, backup_dir, backed_up)
440
+ payload["is_cli_session"] = False
441
+ _atomic_write_json(path, payload)
442
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": True, "backup": backup}
443
+
444
+
445
+ def _clear_index_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
446
+ path = session_dir / "_index.json"
447
+ payload = _read_json(path)
448
+ if not isinstance(payload, list):
449
+ return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "error": "index_unreadable"}
450
+ changed = False
451
+ for entry in payload:
452
+ if not isinstance(entry, dict):
453
+ continue
454
+ if str(entry.get("session_id") or "") != sid:
455
+ continue
456
+ if not _webui_origin(entry):
457
+ continue
458
+ if entry.get("is_cli_session") is True:
459
+ entry["is_cli_session"] = False
460
+ changed = True
461
+ if not changed:
462
+ return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "skipped": "already_clear_or_missing"}
463
+ backup = _backup_file(path, backup_dir, backed_up)
464
+ _atomic_write_json(path, payload)
465
+ return {"session_id": sid, "action": "clear_index_cli_flag", "applied": True, "backup": backup}
466
+
467
+
468
+ def _materialize_sidecar_from_state_db(session_dir: Path, state_db_path: Path | None, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
469
+ if state_db_path is None:
470
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": "state_db_required"}
471
+ target = session_dir / f"{sid}.json"
472
+ if target.exists():
473
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_exists"}
474
+ try:
475
+ from api.session_recovery import _read_state_db_missing_sidecar_rows, _state_db_row_to_sidecar
476
+ except Exception as exc:
477
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": f"recovery_import_failed:{exc}"}
478
+ rows = {str(row.get("id") or ""): row for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path)}
479
+ row = rows.get(sid)
480
+ if not row:
481
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "state_row_not_repairable"}
482
+ payload = _state_db_row_to_sidecar(row)
483
+ _backup_file(state_db_path, backup_dir, backed_up)
484
+ session_dir.mkdir(parents=True, exist_ok=True)
485
+ tmp = target.with_suffix(target.suffix + f".tmp.{os.getpid()}")
486
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
487
+ try:
488
+ os.link(str(tmp), str(target))
489
+ except FileExistsError:
490
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_appeared_during_repair"}
491
+ finally:
492
+ try:
493
+ tmp.unlink(missing_ok=True)
494
+ except OSError:
495
+ pass
496
+ index_updated = False
497
+ index_path = session_dir / "_index.json"
498
+ index_payload = _read_json(index_path)
499
+ if not isinstance(index_payload, list):
500
+ index_payload = []
501
+ if not any(isinstance(entry, dict) and str(entry.get("session_id") or "") == sid for entry in index_payload):
502
+ _backup_file(index_path, backup_dir, backed_up)
503
+ index_entry = {key: value for key, value in payload.items() if key not in {"messages", "tool_calls"}}
504
+ index_payload.append(index_entry)
505
+ _atomic_write_json(index_path, index_payload)
506
+ index_updated = True
507
+ return {
508
+ "session_id": sid,
509
+ "action": "materialize_sidecar_from_state_db",
510
+ "applied": True,
511
+ "messages": len(payload.get("messages") or []),
512
+ "index_updated": index_updated,
513
+ "backup": str((backup_dir / state_db_path.name)) if (backup_dir / state_db_path.name).exists() else None,
514
+ }
515
+
516
+
517
+ def repair_session_discoverability(
518
+ session_dir: Path,
519
+ state_db_path: Path | None = None,
520
+ *,
521
+ api_sessions: Iterable[dict] | None = None,
522
+ dry_run: bool = True,
523
+ backup_dir: Path | None = None,
524
+ ) -> dict:
525
+ """Plan or apply deterministic discoverability repairs.
526
+
527
+ Default mode is read-only. Applying mutations requires ``backup_dir`` and is
528
+ limited to stale persisted WebUI-as-CLI flags plus materializing WebUI
529
+ messageful sidecars from canonical state.db rows.
530
+ """
531
+ before = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
532
+ planned = _plan_discoverability_repairs(before)
533
+ if dry_run:
534
+ return {"ok": True, "dry_run": True, "planned": planned, "applied": [], "before": before, "after": before}
535
+ if backup_dir is None:
536
+ return {"ok": False, "dry_run": False, "error": "backup_dir_required_for_apply", "planned": planned, "applied": [], "before": before}
537
+
538
+ session_dir = Path(session_dir)
539
+ backup_dir = Path(backup_dir)
540
+ backed_up: dict[Path, str] = {}
541
+ applied: list[dict] = []
542
+ for action in planned:
543
+ sid = str(action.get("session_id") or "")
544
+ name = action.get("action")
545
+ try:
546
+ if name == "clear_sidecar_cli_flag":
547
+ applied.append(_clear_sidecar_cli_flag(session_dir, sid, backup_dir, backed_up))
548
+ elif name == "clear_index_cli_flag":
549
+ applied.append(_clear_index_cli_flag(session_dir, sid, backup_dir, backed_up))
550
+ elif name == "materialize_sidecar_from_state_db":
551
+ applied.append(_materialize_sidecar_from_state_db(session_dir, state_db_path, sid, backup_dir, backed_up))
552
+ except Exception as exc:
553
+ applied.append({"session_id": sid, "action": name, "applied": False, "error": str(exc)})
554
+ after = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
555
+ errors = [item for item in applied if item.get("error")]
556
+ return {
557
+ "ok": not errors,
558
+ "dry_run": False,
559
+ "planned": planned,
560
+ "applied": applied,
561
+ "backups": sorted(set(backed_up.values())),
562
+ "before": before,
563
+ "after": after,
564
+ }
565
+
566
+
567
+ def render_discoverability_markdown(report: dict) -> str:
568
+ lines = [
569
+ "# WebUI Session Discoverability Audit",
570
+ "",
571
+ f"Status: `{report.get('status')}`",
572
+ "",
573
+ "## Summary",
574
+ "",
575
+ ]
576
+ for key, value in (report.get("summary") or {}).items():
577
+ lines.append(f"- `{key}`: {value}")
578
+ lines.extend(["", "## Stores", ""])
579
+ for key, value in (report.get("stores") or {}).items():
580
+ lines.append(f"- `{key}`: {value}")
581
+ lines.extend(["", "## Findings", ""])
582
+ items = report.get("items") or []
583
+ if items:
584
+ lines.extend(["### By kind", ""])
585
+ for kind, count in sorted(Counter(str(item.get("kind")) for item in items).items()):
586
+ lines.append(f"- `{kind}`: {count}")
587
+ lines.extend(["", "### Details", ""])
588
+ if not items:
589
+ lines.append("No discoverability findings.")
590
+ else:
591
+ for item in items:
592
+ lines.append(
593
+ f"- `{item.get('kind')}` `{item.get('session_id')}` "
594
+ f"messages={item.get('message_count', 'n/a')} recommendation=`{item.get('recommendation')}`"
595
+ )
596
+ present = item.get("present_in")
597
+ if isinstance(present, dict):
598
+ lines.append(
599
+ " - present_in: " + ", ".join(f"{k}={v}" for k, v in sorted(present.items()))
600
+ )
601
+ if item.get("represented_by_api_lineage"):
602
+ lines.append(
603
+ f" - represented_by_api_lineage: true via `{item.get('api_representative_session_id')}`"
604
+ )
605
+ lines.append("")
606
+ return "\n".join(lines)
607
+
608
+
609
+ def _main() -> int:
610
+ parser = argparse.ArgumentParser(description="Read-only Hermes WebUI session discoverability audit")
611
+ parser.add_argument("--session-dir", type=Path, required=True)
612
+ parser.add_argument("--state-db", type=Path, default=None)
613
+ parser.add_argument("--format", choices=("json", "markdown"), default="json")
614
+ parser.add_argument("--repair-safe", action="store_true", help="Plan/apply deterministic discoverability repairs")
615
+ parser.add_argument("--apply", action="store_true", help="Apply --repair-safe changes; default is dry-run")
616
+ parser.add_argument("--backup-dir", type=Path, default=None, help="Required with --repair-safe --apply")
617
+ parser.add_argument("--out", type=Path, default=None)
618
+ args = parser.parse_args()
619
+
620
+ if args.repair_safe:
621
+ report = repair_session_discoverability(
622
+ args.session_dir,
623
+ state_db_path=args.state_db,
624
+ dry_run=not args.apply,
625
+ backup_dir=args.backup_dir,
626
+ )
627
+ text = json.dumps(report, sort_keys=True)
628
+ else:
629
+ report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db)
630
+ text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True)
631
+ if args.out:
632
+ args.out.parent.mkdir(parents=True, exist_ok=True)
633
+ args.out.write_text(text, encoding="utf-8")
634
+ else:
635
+ print(text)
636
+ return 0
637
+
638
+
639
+ if __name__ == "__main__":
640
+ raise SystemExit(_main())
@@ -0,0 +1,45 @@
1
+ """Lightweight in-process invalidation events for session sidebar state."""
2
+
3
+ import queue
4
+ import threading
5
+
6
+ _SESSION_EVENTS_LOCK = threading.Lock()
7
+ _SESSION_EVENTS_SUBSCRIBERS: set[queue.Queue] = set()
8
+ _SESSION_EVENTS_VERSION = 0
9
+
10
+
11
+ def publish_session_list_changed(reason: str = "session_changed") -> None:
12
+ """Notify connected browsers that the session sidebar may be stale."""
13
+ global _SESSION_EVENTS_VERSION
14
+ with _SESSION_EVENTS_LOCK:
15
+ _SESSION_EVENTS_VERSION += 1
16
+ payload = {
17
+ "type": "sessions_changed",
18
+ "version": _SESSION_EVENTS_VERSION,
19
+ "reason": reason,
20
+ }
21
+ subscribers = list(_SESSION_EVENTS_SUBSCRIBERS)
22
+ for q in subscribers:
23
+ try:
24
+ q.put_nowait(payload)
25
+ except queue.Full:
26
+ try:
27
+ q.get_nowait()
28
+ except queue.Empty:
29
+ pass
30
+ try:
31
+ q.put_nowait(payload)
32
+ except queue.Full:
33
+ pass
34
+
35
+
36
+ def subscribe_session_events() -> queue.Queue:
37
+ q: queue.Queue = queue.Queue(maxsize=1)
38
+ with _SESSION_EVENTS_LOCK:
39
+ _SESSION_EVENTS_SUBSCRIBERS.add(q)
40
+ return q
41
+
42
+
43
+ def unsubscribe_session_events(q: queue.Queue) -> None:
44
+ with _SESSION_EVENTS_LOCK:
45
+ _SESSION_EVENTS_SUBSCRIBERS.discard(q)