@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
package/ui/serve.py CHANGED
@@ -1,806 +1,78 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Aesop Web Dashboard — stdlib-only local observability.
4
- Serves a dark-theme HTML dashboard on a configurable port (default 8770).
5
- No external dependencies. Auto-refresh every 3s via fetch('/data') → JSON.
6
-
7
- Configuration:
8
- - AESOP_ROOT: env var pointing to aesop installation (default: $HOME/aesop)
9
- - aesop.config.json: optional config file with paths and settings
10
- - PORT env var: override dashboard port (default: 8770)
11
- - AESOP_TRANSCRIPTS_ROOT: env var for Claude transcript directory
3
+ Aesop Web Dashboard — stdlib-only local observability (thin entry point).
4
+
5
+ Serves a dark-theme HTML dashboard on a configurable port (default 8770) with
6
+ realtime updates via GET /events (Server-Sent Events). No external dependencies.
7
+
8
+ Wave-9 split: this module is now a thin composition layer. The implementation
9
+ lives in focused siblings, each of which reads config.* at call time so config
10
+ values stay live across reloads:
11
+ - config.py — path / env / aesop.config.json resolution (+ reload())
12
+ - csrf.py — session-token generation + request validation
13
+ - collectors.py — read-only data collectors, tracker CRUD, SSE snapshots
14
+ - agents.py — agent transcript reading + path-safe id handling
15
+ - sse.py — SSE client registry, broadcast, background collector loop
16
+ - render.py — dashboard template rendering (templates/dashboard.html)
17
+ - handler.py — DashboardHandler (HTTP routing/endpoints) + run_server
18
+
19
+ serve.py re-exports the sibling symbols so `serve.X` keeps resolving for the
20
+ existing test suite (which loads this file by path and pokes its module globals)
21
+ and for `python ui/serve.py` as the unchanged entry point.
22
+
23
+ Configuration, CSRF, and SSE details are documented in ui/CLAUDE.md.
12
24
  """
13
- import http.server
14
- import json
15
25
  import os
16
- import re
17
- import subprocess
18
26
  import sys
19
- import urllib.parse
20
- from datetime import datetime
21
- from pathlib import Path
22
- from time import time
23
-
24
-
25
- # ==============================================================================
26
- # Configuration & Paths
27
- # ==============================================================================
28
-
29
- PORT = int(os.getenv("PORT", "8770"))
30
-
31
- # Determine AESOP_ROOT
32
- AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
33
-
34
- # Try to load config file for additional settings
35
- CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
36
- config = {}
37
- if CONFIG_FILE.exists():
38
- try:
39
- with open(CONFIG_FILE) as f:
40
- config = json.load(f)
41
- except:
42
- pass
43
-
44
- # Derive paths from config or defaults
45
- STATE_DIR = Path(config.get("state_root", str(AESOP_ROOT / "state")))
46
- SCAN_DIR = Path(config.get("scan_root", str(AESOP_ROOT / "scan")))
47
-
48
- # Transcript path: configurable via env var or config
49
- # Default placeholder: $BRAIN_ROOT/projects/<project-name> (user fills in)
50
- TRANSCRIPTS_ROOT = Path(
51
- os.getenv(
52
- "AESOP_TRANSCRIPTS_ROOT",
53
- config.get("transcripts_root", "~/.claude/projects")
54
- )
55
- ).expanduser()
56
-
57
- WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
58
- MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
59
- REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
60
- BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
61
- ALERTS_LOG = SCAN_DIR / "SECURITY-ALERTS.log"
62
- INBOX_FILE = STATE_DIR / "ui-inbox.md"
63
-
64
-
65
- # ==============================================================================
66
- # Data Collection Functions
67
- # ==============================================================================
68
-
69
- def get_heartbeat_status():
70
- """Read daemon heartbeat age and status."""
71
- try:
72
- if not WATCHDOG_HEARTBEAT.exists():
73
- return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
74
- content = WATCHDOG_HEARTBEAT.read_text().strip()
75
- if not content:
76
- return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
77
- # Parse epoch value robustly; assume seconds (standard epoch format)
78
- try:
79
- timestamp = int(content)
80
- except ValueError:
81
- # Retry once in case of race during daemon write
82
- try:
83
- content = WATCHDOG_HEARTBEAT.read_text().strip()
84
- timestamp = int(content)
85
- except:
86
- return {"alive": "unknown", "age": -1, "threshold": 300}
87
- # Age in seconds: now_seconds - heartbeat_seconds
88
- age_seconds = int(time()) - timestamp
89
- alive = "ALIVE" if age_seconds < 300 else "STALE"
90
- return {"alive": alive, "age": age_seconds, "threshold": 300}
91
- except:
92
- return {"alive": "unknown", "age": -1, "threshold": 300}
93
-
94
-
95
- def get_monitor_heartbeat_status():
96
- """Read orchestration monitor heartbeat age and status."""
97
- try:
98
- # Check both possible paths: state/.monitor-heartbeat and monitor/.monitor-heartbeat
99
- monitor_hb = MONITOR_HEARTBEAT
100
- if not monitor_hb.exists():
101
- # Try alternate path
102
- alt_path = AESOP_ROOT / "monitor" / ".monitor-heartbeat"
103
- if not alt_path.exists():
104
- return {"alive": "not running", "age": -1, "threshold": 3600}
105
- monitor_hb = alt_path
106
-
107
- content = monitor_hb.read_text().strip()
108
- if not content:
109
- return {"alive": "not running", "age": -1, "threshold": 3600}
110
- # Parse epoch value robustly; assume seconds (standard epoch format)
111
- try:
112
- timestamp = int(content)
113
- except ValueError:
114
- # Retry once in case of race during monitor write
115
- try:
116
- content = monitor_hb.read_text().strip()
117
- timestamp = int(content)
118
- except:
119
- return {"alive": "unknown", "age": -1, "threshold": 3600}
120
- # Age in seconds: now_seconds - heartbeat_seconds
121
- age_seconds = int(time()) - timestamp
122
- alive = "ALIVE" if age_seconds < 3600 else "STALE"
123
- return {"alive": alive, "age": age_seconds, "threshold": 3600}
124
- except:
125
- return {"alive": "unknown", "age": -1, "threshold": 3600}
126
-
127
-
128
- def get_fleet_agents():
129
- """Detect running subagents by calling dash-extra.mjs --json."""
130
- agents = []
131
- try:
132
- # Call the working detector (dash-extra.mjs) with --json flag
133
- dash_extra_path = AESOP_ROOT / "dash" / "dash-extra.mjs"
134
- if not dash_extra_path.exists():
135
- return agents
136
- result = subprocess.run(
137
- ["node", str(dash_extra_path), "--json"],
138
- capture_output=True,
139
- text=True,
140
- timeout=5
141
- )
142
- if result.returncode == 0 and result.stdout:
143
- agents = json.loads(result.stdout.strip())
144
- except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
145
- pass
146
- except Exception:
147
- pass
148
- return agents
149
-
150
-
151
- def get_main_thread_messages():
152
- """Read last ~12 messages from newest session JSONL."""
153
- messages = []
154
- try:
155
- if not TRANSCRIPTS_ROOT.exists():
156
- return messages
157
- # Find newest .jsonl
158
- jsonl_files = sorted(
159
- TRANSCRIPTS_ROOT.glob("**/*.jsonl"),
160
- key=lambda p: p.stat().st_mtime,
161
- reverse=True
162
- )
163
- if not jsonl_files:
164
- return messages
165
-
166
- newest = jsonl_files[0]
167
- with open(newest, 'r', encoding='utf-8', errors='ignore') as f:
168
- lines = f.readlines()
169
- # Get last 30 lines to extract ~12 message turns
170
- for line in lines[-30:]:
171
- try:
172
- obj = json.loads(line)
173
- role = obj.get("role", "unknown")
174
- if role in ("user", "assistant"):
175
- # Extract text content
176
- content = obj.get("content", [])
177
- text = ""
178
- if isinstance(content, list):
179
- for block in content:
180
- if isinstance(block, dict) and "text" in block:
181
- text = block["text"]
182
- break
183
- elif isinstance(content, str):
184
- text = content
185
-
186
- if text:
187
- # Truncate to 200 chars and sanitize
188
- preview = text[:200].replace("\n", " ").strip()
189
- timestamp = obj.get("timestamp", "")
190
- messages.append({
191
- "role": role,
192
- "text": preview,
193
- "timestamp": timestamp
194
- })
195
- except (json.JSONDecodeError, KeyError):
196
- pass
197
- # Keep only last 12
198
- messages = messages[-12:]
199
- except:
200
- pass
201
- return messages
202
-
203
-
204
- def get_repos_status():
205
- """Read repos from .watchdog-repos.json."""
206
- repos = []
207
- try:
208
- if not REPOS_JSON.exists():
209
- return repos
210
- data = json.loads(REPOS_JSON.read_text())
211
- if isinstance(data, list):
212
- repos = data[:10] # Limit to 10
213
- elif isinstance(data, dict):
214
- repos = [{"repo": k, "state": v} for k, v in data.items()][:10]
215
- except:
216
- pass
217
- return repos
218
-
219
-
220
- def get_recent_events():
221
- """Read last 8 lines from FLEET-BACKUP.log."""
222
- events = []
223
- try:
224
- if not BACKUP_LOG.exists():
225
- return events
226
- lines = BACKUP_LOG.read_text().strip().split('\n')
227
- events = [line.strip() for line in lines[-8:] if line.strip()]
228
- except:
229
- pass
230
- return events
231
-
232
-
233
- def get_alerts():
234
- """Read SECURITY-ALERTS.log, skip NOTE:/RESOLVED-FP, count by severity."""
235
- alerts = {"count": 0, "lines": []}
236
- try:
237
- if not ALERTS_LOG.exists():
238
- return alerts
239
- lines = ALERTS_LOG.read_text().strip().split('\n')
240
- unreviewed = [
241
- line.strip() for line in lines
242
- if line.strip()
243
- and "NOTE:" not in line
244
- and "RESOLVED-FP" not in line
245
- ]
246
- alerts["count"] = len(unreviewed)
247
- alerts["lines"] = unreviewed[-5:] # Show last 5
248
- except:
249
- pass
250
- return alerts
251
-
252
-
253
- def extract_agent_dispatch_prompt(agent_id):
254
- """
255
- Extract dispatch prompt and metadata from agent output file.
256
- Returns dict with prompt, dispatcher, model, activity times, and message count.
257
- Robust: missing/invalid file -> {error: "..."}
258
-
259
- CRITICAL: Use prefix-matching via glob, not exact match. The dashboard supplies
260
- truncated agent IDs; files on disk carry full IDs (e.g., a77b995bcdb953e9c.output).
261
- """
262
- try:
263
- # Prefix-match: search in TRANSCRIPTS_ROOT for files matching agent_id*.output
264
- if not TRANSCRIPTS_ROOT.exists():
265
- return {"error": f"transcripts root not found at {TRANSCRIPTS_ROOT}"}
266
-
267
- # Glob for matching files (prefix-match handles truncated IDs)
268
- matches = sorted(
269
- TRANSCRIPTS_ROOT.glob(f"**/{agent_id}*.output"),
270
- key=lambda p: p.stat().st_mtime, reverse=True,
271
- )
272
- if not matches:
273
- return {"error": f"transcript not found for {agent_id}"}
274
- output_file = matches[0]
275
-
276
- dispatch_prompt = None
277
- message_count = 0
278
- model = None
279
- parent_uuid = None
280
- first_seen = None
281
- last_activity = None
282
-
283
- # Parse NDJSON (one JSON per line)
284
- with open(output_file, 'r', encoding='utf-8', errors='ignore') as f:
285
- lines = f.readlines()
286
- message_count = len(lines)
287
-
288
- # Get file mtime for activity time
289
- stat = output_file.stat()
290
- first_seen = int(stat.st_mtime)
291
- last_activity = int(stat.st_mtime)
292
-
293
- # First line should be type="user" with the dispatch prompt
294
- if lines:
295
- try:
296
- first_line = json.loads(lines[0])
297
- if first_line.get('type') == 'user':
298
- msg = first_line.get('message', {})
299
- dispatch_prompt = msg.get('content', '')
300
- parent_uuid = first_line.get('parentUuid')
301
- except (json.JSONDecodeError, KeyError):
302
- pass
303
-
304
- # Scan for model info in assistant messages
305
- for line in lines[1:20]: # Check first ~20 lines
306
- try:
307
- obj = json.loads(line)
308
- if obj.get('type') == 'assistant' and not model:
309
- if 'model' in obj:
310
- model = obj.get('model')
311
- except (json.JSONDecodeError, KeyError):
312
- pass
313
-
314
- if not dispatch_prompt:
315
- return {"error": f"no dispatch prompt found"}
316
-
317
- # Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
318
- dispatcher = "main thread" if parent_uuid is None else "parent agent"
319
-
320
- return {
321
- "id": agent_id,
322
- "dispatch_prompt": dispatch_prompt,
323
- "dispatcher": dispatcher,
324
- "model": model or "unknown",
325
- "message_count": message_count,
326
- "first_seen": first_seen,
327
- "last_activity": last_activity,
328
- }
329
- except Exception as e:
330
- return {"error": str(e)}
331
-
332
-
333
- # ==============================================================================
334
- # HTTP Server
335
- # ==============================================================================
336
-
337
- class DashboardHandler(http.server.BaseHTTPRequestHandler):
338
- """HTTP request handler for dashboard."""
339
-
340
- def log_message(self, format, *args):
341
- """Suppress default logging."""
342
- pass
343
-
344
- def do_GET(self):
345
- """Handle GET requests."""
346
- if self.path == "/":
347
- self.serve_html()
348
- elif self.path == "/data":
349
- self.serve_data()
350
- elif self.path.startswith("/agent?"):
351
- self.serve_agent()
352
- else:
353
- self.send_error(404)
354
-
355
- def do_POST(self):
356
- """Handle POST requests."""
357
- if self.path == "/submit":
358
- self.handle_submit()
359
- else:
360
- self.send_error(404)
361
-
362
- def serve_html(self):
363
- """Serve the dashboard HTML."""
364
- html = """<!DOCTYPE html>
365
- <html lang="en">
366
- <head>
367
- <meta charset="UTF-8">
368
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
369
- <title>Aesop Fleet Dashboard</title>
370
- <style>
371
- * { margin: 0; padding: 0; box-sizing: border-box; }
372
- html { color-scheme: dark; }
373
- body {
374
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Monospace;
375
- background: #0a0a0a;
376
- color: #e0e0e0;
377
- padding: 16px;
378
- line-height: 1.5;
379
- }
380
- .container { max-width: 1600px; margin: 0 auto; }
381
- h1 { font-size: 20px; margin-bottom: 20px; color: #fff; }
382
- h2 { font-size: 14px; margin-top: 20px; margin-bottom: 10px; color: #8ac; font-weight: bold; }
383
-
384
- .header { display: flex; gap: 20px; margin-bottom: 20px; padding: 12px; background: #1a1a1a; border-radius: 4px; }
385
- .header-item { flex: 1; }
386
- .header-label { font-size: 11px; color: #666; text-transform: uppercase; margin-bottom: 4px; }
387
- .header-value { font-size: 14px; color: #fff; font-weight: bold; }
388
- .status-alive { color: #0a0; }
389
- .status-stale { color: #f44; }
390
- .status-unknown { color: #999; }
391
-
392
- .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
393
- @media (max-width: 1200px) { .grid { grid-template-columns: 1fr; } }
394
-
395
- .panel { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; }
396
- .panel-title { font-size: 12px; color: #8ac; font-weight: bold; text-transform: uppercase; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
397
- .panel-title-emoji { font-size: 14px; }
398
-
399
- /* Agent row expand/collapse */
400
- .agent-row { cursor: pointer; padding: 8px; margin-bottom: 4px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px; transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; }
401
- .agent-row:hover { background: #151515; border-color: #444; }
402
- .agent-row.expanded { background: #1a1a1a; border-color: #8ac; }
403
- .agent-status-icon { font-size: 12px; width: 14px; }
404
- .agent-row-header { flex: 1; font-size: 12px; display: flex; gap: 12px; align-items: center; }
405
- .agent-id-badge { color: #8ac; font-weight: bold; }
406
- .agent-age { color: #666; font-size: 11px; }
407
- .agent-preview { color: #999; font-size: 11px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
408
- .agent-expand-toggle { color: #666; font-size: 11px; transition: transform 0.2s ease; }
409
- .agent-row.expanded .agent-expand-toggle { transform: rotate(90deg); }
410
-
411
- .agent-details { display: none; margin-top: 8px; padding: 12px; background: #0f0f0f; border-left: 3px solid #8ac; border-radius: 2px; max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
412
- .agent-row.expanded .agent-details { display: block; max-height: 600px; }
413
- .detail-row { margin-bottom: 8px; font-size: 11px; }
414
- .detail-label { color: #8ac; font-weight: bold; display: inline-block; width: 100px; }
415
- .detail-value { color: #ccc; word-break: break-all; }
416
- .dispatch-prompt { background: #0a0a0a; border: 1px solid #333; border-radius: 2px; padding: 8px; margin-top: 6px; max-height: 300px; overflow-y: auto; font-size: 11px; color: #ccc; font-family: 'Monaco', 'Menlo', monospace; white-space: pre-wrap; word-wrap: break-word; }
417
-
418
- .item { padding: 8px 0; font-size: 12px; border-bottom: 1px solid #2a2a2a; }
419
- .item:last-child { border-bottom: none; }
420
- .item-id { color: #8ac; font-weight: bold; }
421
- .item-age { color: #999; margin: 0 8px; }
422
- .item-status { padding: 2px 6px; border-radius: 2px; font-size: 10px; font-weight: bold; }
423
- .status-running { background: #0a0; color: #000; }
424
- .status-done { background: #666; color: #fff; }
425
-
426
- .inbox-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; margin-bottom: 20px; }
427
- .inbox-label { font-size: 11px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
428
- .inbox-input { width: 100%; padding: 8px; background: #0a0a0a; border: 1px solid #333; color: #e0e0e0; border-radius: 2px; font-size: 12px; font-family: inherit; }
429
- .inbox-input:focus { outline: none; border-color: #8ac; }
430
- .inbox-button { background: #8ac; color: #000; border: none; padding: 8px 16px; border-radius: 2px; margin-top: 8px; cursor: pointer; font-weight: bold; font-size: 12px; }
431
- .inbox-button:hover { background: #9bd; }
432
- .inbox-button:disabled { background: #555; cursor: not-allowed; }
433
- .inbox-status { font-size: 11px; color: #0a0; margin-top: 4px; display: none; }
434
-
435
- .alerts-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; }
436
- .alert-line { font-size: 11px; padding: 4px 0; color: #f44; font-family: monospace; }
437
- .alert-none { color: #666; }
438
-
439
- .messages-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; max-height: 400px; overflow-y: auto; }
440
- .message { padding: 8px 0; border-bottom: 1px solid #2a2a2a; font-size: 11px; }
441
- .message:last-child { border-bottom: none; }
442
- .message-role { color: #8ac; font-weight: bold; }
443
- .message-time { color: #666; font-size: 10px; margin-left: 8px; }
444
- .message-text { color: #ccc; margin-top: 4px; }
445
-
446
- .loading { color: #666; font-style: italic; }
447
- .error { color: #f44; }
448
- .fade-in { animation: fadeIn 0.3s ease-in; }
449
- @keyframes fadeIn { from { opacity: 0.5; } to { opacity: 1; } }
450
- </style>
451
- </head>
452
- <body>
453
- <div class="container">
454
- <h1>Aesop Fleet Dashboard</h1>
455
-
456
- <div class="header" id="header">
457
- <div class="header-item">
458
- <div class="header-label">Watchdog Status</div>
459
- <div class="header-value" id="watchdog-status">
460
- <span id="watchdog-alive" class="status-unknown">—</span>
461
- <span id="watchdog-age" style="color: #999; margin-left: 8px;">—</span>
462
- </div>
463
- </div>
464
- <div class="header-item">
465
- <div class="header-label">Monitor Status</div>
466
- <div class="header-value" id="monitor-status">
467
- <span id="monitor-alive" class="status-unknown">—</span>
468
- <span id="monitor-age" style="color: #999; margin-left: 8px;">—</span>
469
- </div>
470
- </div>
471
- <div class="header-item">
472
- <div class="header-label">Security Alerts</div>
473
- <div class="header-value" id="alert-count" style="color: #999;">—</div>
474
- </div>
475
- <div class="header-item">
476
- <div class="header-label">Running Agents</div>
477
- <div class="header-value" id="running-count" style="color: #999;">—</div>
478
- </div>
479
- </div>
480
-
481
- <div class="inbox-box">
482
- <div class="inbox-label">Queue Work (Read by Orchestrator Each Turn)</div>
483
- <input type="text" class="inbox-input" id="inbox-input" placeholder="Type your task here...">
484
- <button class="inbox-button" id="inbox-button">Send to Inbox</button>
485
- <div class="inbox-status" id="inbox-status">Queued ✓</div>
486
- </div>
487
-
488
- <div class="grid">
489
- <div class="panel">
490
- <div class="panel-title">
491
- <span class="panel-title-emoji">⚡</span>
492
- <span>Fleet Agents (<span id="running-agents-count">0</span> active)</span>
493
- </div>
494
- <div id="agents-list" class="loading">—</div>
495
- </div>
496
-
497
- <div class="panel">
498
- <div class="panel-title">Repos Status</div>
499
- <div id="repos-list" class="loading">—</div>
500
- </div>
501
- </div>
502
-
503
- <div class="grid">
504
- <div class="panel">
505
- <div class="panel-title">Recent Events (Last 8)</div>
506
- <div id="events-list" class="loading">—</div>
507
- </div>
508
-
509
- <div class="alerts-box">
510
- <div class="panel-title">Security Alerts (Unreviewed)</div>
511
- <div id="alerts-list" class="alert-none">—</div>
512
- </div>
513
- </div>
514
-
515
- <div class="panel">
516
- <div class="panel-title">Main-Thread Prompts (Last ~12 Messages)</div>
517
- <div class="messages-box" id="messages-list" class="loading">—</div>
518
- </div>
519
-
520
- <div style="text-align: center; margin-top: 30px; color: #666; font-size: 11px;">
521
- Auto-refresh every 3s · Dashboard · Click agent rows to inspect dispatches
522
- </div>
523
- </div>
524
-
525
- <script>
526
- let lastRefresh = 0;
527
-
528
- function formatTimestamp(iso) {
529
- if (!iso) return '';
530
- const d = new Date(iso);
531
- return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
532
- }
533
-
534
- function sanitize(text) {
535
- const div = document.createElement('div');
536
- div.textContent = text;
537
- return div.innerHTML;
538
- }
539
-
540
- async function refresh() {
541
- try {
542
- const response = await fetch('/data');
543
- if (!response.ok) return;
544
- const data = await response.json();
545
-
546
- // Update header
547
- const watchdog = data.watchdog || {};
548
- const watchdogAlive = document.getElementById('watchdog-alive');
549
- watchdogAlive.textContent = watchdog.alive || '—';
550
- watchdogAlive.className = 'status-' + (watchdog.alive || 'unknown').toLowerCase();
551
- document.getElementById('watchdog-age').textContent = watchdog.age >= 0 ? watchdog.age + 's' : '—';
552
-
553
- const monitor = data.monitor || {};
554
- const monitorAlive = document.getElementById('monitor-alive');
555
- monitorAlive.textContent = monitor.alive || '—';
556
- monitorAlive.className = 'status-' + (monitor.alive || 'unknown').toLowerCase();
557
- document.getElementById('monitor-age').textContent = monitor.age >= 0 ? monitor.age + 's' : '—';
558
-
559
- document.getElementById('alert-count').textContent = data.alerts?.count || 0;
560
- const runningAgents = (data.agents || []).filter(a => a.status === 'running').length;
561
- document.getElementById('running-count').textContent = runningAgents;
562
-
563
- // Agents with expandable details
564
- const agentsList = document.getElementById('agents-list');
565
- document.getElementById('running-agents-count').textContent = (data.agents || []).length;
566
- if (data.agents && data.agents.length > 0) {
567
- agentsList.innerHTML = data.agents.map(a => {
568
- const statusEmoji = a.status === 'running' ? '🟢' : a.status === 'done' ? '⚪' : '⚠️';
569
- const preview = (a.hint || '').substring(0, 60).replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
570
- return `<div class="agent-row fade-in" data-agent-id="${sanitize(a.id)}">
571
- <span class="agent-status-icon">${statusEmoji}</span>
572
- <div class="agent-row-header">
573
- <span class="agent-id-badge">${sanitize(a.id)}</span>
574
- <span class="agent-age">${a.age_s}s</span>
575
- <span class="agent-preview">${preview}</span>
576
- </div>
577
- <span class="agent-expand-toggle">▶</span>
578
- <div class="agent-details" style="display: none;"></div>
579
- </div>`;
580
- }).join('');
581
- // Attach click handlers for expansion
582
- document.querySelectorAll('.agent-row').forEach(row => {
583
- row.addEventListener('click', async function(e) {
584
- e.stopPropagation();
585
- const agentId = this.dataset.agentId;
586
- this.classList.toggle('expanded');
587
- const detailsDiv = this.querySelector('.agent-details');
588
- if (this.classList.contains('expanded') && !detailsDiv.innerHTML) {
589
- // Fetch details on first expand
590
- try {
591
- const resp = await fetch('/agent?id=' + encodeURIComponent(agentId));
592
- const details = await resp.json();
593
- if (details.error) {
594
- detailsDiv.innerHTML = `<div class="detail-row"><span class="detail-label">Error:</span> <span class="detail-value">${sanitize(details.error)}</span></div>`;
595
- } else {
596
- const uptime = Math.floor((Date.now() / 1000 - details.last_activity) / 60);
597
- detailsDiv.innerHTML = `
598
- <div class="detail-row"><span class="detail-label">Dispatcher:</span> <span class="detail-value">${sanitize(details.dispatcher)}</span></div>
599
- <div class="detail-row"><span class="detail-label">Model:</span> <span class="detail-value">${sanitize(details.model)}</span></div>
600
- <div class="detail-row"><span class="detail-label">Messages:</span> <span class="detail-value">${details.message_count}</span></div>
601
- <div class="detail-row"><span class="detail-label">Prompt:</span></div>
602
- <div class="dispatch-prompt">${sanitize(details.dispatch_prompt)}</div>
603
- `;
604
- }
605
- } catch (e) {
606
- detailsDiv.innerHTML = `<div class="detail-row" style="color: #f44;">Failed to fetch details: ${sanitize(e.message)}</div>`;
607
- }
608
- }
609
- });
610
- });
611
- } else {
612
- agentsList.innerHTML = '<div style="color: #666; font-size: 12px;">💤 No active agents — fleet is idle</div>';
613
- }
614
-
615
- // Repos
616
- const reposList = document.getElementById('repos-list');
617
- if (data.repos && data.repos.length > 0) {
618
- reposList.innerHTML = data.repos.map(r => {
619
- const repo = r.repo || Object.keys(r)[0] || 'unknown';
620
- const state = r.state || r[repo] || 'unknown';
621
- return `<div class="item"><span class="item-id">${sanitize(repo.substring(0, 30))}</span> <span style="color: #999;">${sanitize(state)}</span></div>`;
622
- }).join('');
623
- } else {
624
- reposList.textContent = '(no repos)';
625
- reposList.style.color = '#666';
626
- }
627
-
628
- // Events
629
- const eventsList = document.getElementById('events-list');
630
- if (data.events && data.events.length > 0) {
631
- eventsList.innerHTML = data.events.map(e =>
632
- `<div class="item"><span style="color: #999; font-size: 10px;">${sanitize(e.substring(0, 80))}</span></div>`
633
- ).join('');
634
- } else {
635
- eventsList.textContent = '(no recent events)';
636
- eventsList.style.color = '#666';
637
- }
638
-
639
- // Alerts
640
- const alertsList = document.getElementById('alerts-list');
641
- if (data.alerts && data.alerts.lines && data.alerts.lines.length > 0) {
642
- alertsList.innerHTML = data.alerts.lines.map(line =>
643
- `<div class="alert-line">${sanitize(line.substring(0, 120))}</div>`
644
- ).join('');
645
- } else {
646
- alertsList.innerHTML = '<div class="alert-none">(no alerts)</div>';
647
- }
648
-
649
- // Messages
650
- const messagesList = document.getElementById('messages-list');
651
- if (data.messages && data.messages.length > 0) {
652
- messagesList.innerHTML = data.messages.map(m =>
653
- `<div class="message fade-in"><span class="message-role">${sanitize(m.role)}</span><span class="message-time">${formatTimestamp(m.timestamp)}</span><div class="message-text">${sanitize(m.text)}</div></div>`
654
- ).join('');
655
- } else {
656
- messagesList.textContent = '(no messages)';
657
- messagesList.style.color = '#666';
658
- }
659
-
660
- lastRefresh = Date.now();
661
- } catch (e) {
662
- console.error('Refresh error:', e);
663
- }
664
- }
665
-
666
- async function handleInboxSubmit() {
667
- const input = document.getElementById('inbox-input');
668
- const button = document.getElementById('inbox-button');
669
- const status = document.getElementById('inbox-status');
670
-
671
- const text = input.value.trim();
672
- if (!text) return;
673
-
674
- button.disabled = true;
675
- try {
676
- const response = await fetch('/submit', {
677
- method: 'POST',
678
- headers: { 'Content-Type': 'application/json' },
679
- body: JSON.stringify({ text })
680
- });
681
- if (response.ok) {
682
- input.value = '';
683
- status.style.display = 'block';
684
- setTimeout(() => { status.style.display = 'none'; }, 3000);
685
- }
686
- } catch (e) {
687
- console.error('Submit error:', e);
688
- } finally {
689
- button.disabled = false;
690
- }
691
- }
692
-
693
- document.getElementById('inbox-button').addEventListener('click', handleInboxSubmit);
694
- document.getElementById('inbox-input').addEventListener('keypress', (e) => {
695
- if (e.key === 'Enter') handleInboxSubmit();
696
- });
697
-
698
- refresh();
699
- setInterval(refresh, 3000);
700
- </script>
701
- </body>
702
- </html>"""
703
- self.send_response(200)
704
- self.send_header("Content-Type", "text/html; charset=utf-8")
705
- self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
706
- self.end_headers()
707
- self.wfile.write(html.encode('utf-8'))
708
-
709
- def serve_data(self):
710
- """Serve dashboard data as JSON."""
711
- data = {
712
- "watchdog": get_heartbeat_status(),
713
- "monitor": get_monitor_heartbeat_status(),
714
- "agents": get_fleet_agents(),
715
- "repos": get_repos_status(),
716
- "events": get_recent_events(),
717
- "alerts": get_alerts(),
718
- "messages": get_main_thread_messages(),
719
- }
720
- self.send_response(200)
721
- self.send_header("Content-Type", "application/json; charset=utf-8")
722
- self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
723
- self.end_headers()
724
- self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
725
-
726
- def serve_agent(self):
727
- """Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
728
- try:
729
- # Parse query string
730
- query = urllib.parse.urlparse(self.path).query
731
- params = urllib.parse.parse_qs(query)
732
- agent_id = params.get('id', [None])[0]
733
-
734
- if not agent_id:
735
- self.send_response(400)
736
- self.send_header("Content-Type", "application/json")
737
- self.end_headers()
738
- self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
739
- return
740
-
741
- # Extract dispatch prompt and metadata
742
- data = extract_agent_dispatch_prompt(agent_id)
743
-
744
- self.send_response(200)
745
- self.send_header("Content-Type", "application/json; charset=utf-8")
746
- self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
747
- self.end_headers()
748
- self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
749
- except Exception as e:
750
- self.send_response(500)
751
- self.send_header("Content-Type", "application/json")
752
- self.end_headers()
753
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
754
-
755
- def handle_submit(self):
756
- """Handle /submit POST."""
757
- try:
758
- content_length = int(self.headers.get('Content-Length', 0))
759
- if content_length > 10000: # 10KB limit
760
- self.send_error(413)
761
- return
762
-
763
- body = self.rfile.read(content_length).decode('utf-8', errors='ignore')
764
- data = json.loads(body)
765
- text = data.get("text", "").strip()
766
-
767
- if not text:
768
- self.send_response(400)
769
- self.end_headers()
770
- return
771
-
772
- # Append to inbox
773
- inbox_content = f"- [{datetime.now().isoformat()}] {text}\n"
774
- if not INBOX_FILE.exists():
775
- INBOX_FILE.parent.mkdir(parents=True, exist_ok=True)
776
- INBOX_FILE.write_text("# UI Inbox — orchestrator reads each turn / on /power\n\n")
777
-
778
- with open(INBOX_FILE, 'a', encoding='utf-8') as f:
779
- f.write(inbox_content)
780
-
781
- self.send_response(200)
782
- self.send_header("Content-Type", "application/json")
783
- self.end_headers()
784
- self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
785
- except Exception as e:
786
- self.send_response(500)
787
- self.end_headers()
788
-
789
27
 
790
- def run_server():
791
- """Start the HTTP server."""
792
- addr = ("127.0.0.1", PORT)
793
- httpd = http.server.HTTPServer(addr, DashboardHandler)
794
- print(f"Dashboard: http://localhost:{PORT}")
795
- print(f"AESOP_ROOT: {AESOP_ROOT}")
796
- print(f"Transcripts: {TRANSCRIPTS_ROOT}")
797
- print(f"Press Ctrl-C to stop")
798
- try:
799
- httpd.serve_forever()
800
- except KeyboardInterrupt:
801
- print("\nShutdown complete.")
802
- sys.exit(0)
28
+ # Sys.path shim: add ui/ so sibling imports resolve both when this file is run
29
+ # as `python ui/serve.py` and when it is loaded by path via importlib (tests).
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+
32
+ import config
33
+ config.reload()
34
+
35
+ import csrf
36
+ csrf.init()
37
+
38
+ import render
39
+ import collectors
40
+ import agents
41
+ import sse
42
+
43
+ # Fresh collector/snapshot state per serve import (restores the per-import
44
+ # isolation the monolith had; the sse module object is cached across re-imports).
45
+ sse.reset_state()
46
+
47
+ # Re-export sibling symbols so serve.X keeps resolving for tests + the handler.
48
+ # For config symbols, use __getattr__ to ensure they stay live through config.reload()
49
+ from csrf import *
50
+ from render import render_dashboard
51
+ from collectors import *
52
+ from agents import *
53
+ from sse import *
54
+ from collectors import (_snapshot_data, _snapshot_tracker,
55
+ _snapshot_orchestrator_status, drain_tracker_inbox)
56
+ from agents import _AGENT_ID_FORBIDDEN, _transcripts_fingerprint
57
+ from sse import (_sse_lock, _sse_clients, _latest_lock, _latest_snapshots,
58
+ _collector_lock, _collector_stop_event, _maybe_emit)
59
+
60
+ # HTTP handler + server entry.
61
+ import handler
62
+ from handler import DashboardHandler, run_server
63
+
64
+
65
+ def __getattr__(name):
66
+ """Forward config symbols to the live config module so they stay current
67
+ through config.reload() calls. This prevents the frozen `from config import *`
68
+ pattern which would go stale after any reload."""
69
+ import config
70
+ if hasattr(config, name):
71
+ return getattr(config, name)
72
+ raise AttributeError(f"module 'serve' has no attribute '{name}'")
803
73
 
804
74
 
805
75
  if __name__ == "__main__":
76
+ print(f"Aesop Dashboard: http://127.0.0.1:{config.PORT}", flush=True)
77
+ print(f"Press Ctrl-C to stop", flush=True)
806
78
  run_server()