@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/handler.py ADDED
@@ -0,0 +1,676 @@
1
+ #!/usr/bin/env python3
2
+ """Aesop UI — HTTP request handler (DashboardHandler) + server entry (wave-9 split)."""
3
+ import http.server
4
+ import json
5
+ import queue
6
+ import socketserver
7
+ import sys
8
+ import threading
9
+ import urllib.parse
10
+ from pathlib import Path
11
+
12
+ import config
13
+ import cost
14
+ import csrf
15
+ import sse
16
+ import api
17
+ import api.tracker
18
+ import api.submit
19
+ from render import render_dashboard
20
+ from csrf import validate_csrf_request
21
+ from collectors import (_snapshot_data, _snapshot_tracker,
22
+ _snapshot_orchestrator_status, drain_tracker_inbox,
23
+ get_alerts, get_heartbeat_status,
24
+ get_main_thread_messages, get_monitor_heartbeat_status,
25
+ get_recent_events, get_repos_status,
26
+ parse_audit_backlog)
27
+ from agents import (_AGENT_ID_FORBIDDEN, _transcripts_fingerprint,
28
+ extract_agent_dispatch_prompt, get_fleet_agents)
29
+ from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
30
+ register_sse_client, unregister_sse_client)
31
+
32
+
33
+ # SSE section names, in emit order. /api/state returns the same sections so the
34
+ # frontend's first paint is one round trip (plan D3.1).
35
+ _STATE_SECTIONS = ("data", "backlog", "agents", "tracker", "status", "cost")
36
+
37
+ # MIME types for the built dist's content-hashed assets (wave-14, plan D3.4).
38
+ _ASSET_MIME = {
39
+ ".js": "text/javascript; charset=utf-8",
40
+ ".mjs": "text/javascript; charset=utf-8",
41
+ ".css": "text/css; charset=utf-8",
42
+ ".svg": "image/svg+xml",
43
+ ".map": "application/json; charset=utf-8",
44
+ ".json": "application/json; charset=utf-8",
45
+ ".woff2": "font/woff2",
46
+ ".woff": "font/woff",
47
+ ".png": "image/png",
48
+ ".ico": "image/x-icon",
49
+ }
50
+
51
+
52
+ def _is_local_origin(origin):
53
+ """True if an Origin header value is on the local allowlist.
54
+
55
+ Keep in sync with csrf.validate_csrf_request(): same six local forms
56
+ (http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>).
57
+ Both http:// and https:// schemes are accepted for the same loopback hosts.
58
+ """
59
+ return bool(origin) and (
60
+ origin.startswith("http://127.0.0.1:") or
61
+ origin.startswith("https://127.0.0.1:") or
62
+ origin.startswith("http://localhost:") or
63
+ origin.startswith("https://localhost:") or
64
+ origin.startswith("http://[::1]:") or
65
+ origin.startswith("https://[::1]:")
66
+ )
67
+
68
+
69
+ class DashboardHandler(http.server.BaseHTTPRequestHandler):
70
+ """HTTP request handler for dashboard."""
71
+
72
+ def log_message(self, format, *args):
73
+ """Suppress default logging."""
74
+ pass
75
+
76
+ def do_GET(self):
77
+ """Handle GET requests."""
78
+ if self.path == "/":
79
+ self.serve_html()
80
+ elif self.path == "/data":
81
+ self.serve_data()
82
+ elif self.path == "/api/state":
83
+ self.serve_api_state()
84
+ elif self.path == "/api/session":
85
+ self.serve_api_session()
86
+ elif self.path == "/api/cost":
87
+ self.serve_api_cost()
88
+ elif self.path == "/api/backlog":
89
+ self.serve_backlog()
90
+ elif self.path == "/api/agents":
91
+ self.serve_agents()
92
+ elif self.path.startswith("/api/tracker"):
93
+ self.serve_tracker()
94
+ elif self.path.startswith("/assets/"):
95
+ self.serve_asset()
96
+ elif self.path.startswith("/agent?"):
97
+ self.serve_agent()
98
+ elif self.path == "/events":
99
+ self.serve_events()
100
+ elif self.path == "/favicon.ico":
101
+ # Browsers auto-request this; answer 204 so it never 404s (keeps the
102
+ # console clean — the dashboard ships no favicon asset).
103
+ self.send_response(204)
104
+ self.end_headers()
105
+ else:
106
+ self.send_error(404)
107
+
108
+ def do_POST(self):
109
+ """Handle POST requests."""
110
+ if self.path == "/submit":
111
+ self.handle_submit()
112
+ elif self.path == "/api/tracker":
113
+ self.handle_tracker_create()
114
+ elif self.path.startswith("/api/tracker/"):
115
+ self.handle_tracker_mutate()
116
+ else:
117
+ self.send_error(404)
118
+
119
+ def serve_html(self):
120
+ """Serve the dashboard HTML.
121
+
122
+ Wave-14 (plan D3.4/D7 U9 cutover): the built frontend at
123
+ config.WEB_DIST/index.html is always required and must be present;
124
+ if missing, return a hard 500 with a clear error (never fall back to
125
+ a legacy template). config.WEB_DIST is read at call time so
126
+ config.reload() keeps working across fixtures.
127
+ """
128
+ dist_index = config.WEB_DIST / "index.html"
129
+ if not dist_index.is_file():
130
+ error_msg = (
131
+ "built dashboard missing — run npm run build in ui/web"
132
+ )
133
+ self.send_response(500)
134
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
135
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
136
+ self.end_headers()
137
+ self.wfile.write(error_msg.encode('utf-8'))
138
+ return
139
+
140
+ html = render_dashboard(csrf.SESSION_TOKEN, template_path=dist_index)
141
+ self.send_response(200)
142
+ self.send_header("Content-Type", "text/html; charset=utf-8")
143
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
144
+ self.end_headers()
145
+ self.wfile.write(html.encode('utf-8'))
146
+
147
+ def serve_asset(self):
148
+ """GET /assets/* — static files from the built dist (config.WEB_DIST/assets).
149
+
150
+ Security: path-traversal containment via resolve() + is_relative_to,
151
+ mirroring the pattern in agents.py — the resolved candidate must stay
152
+ inside WEB_DIST/assets or the request is refused. URL-encoded (%2e%2e,
153
+ %2f, %5c) and absolute-path inputs are decoded first so the containment
154
+ check sees the real target.
155
+
156
+ Caching: filenames are content-hashed by the Vite build, so responses
157
+ are immutable (Cache-Control: public, max-age=31536000, immutable).
158
+ """
159
+ try:
160
+ assets_root = config.WEB_DIST / "assets"
161
+ if not assets_root.is_dir():
162
+ # No built dist present (pre-cutover main) — nothing to serve.
163
+ self.send_error(404)
164
+ return
165
+
166
+ raw_path = urllib.parse.urlparse(self.path).path
167
+ rel = urllib.parse.unquote(raw_path[len("/assets/"):])
168
+ if not rel or "\x00" in rel:
169
+ self.send_error(404)
170
+ return
171
+
172
+ candidate = (assets_root / rel).resolve()
173
+
174
+ # Containment check: the resolved target must stay inside
175
+ # WEB_DIST/assets. Same belt-and-suspenders shape as agents.py.
176
+ try:
177
+ is_contained = candidate.is_relative_to(assets_root.resolve())
178
+ except AttributeError:
179
+ # Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
180
+ try:
181
+ candidate.relative_to(assets_root.resolve())
182
+ is_contained = True
183
+ except ValueError:
184
+ is_contained = False
185
+ if not is_contained:
186
+ self.send_error(403)
187
+ return
188
+
189
+ if not candidate.is_file():
190
+ self.send_error(404)
191
+ return
192
+
193
+ content = candidate.read_bytes()
194
+ mime = _ASSET_MIME.get(candidate.suffix.lower(), "application/octet-stream")
195
+ self.send_response(200)
196
+ self.send_header("Content-Type", mime)
197
+ self.send_header("Content-Length", str(len(content)))
198
+ self.send_header("Cache-Control", "public, max-age=31536000, immutable")
199
+ self.end_headers()
200
+ self.wfile.write(content)
201
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
202
+ pass # client went away mid-response — normal, not an error
203
+ except Exception as e:
204
+ print(f"[serve_asset] Uncaught exception: {e}", file=sys.stderr)
205
+ self.send_error(500)
206
+
207
+ def serve_api_state(self):
208
+ """GET /api/state — consolidated snapshot of all six SSE sections.
209
+
210
+ One round trip for the frontend's first paint (plan D3.1). Reuses the
211
+ collectors' latest-snapshot mechanism: sections the background collector
212
+ has already produced are returned as-is; anything not yet snapshotted
213
+ (first-ever request) is computed inline, mirroring serve_events.
214
+ """
215
+ try:
216
+ sse.start_collector_thread()
217
+ with _latest_lock:
218
+ latest = dict(_latest_snapshots)
219
+
220
+ computers = {
221
+ "data": _snapshot_data,
222
+ "backlog": parse_audit_backlog,
223
+ "agents": get_fleet_agents,
224
+ "tracker": _snapshot_tracker,
225
+ "status": _snapshot_orchestrator_status,
226
+ "cost": cost.get_cost_summary,
227
+ }
228
+ state = {}
229
+ for name in _STATE_SECTIONS:
230
+ payload = latest.get(name)
231
+ if payload is not None:
232
+ state[name] = json.loads(payload)
233
+ else:
234
+ state[name] = computers[name]()
235
+
236
+ self.send_response(200)
237
+ self.send_header("Content-Type", "application/json; charset=utf-8")
238
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
239
+ self.end_headers()
240
+ self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
241
+ except Exception as e:
242
+ self.send_response(500)
243
+ self.send_header("Content-Type", "application/json; charset=utf-8")
244
+ self.end_headers()
245
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
246
+
247
+ def serve_api_session(self):
248
+ """GET /api/session — the CSRF token for same-origin JS (plan D3.3).
249
+
250
+ Exists so the Vite dev server (which cannot do the sentinel
251
+ substitution) still gets a working token; the built app keeps the
252
+ sentinel-injection path as primary.
253
+
254
+ SECURITY — Origin-checked FAIL-CLOSED: unlike the mutation endpoints
255
+ (where a missing Origin is tolerated because the token itself gates the
256
+ write), this endpoint HANDS OUT the token, so absence of an Origin
257
+ header is a refusal. Only the same local allowlist csrf.py uses
258
+ (127.0.0.1 / localhost / [::1]) is accepted.
259
+ """
260
+ origin = self.headers.get("Origin", "").strip()
261
+ if not _is_local_origin(origin):
262
+ self.send_response(403)
263
+ self.send_header("Content-Type", "application/json; charset=utf-8")
264
+ self.end_headers()
265
+ self.wfile.write(json.dumps(
266
+ {"error": "Forbidden: /api/session requires a local Origin header"}
267
+ ).encode('utf-8'))
268
+ return
269
+
270
+ self.send_response(200)
271
+ self.send_header("Content-Type", "application/json; charset=utf-8")
272
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
273
+ self.end_headers()
274
+ self.wfile.write(json.dumps({"token": csrf.SESSION_TOKEN}).encode('utf-8'))
275
+
276
+ def serve_api_cost(self):
277
+ """GET /api/cost — cost/scorecard summary from the outcomes ledger (plan D3.2)."""
278
+ try:
279
+ summary = cost.get_cost_summary()
280
+ self.send_response(200)
281
+ self.send_header("Content-Type", "application/json; charset=utf-8")
282
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
283
+ self.end_headers()
284
+ self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
285
+ except Exception as e:
286
+ self.send_response(500)
287
+ self.send_header("Content-Type", "application/json; charset=utf-8")
288
+ self.end_headers()
289
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
290
+
291
+ def serve_data(self):
292
+ """Serve dashboard data as JSON."""
293
+ data = {
294
+ "watchdog": get_heartbeat_status(),
295
+ "monitor": get_monitor_heartbeat_status(),
296
+ "agents": get_fleet_agents(),
297
+ "repos": get_repos_status(),
298
+ "events": get_recent_events(),
299
+ "alerts": get_alerts(),
300
+ "messages": get_main_thread_messages(),
301
+ }
302
+ self.send_response(200)
303
+ self.send_header("Content-Type", "application/json; charset=utf-8")
304
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
305
+ self.end_headers()
306
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
307
+
308
+ def serve_tracker(self):
309
+ """Serve tracker items as JSON via GET /api/tracker."""
310
+ try:
311
+ # Parse query string for filters
312
+ query = urllib.parse.urlparse(self.path).query
313
+ params = urllib.parse.parse_qs(query)
314
+ status = params.get('status', [None])[0]
315
+ priority = params.get('priority', [None])[0]
316
+
317
+ status_code, body = api.tracker.list_items(status=status, priority=priority)
318
+ self.send_response(status_code)
319
+ self.send_header("Content-Type", "application/json; charset=utf-8")
320
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
321
+ self.end_headers()
322
+ self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
323
+ except Exception as e:
324
+ self.send_response(500)
325
+ self.send_header("Content-Type", "application/json")
326
+ self.end_headers()
327
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
328
+
329
+ def handle_tracker_create(self):
330
+ """Handle POST /api/tracker (create item)."""
331
+ try:
332
+ is_valid, reason = validate_csrf_request(self.headers)
333
+ if not is_valid:
334
+ self.send_response(403)
335
+ self.send_header("Content-Type", "application/json; charset=utf-8")
336
+ self.end_headers()
337
+ self.wfile.write(json.dumps({"error": "CSRF protection: " + reason}).encode('utf-8'))
338
+ return
339
+
340
+ content_length = int(self.headers.get('Content-Length', 0))
341
+ # Read bounded amount; api.validate_mutation() validates Content-Length
342
+ body_bytes = self.rfile.read(min(max(content_length, 0), api.MAX_BODY_BYTES))
343
+ status_code, result = api.tracker.create(self.headers, body_bytes)
344
+ self.send_response(status_code)
345
+ self.send_header("Content-Type", "application/json; charset=utf-8")
346
+ self.end_headers()
347
+ self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
348
+ except Exception as e:
349
+ self.send_response(500)
350
+ self.send_header("Content-Type", "application/json; charset=utf-8")
351
+ self.end_headers()
352
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
353
+
354
+ def handle_tracker_mutate(self):
355
+ """Handle POST /api/tracker/<id> (update or delete)."""
356
+ try:
357
+ is_valid, reason = validate_csrf_request(self.headers)
358
+ if not is_valid:
359
+ self.send_response(403)
360
+ self.send_header("Content-Type", "application/json; charset=utf-8")
361
+ self.end_headers()
362
+ self.wfile.write(json.dumps({"error": "CSRF protection: " + reason}).encode('utf-8'))
363
+ return
364
+
365
+ # Extract item_id from path
366
+ path_parts = self.path.strip("/").split("/")
367
+ if len(path_parts) < 3:
368
+ self.send_response(404)
369
+ self.send_header("Content-Type", "application/json; charset=utf-8")
370
+ self.end_headers()
371
+ self.wfile.write(json.dumps({"error": "Not found"}).encode('utf-8'))
372
+ return
373
+
374
+ item_id = path_parts[2]
375
+
376
+ # Parse query for action (update or delete)
377
+ query = urllib.parse.urlparse(self.path).query
378
+ params = urllib.parse.parse_qs(query)
379
+ action = params.get('action', ['update'])[0]
380
+
381
+ if action == "delete":
382
+ status_code, result = api.tracker.delete(item_id)
383
+ self.send_response(status_code)
384
+ self.send_header("Content-Type", "application/json; charset=utf-8")
385
+ self.end_headers()
386
+ self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
387
+ else:
388
+ content_length = int(self.headers.get('Content-Length', 0))
389
+ # Read bounded amount; api.validate_mutation() validates Content-Length
390
+ body_bytes = self.rfile.read(min(max(content_length, 0), api.MAX_BODY_BYTES))
391
+ status_code, result = api.tracker.update(item_id, body_bytes)
392
+ self.send_response(status_code)
393
+ self.send_header("Content-Type", "application/json; charset=utf-8")
394
+ self.end_headers()
395
+ self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
396
+ except Exception as e:
397
+ self.send_response(500)
398
+ self.send_header("Content-Type", "application/json; charset=utf-8")
399
+ self.end_headers()
400
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
401
+
402
+
403
+ def serve_backlog(self):
404
+ """Serve audit backlog data as JSON via GET /api/backlog."""
405
+ try:
406
+ data = parse_audit_backlog()
407
+ self.send_response(200)
408
+ self.send_header("Content-Type", "application/json; charset=utf-8")
409
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
410
+ self.end_headers()
411
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
412
+ except Exception as e:
413
+ self.send_response(500)
414
+ self.send_header("Content-Type", "application/json; charset=utf-8")
415
+ self.end_headers()
416
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
417
+
418
+ def serve_agents(self):
419
+ """Serve rich agent list with metadata via GET /api/agents."""
420
+ try:
421
+ agents = get_fleet_agents()
422
+ self.send_response(200)
423
+ self.send_header("Content-Type", "application/json; charset=utf-8")
424
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
425
+ self.end_headers()
426
+ self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
427
+ except Exception as e:
428
+ self.send_response(500)
429
+ self.send_header("Content-Type", "application/json; charset=utf-8")
430
+ self.end_headers()
431
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
432
+
433
+ def serve_agent(self):
434
+ """Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
435
+ try:
436
+ # Parse query string
437
+ query = urllib.parse.urlparse(self.path).query
438
+ params = urllib.parse.parse_qs(query)
439
+ agent_id = params.get('id', [None])[0]
440
+
441
+ if not agent_id:
442
+ self.send_response(400)
443
+ self.send_header("Content-Type", "application/json; charset=utf-8")
444
+ self.end_headers()
445
+ self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
446
+ return
447
+
448
+ # Extract dispatch prompt and metadata
449
+ data = extract_agent_dispatch_prompt(agent_id)
450
+
451
+ if "error" in data:
452
+ # Rejected input (path traversal, glob metacharacters, or a match
453
+ # that resolved outside config.TRANSCRIPTS_ROOT) -> 400. A well-formed id
454
+ # with no matching transcript -> 404. Never 200 on error.
455
+ status = 400 if data.get("invalid") else 404
456
+ self.send_response(status)
457
+ self.send_header("Content-Type", "application/json; charset=utf-8")
458
+ self.end_headers()
459
+ self.wfile.write(json.dumps({"error": data["error"]}).encode('utf-8'))
460
+ return
461
+
462
+ self.send_response(200)
463
+ self.send_header("Content-Type", "application/json; charset=utf-8")
464
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
465
+ self.end_headers()
466
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
467
+ except Exception as e:
468
+ self.send_response(500)
469
+ self.send_header("Content-Type", "application/json; charset=utf-8")
470
+ self.end_headers()
471
+ print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
472
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
473
+
474
+ def _write_sse_event(self, event_name, payload):
475
+ """Write one SSE frame with timeout. Caller handles disconnect exceptions."""
476
+ msg = f"event: {event_name}\ndata: {payload}\n\n"
477
+ # Set socket timeout to prevent stalled writes from blocking the server
478
+ old_timeout = None
479
+ try:
480
+ old_timeout = self.connection.gettimeout()
481
+ self.connection.settimeout(config.SSE_WRITE_TIMEOUT)
482
+ except (AttributeError, OSError):
483
+ pass
484
+ try:
485
+ self.wfile.write(msg.encode("utf-8"))
486
+ self.wfile.flush()
487
+ finally:
488
+ # Restore original timeout
489
+ try:
490
+ if old_timeout is not None:
491
+ self.connection.settimeout(old_timeout)
492
+ except (AttributeError, OSError):
493
+ pass
494
+
495
+ def serve_events(self):
496
+ """GET /events — Server-Sent Events stream.
497
+
498
+ No CSRF token required: this is a read-only stream, not a mutation (POST
499
+ /submit keeps its token requirement unchanged). Holds the connection open
500
+ for the life of the client; requires ThreadingHTTPServer (see run_server)
501
+ so one SSE client can't block every other request.
502
+
503
+ Returns HTTP 503 if concurrent connection cap (config.SSE_MAX_CLIENTS) is exceeded.
504
+ """
505
+ sse.start_collector_thread()
506
+
507
+ q = register_sse_client()
508
+ if q is None:
509
+ # Connection cap exceeded; return 503 Service Unavailable
510
+ try:
511
+ self.send_response(503)
512
+ self.send_header("Content-Type", "text/plain")
513
+ self.send_header("Retry-After", "30")
514
+ self.end_headers()
515
+ self.wfile.write(b"Service overloaded: too many concurrent clients\n")
516
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
517
+ pass
518
+ return
519
+
520
+ try:
521
+ self.send_response(200)
522
+ self.send_header("Content-Type", "text/event-stream")
523
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
524
+ self.send_header("Connection", "keep-alive")
525
+ self.send_header("X-Accel-Buffering", "no")
526
+ self.end_headers()
527
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
528
+ unregister_sse_client(q)
529
+ return
530
+ try:
531
+ # Send an immediate full snapshot so first paint isn't empty. If the
532
+ # collector hasn't produced anything yet (first-ever request), compute
533
+ # it inline once.
534
+ with _latest_lock:
535
+ initial = dict(_latest_snapshots)
536
+ if all(v is None for v in initial.values()):
537
+ initial["data"] = json.dumps(_snapshot_data(), default=str, sort_keys=True)
538
+ initial["backlog"] = json.dumps(parse_audit_backlog(), default=str, sort_keys=True)
539
+ initial["agents"] = json.dumps(get_fleet_agents(), default=str, sort_keys=True)
540
+ initial["tracker"] = json.dumps(_snapshot_tracker(), default=str, sort_keys=True)
541
+ initial["status"] = json.dumps(_snapshot_orchestrator_status(), default=str, sort_keys=True)
542
+ initial["cost"] = json.dumps(cost.get_cost_summary(), default=str, sort_keys=True)
543
+ with _latest_lock:
544
+ _latest_snapshots.update(initial)
545
+
546
+ for name in _STATE_SECTIONS:
547
+ payload = initial.get(name)
548
+ if payload is not None:
549
+ self._write_sse_event(name, payload)
550
+
551
+ while True:
552
+ try:
553
+ event_name, payload = q.get(timeout=config.SSE_KEEPALIVE_SECONDS)
554
+ self._write_sse_event(event_name, payload)
555
+ except queue.Empty:
556
+ self.wfile.write(b": keepalive\n\n")
557
+ self.wfile.flush()
558
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
559
+ # Client disconnected (tab closed, network drop) — normal, not an error.
560
+ pass
561
+ except Exception:
562
+ pass
563
+ finally:
564
+ unregister_sse_client(q)
565
+
566
+ def handle_submit(self):
567
+ """Handle /submit POST with CSRF protection."""
568
+ try:
569
+ # CSRF validation: Check Origin/Referer + X-Aesop-Token
570
+ is_valid, reason = validate_csrf_request(self.headers)
571
+ if not is_valid:
572
+ self.send_response(403)
573
+ self.send_header("Content-Type", "application/json; charset=utf-8")
574
+ self.end_headers()
575
+ self.wfile.write(json.dumps({
576
+ "error": "CSRF protection: " + reason
577
+ }).encode('utf-8'))
578
+ return
579
+
580
+ content_length = int(self.headers.get('Content-Length', 0))
581
+ if content_length <= 0 or content_length > 10000: # 10KB limit, must be positive
582
+ self.send_response(400)
583
+ self.send_header("Content-Type", "application/json; charset=utf-8")
584
+ self.end_headers()
585
+ self.wfile.write(json.dumps({
586
+ "error": "Invalid Content-Length (must be 1-10000 bytes)"
587
+ }).encode('utf-8'))
588
+ return
589
+
590
+ body_bytes = self.rfile.read(content_length)
591
+ data = json.loads(body_bytes.decode('utf-8', errors='ignore'))
592
+ text = data.get("text", "").strip()
593
+
594
+ if not text:
595
+ self.send_response(400)
596
+ self.send_header("Content-Type", "application/json; charset=utf-8")
597
+ self.end_headers()
598
+ self.wfile.write(json.dumps({"error": "No text provided"}).encode('utf-8'))
599
+ return
600
+
601
+ ok, result = api.submit.append_to_inbox(text)
602
+ if not ok:
603
+ status_code, error = result
604
+ self.send_response(status_code)
605
+ self.send_header("Content-Type", "application/json; charset=utf-8")
606
+ self.end_headers()
607
+ self.wfile.write(json.dumps(error).encode('utf-8'))
608
+ return
609
+
610
+ self.send_response(200)
611
+ self.send_header("Content-Type", "application/json; charset=utf-8")
612
+ self.end_headers()
613
+ self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
614
+ except Exception as e:
615
+ self.send_response(500)
616
+ self.send_header("Content-Type", "application/json; charset=utf-8")
617
+ self.end_headers()
618
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
619
+
620
+
621
+ class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):
622
+ """ThreadingHTTPServer that suppresses expected socket disconnect exceptions.
623
+
624
+ During normal operation and especially during shutdown, ThreadingHTTPServer may
625
+ encounter ConnectionAbortedError (WinError 10053) or ConnectionResetError
626
+ (WinError 10054) when clients disconnect abruptly. These are expected, not errors,
627
+ and clutter stderr with tracebacks.
628
+
629
+ This server overrides handle_error() to suppress only these two exception types
630
+ while still reporting all other exceptions (real bugs, timeouts, etc.).
631
+ """
632
+
633
+ def handle_error(self, request, client_address):
634
+ """Suppress client disconnect exceptions; report all others.
635
+
636
+ Args:
637
+ request: The socket request object
638
+ client_address: The client address tuple
639
+ """
640
+ exc_type, exc_value, exc_tb = sys.exc_info()
641
+
642
+ # Suppress only the two disconnect exception types that occur during
643
+ # normal client aborts (especially on shutdown). All other exceptions
644
+ # (real bugs, timeouts, etc.) still get logged via super().
645
+ if exc_type in (ConnectionAbortedError, ConnectionResetError):
646
+ # Expected client disconnect; silent is correct.
647
+ return
648
+
649
+ # All other exceptions get the default handler (logged to stderr)
650
+ super().handle_error(request, client_address)
651
+
652
+
653
+ def run_server():
654
+ """Start the HTTP server.
655
+
656
+ Must be ThreadingHTTPServer, not HTTPServer: GET /events (SSE) holds its
657
+ connection open for the life of the client, so a single-threaded server would
658
+ wedge every other request (including the initial page load and /submit)
659
+ behind that one held connection.
660
+
661
+ Uses QuietThreadingHTTPServer to suppress expected socket disconnect exceptions.
662
+ """
663
+ addr = ("127.0.0.1", config.PORT)
664
+ httpd = QuietThreadingHTTPServer(addr, DashboardHandler)
665
+ httpd.daemon_threads = True
666
+ sse.start_collector_thread()
667
+ print(f"Dashboard: http://localhost:{config.PORT}")
668
+ print(f"config.AESOP_ROOT: {config.AESOP_ROOT}")
669
+ print(f"Transcripts: {config.TRANSCRIPTS_ROOT}")
670
+ print(f"Press Ctrl-C to stop")
671
+ try:
672
+ httpd.serve_forever()
673
+ except KeyboardInterrupt:
674
+ sse._collector_stop_event.set()
675
+ print("\nShutdown complete.")
676
+ sys.exit(0)