@matt82198/aesop 0.1.0-beta.4 → 0.1.0-rc.1

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 (134) hide show
  1. package/CHANGELOG.md +187 -5
  2. package/LICENSE +66 -21
  3. package/README.md +143 -23
  4. package/aesop.config.example.json +14 -1
  5. package/bin/CLAUDE.md +42 -5
  6. package/bin/cli.js +479 -95
  7. package/daemons/CLAUDE.md +5 -2
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +211 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/RELEASING.md +159 -0
  19. package/docs/archive/README.md +3 -0
  20. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  21. package/docs/autonomous-swe.md +149 -0
  22. package/docs/case-study-portfolio.md +61 -0
  23. package/docs/reproduce.md +121 -0
  24. package/docs/self-stats-data.json +11 -0
  25. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  26. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  27. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  28. package/hooks/CLAUDE.md +51 -0
  29. package/hooks/install-waveguard.sh +68 -0
  30. package/hooks/pre-commit-waveguard.sh +26 -0
  31. package/hooks/pre-push-policy.sh +253 -15
  32. package/mcp/CLAUDE.md +213 -0
  33. package/mcp/package.json +26 -0
  34. package/mcp/server.mjs +543 -0
  35. package/monitor/CHARTER.md +76 -110
  36. package/monitor/CLAUDE.md +3 -3
  37. package/monitor/collect-signals.mjs +218 -20
  38. package/package.json +17 -6
  39. package/scan/CLAUDE.md +30 -0
  40. package/scan/fleet-scan.example.mjs +292 -0
  41. package/skills/CLAUDE.md +1 -0
  42. package/skills/healthcheck/SKILL.md +44 -0
  43. package/state_store/CLAUDE.md +39 -0
  44. package/state_store/__init__.py +24 -0
  45. package/state_store/api.py +35 -0
  46. package/state_store/export.py +19 -0
  47. package/state_store/ingest.py +41 -0
  48. package/state_store/projections.py +126 -0
  49. package/state_store/store.py +197 -0
  50. package/tools/CLAUDE.md +37 -149
  51. package/tools/alert_bridge.py +453 -0
  52. package/tools/bench_runner.py +438 -0
  53. package/tools/buildlog.py +4 -7
  54. package/tools/ci_merge_wait.py +308 -14
  55. package/tools/common.py +62 -0
  56. package/tools/cost_ceiling.py +191 -0
  57. package/tools/dash.js +102 -0
  58. package/tools/doctor.js +220 -0
  59. package/tools/fleet_ledger.py +189 -17
  60. package/tools/fleet_prompt_extractor.py +134 -0
  61. package/tools/halt.py +172 -0
  62. package/tools/healthcheck.py +299 -0
  63. package/tools/heartbeat.py +4 -7
  64. package/tools/metrics_gate.py +8 -2
  65. package/tools/orchestrator_status.py +4 -9
  66. package/tools/reconcile.py +277 -0
  67. package/tools/rotate_logs.py +152 -62
  68. package/tools/scanner_selftest.py +28 -3
  69. package/tools/secret_scan.py +367 -91
  70. package/tools/self_stats.py +797 -0
  71. package/tools/session_usage_summary.py +198 -0
  72. package/tools/status.js +187 -0
  73. package/tools/svg_to_png.mjs +50 -0
  74. package/tools/transcript_replay.py +236 -0
  75. package/tools/transcript_timeline.py +184 -0
  76. package/tools/verify_agent_inspector.py +289 -0
  77. package/tools/verify_dash.py +361 -542
  78. package/tools/verify_prboard.py +344 -0
  79. package/tools/verify_submit_encoding.py +12 -4
  80. package/tools/watch.js +49 -0
  81. package/ui/CLAUDE.md +59 -41
  82. package/ui/agents.py +337 -34
  83. package/ui/api/submit.py +44 -5
  84. package/ui/api/tracker.py +15 -7
  85. package/ui/collectors.py +294 -52
  86. package/ui/config.py +31 -2
  87. package/ui/cost.py +355 -0
  88. package/ui/csrf.py +9 -4
  89. package/ui/handler.py +450 -12
  90. package/ui/render.py +26 -6
  91. package/ui/sse.py +114 -13
  92. package/ui/wave_prs.py +230 -0
  93. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  94. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  95. package/ui/web/dist/index.html +14 -0
  96. package/monitor/.signal-state.json +0 -3
  97. package/monitor/ACTIONS.log +0 -1
  98. package/monitor/BRIEF.md +0 -24
  99. package/monitor/SIGNALS.json +0 -54
  100. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  104. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  105. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  106. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  107. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  108. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  109. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  110. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  111. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  112. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  113. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  114. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  115. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  116. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  117. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  118. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  119. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  120. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  121. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  122. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  123. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  124. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  125. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  126. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  127. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  128. package/ui/templates/dashboard.html +0 -1202
  129. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  130. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  131. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  132. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  133. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  134. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/ui/handler.py CHANGED
@@ -3,14 +3,17 @@
3
3
  import http.server
4
4
  import json
5
5
  import queue
6
+ import socketserver
6
7
  import sys
7
8
  import threading
8
9
  import urllib.parse
9
10
  from pathlib import Path
10
11
 
11
12
  import config
13
+ import cost
12
14
  import csrf
13
15
  import sse
16
+ import wave_prs
14
17
  import api
15
18
  import api.tracker
16
19
  import api.submit
@@ -23,11 +26,145 @@ from collectors import (_snapshot_data, _snapshot_tracker,
23
26
  get_recent_events, get_repos_status,
24
27
  parse_audit_backlog)
25
28
  from agents import (_AGENT_ID_FORBIDDEN, _transcripts_fingerprint,
26
- extract_agent_dispatch_prompt, get_fleet_agents)
29
+ extract_agent_dispatch_prompt, get_agent_detail,
30
+ get_fleet_agents)
27
31
  from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
28
32
  register_sse_client, unregister_sse_client)
29
33
 
30
34
 
35
+ # SSE section names, in emit order. /api/state returns the same sections so the
36
+ # frontend's first paint is one round trip (plan D3.1).
37
+ _STATE_SECTIONS = ("data", "backlog", "agents", "tracker", "status", "cost")
38
+
39
+
40
+ def _path_is_contained(child, root):
41
+ """Check if child path is contained within root path (no traversal).
42
+
43
+ Returns True if child is under root, False if it escapes (e.g., via ..).
44
+ Uses Path.is_relative_to (Python 3.9+) with a fallback for older runtimes.
45
+
46
+ Args:
47
+ child: Path object (typically resolved)
48
+ root: Path object (typically resolved)
49
+
50
+ Returns:
51
+ bool: True if child is contained within root, False otherwise
52
+ """
53
+ try:
54
+ return child.is_relative_to(root.resolve())
55
+ except AttributeError:
56
+ # Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
57
+ try:
58
+ child.relative_to(root.resolve())
59
+ return True
60
+ except ValueError:
61
+ return False
62
+
63
+ # MIME types for the built dist's content-hashed assets (wave-14, plan D3.4).
64
+ _ASSET_MIME = {
65
+ ".js": "text/javascript; charset=utf-8",
66
+ ".mjs": "text/javascript; charset=utf-8",
67
+ ".css": "text/css; charset=utf-8",
68
+ ".svg": "image/svg+xml",
69
+ ".map": "application/json; charset=utf-8",
70
+ ".json": "application/json; charset=utf-8",
71
+ ".woff2": "font/woff2",
72
+ ".woff": "font/woff",
73
+ ".png": "image/png",
74
+ ".ico": "image/x-icon",
75
+ }
76
+
77
+
78
+ def _is_local_origin(origin):
79
+ """True if an Origin header value is on the local allowlist.
80
+
81
+ Keep in sync with csrf.validate_csrf_request(): same six local forms
82
+ (http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>).
83
+ Both http:// and https:// schemes are accepted for the same loopback hosts.
84
+ """
85
+ return bool(origin) and (
86
+ origin.startswith("http://127.0.0.1:") or
87
+ origin.startswith("https://127.0.0.1:") or
88
+ origin.startswith("http://localhost:") or
89
+ origin.startswith("https://localhost:") or
90
+ origin.startswith("http://[::1]:") or
91
+ origin.startswith("https://[::1]:")
92
+ )
93
+
94
+
95
+ def _is_valid_host_header(host_header, expected_port):
96
+ """True if Host header value is on the loopback allowlist.
97
+
98
+ DNS-rebinding mitigation (wave-19): validates that the Host header
99
+ matches one of the allowed loopback addresses with the correct port.
100
+
101
+ Allowed forms (where port matches expected_port):
102
+ - 127.0.0.1 (no port, implies http default)
103
+ - 127.0.0.1:<port>
104
+ - localhost (no port)
105
+ - localhost:<port>
106
+ - [::1] (IPv6 loopback, no port)
107
+ - [::1]:<port>
108
+
109
+ Args:
110
+ host_header: The value of the Host header (may be None/empty)
111
+ expected_port: the port the server is actually bound to (validate
112
+ against the live socket, not config.PORT — a test or a
113
+ dynamically-assigned ephemeral port can differ from config)
114
+
115
+ Returns:
116
+ bool: True if host is on the local allowlist, False otherwise
117
+ """
118
+ if not host_header:
119
+ return False
120
+
121
+ host_header = host_header.strip()
122
+
123
+ # Extract host and port from the header value
124
+ # Handle IPv6 format [::1]:port
125
+ if host_header.startswith("["):
126
+ # IPv6 format: [::1] or [::1]:port
127
+ if "]" not in host_header:
128
+ return False
129
+ bracket_end = host_header.index("]")
130
+ host_part = host_header[:bracket_end + 1] # Include brackets
131
+ remainder = host_header[bracket_end + 1:]
132
+ if remainder:
133
+ # Port must follow immediately with a colon
134
+ if not remainder.startswith(":"):
135
+ return False
136
+ try:
137
+ port_part = int(remainder[1:])
138
+ except (ValueError, IndexError):
139
+ return False
140
+ else:
141
+ port_part = None
142
+ else:
143
+ # IPv4 or hostname format: 127.0.0.1, 127.0.0.1:port, localhost, localhost:port
144
+ if ":" in host_header:
145
+ host_part, port_str = host_header.rsplit(":", 1)
146
+ try:
147
+ port_part = int(port_str)
148
+ except ValueError:
149
+ return False
150
+ else:
151
+ host_part = host_header
152
+ port_part = None
153
+
154
+ # Check if host is on allowlist (127.0.0.1, localhost, or [::1])
155
+ # RFC 7230: hostnames are case-insensitive, so lowercase before comparing
156
+ allowed_hosts = ("127.0.0.1", "localhost", "[::1]")
157
+ if host_part.lower() not in allowed_hosts:
158
+ return False
159
+
160
+ # If a port was specified, it must match the server's actual bound port
161
+ if port_part is not None:
162
+ if port_part != expected_port:
163
+ return False
164
+
165
+ return True
166
+
167
+
31
168
  class DashboardHandler(http.server.BaseHTTPRequestHandler):
32
169
  """HTTP request handler for dashboard."""
33
170
 
@@ -37,25 +174,64 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
37
174
 
38
175
  def do_GET(self):
39
176
  """Handle GET requests."""
177
+ # DNS-rebinding mitigation: validate Host header against allowlist
178
+ host_header = self.headers.get("Host", "").strip()
179
+ if not _is_valid_host_header(host_header, self.server.server_address[1]):
180
+ self.send_response(403)
181
+ self.send_header("Content-Type", "application/json; charset=utf-8")
182
+ self.end_headers()
183
+ self.wfile.write(json.dumps(
184
+ {"error": "Forbidden: invalid Host header"}
185
+ ).encode('utf-8'))
186
+ return
187
+
40
188
  if self.path == "/":
41
189
  self.serve_html()
42
190
  elif self.path == "/data":
43
191
  self.serve_data()
192
+ elif self.path == "/api/state":
193
+ self.serve_api_state()
194
+ elif self.path == "/api/session":
195
+ self.serve_api_session()
196
+ elif self.path == "/api/cost":
197
+ self.serve_api_cost()
198
+ elif self.path == "/api/wave/prs":
199
+ self.serve_api_wave_prs()
44
200
  elif self.path == "/api/backlog":
45
201
  self.serve_backlog()
46
202
  elif self.path == "/api/agents":
47
203
  self.serve_agents()
204
+ elif self.path.startswith("/api/agent?"):
205
+ self.serve_api_agent()
48
206
  elif self.path.startswith("/api/tracker"):
49
207
  self.serve_tracker()
208
+ elif self.path.startswith("/assets/"):
209
+ self.serve_asset()
50
210
  elif self.path.startswith("/agent?"):
51
211
  self.serve_agent()
52
212
  elif self.path == "/events":
53
213
  self.serve_events()
214
+ elif self.path == "/favicon.ico":
215
+ # Browsers auto-request this; answer 204 so it never 404s (keeps the
216
+ # console clean — the dashboard ships no favicon asset).
217
+ self.send_response(204)
218
+ self.end_headers()
54
219
  else:
55
220
  self.send_error(404)
56
221
 
57
222
  def do_POST(self):
58
223
  """Handle POST requests."""
224
+ # DNS-rebinding mitigation: validate Host header against allowlist
225
+ host_header = self.headers.get("Host", "").strip()
226
+ if not _is_valid_host_header(host_header, self.server.server_address[1]):
227
+ self.send_response(403)
228
+ self.send_header("Content-Type", "application/json; charset=utf-8")
229
+ self.end_headers()
230
+ self.wfile.write(json.dumps(
231
+ {"error": "Forbidden: invalid Host header"}
232
+ ).encode('utf-8'))
233
+ return
234
+
59
235
  if self.path == "/submit":
60
236
  self.handle_submit()
61
237
  elif self.path == "/api/tracker":
@@ -66,14 +242,191 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
66
242
  self.send_error(404)
67
243
 
68
244
  def serve_html(self):
69
- """Serve the dashboard HTML."""
70
- html = render_dashboard(csrf.SESSION_TOKEN)
245
+ """Serve the dashboard HTML.
246
+
247
+ Wave-14 (plan D3.4/D7 U9 cutover): the built frontend at
248
+ config.WEB_DIST/index.html is always required and must be present;
249
+ if missing, return a hard 500 with a clear error (never fall back to
250
+ a legacy template). config.WEB_DIST is read at call time so
251
+ config.reload() keeps working across fixtures.
252
+ """
253
+ dist_index = config.WEB_DIST / "index.html"
254
+ if not dist_index.is_file():
255
+ error_msg = (
256
+ "built dashboard missing — run npm run build in ui/web"
257
+ )
258
+ self.send_response(500)
259
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
260
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
261
+ self.end_headers()
262
+ self.wfile.write(error_msg.encode('utf-8'))
263
+ return
264
+
265
+ html = render_dashboard(csrf.SESSION_TOKEN, template_path=dist_index)
71
266
  self.send_response(200)
72
267
  self.send_header("Content-Type", "text/html; charset=utf-8")
73
268
  self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
74
269
  self.end_headers()
75
270
  self.wfile.write(html.encode('utf-8'))
76
271
 
272
+ def serve_asset(self):
273
+ """GET /assets/* — static files from the built dist (config.WEB_DIST/assets).
274
+
275
+ Security: path-traversal containment via resolve() + is_relative_to,
276
+ mirroring the pattern in agents.py — the resolved candidate must stay
277
+ inside WEB_DIST/assets or the request is refused. URL-encoded (%2e%2e,
278
+ %2f, %5c) and absolute-path inputs are decoded first so the containment
279
+ check sees the real target.
280
+
281
+ Caching: filenames are content-hashed by the Vite build, so responses
282
+ are immutable (Cache-Control: public, max-age=31536000, immutable).
283
+ """
284
+ try:
285
+ assets_root = config.WEB_DIST / "assets"
286
+ if not assets_root.is_dir():
287
+ # No built dist present (pre-cutover main) — nothing to serve.
288
+ self.send_error(404)
289
+ return
290
+
291
+ raw_path = urllib.parse.urlparse(self.path).path
292
+ rel = urllib.parse.unquote(raw_path[len("/assets/"):])
293
+ if not rel or "\x00" in rel:
294
+ self.send_error(404)
295
+ return
296
+
297
+ candidate = (assets_root / rel).resolve()
298
+
299
+ # Containment check: the resolved target must stay inside
300
+ # WEB_DIST/assets. Uses _path_is_contained helper for clarity.
301
+ if not _path_is_contained(candidate, assets_root):
302
+ self.send_error(403)
303
+ return
304
+
305
+ if not candidate.is_file():
306
+ self.send_error(404)
307
+ return
308
+
309
+ content = candidate.read_bytes()
310
+ mime = _ASSET_MIME.get(candidate.suffix.lower(), "application/octet-stream")
311
+ self.send_response(200)
312
+ self.send_header("Content-Type", mime)
313
+ self.send_header("Content-Length", str(len(content)))
314
+ self.send_header("Cache-Control", "public, max-age=31536000, immutable")
315
+ self.end_headers()
316
+ self.wfile.write(content)
317
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
318
+ pass # client went away mid-response — normal, not an error
319
+ except Exception as e:
320
+ print(f"[serve_asset] Uncaught exception: {e}", file=sys.stderr)
321
+ self.send_error(500)
322
+
323
+ def serve_api_state(self):
324
+ """GET /api/state — consolidated snapshot of all six SSE sections.
325
+
326
+ One round trip for the frontend's first paint (plan D3.1). Reuses the
327
+ collectors' latest-snapshot mechanism: sections the background collector
328
+ has already produced are returned as-is; anything not yet snapshotted
329
+ (first-ever request) is computed inline, mirroring serve_events.
330
+ """
331
+ try:
332
+ sse.start_collector_thread()
333
+ with _latest_lock:
334
+ latest = dict(_latest_snapshots)
335
+
336
+ computers = {
337
+ "data": _snapshot_data,
338
+ "backlog": parse_audit_backlog,
339
+ "agents": get_fleet_agents,
340
+ "tracker": _snapshot_tracker,
341
+ "status": _snapshot_orchestrator_status,
342
+ "cost": cost.get_cost_summary,
343
+ }
344
+ state = {}
345
+ for name in _STATE_SECTIONS:
346
+ payload = latest.get(name)
347
+ if payload is not None:
348
+ state[name] = json.loads(payload)
349
+ else:
350
+ state[name] = computers[name]()
351
+
352
+ self.send_response(200)
353
+ self.send_header("Content-Type", "application/json; charset=utf-8")
354
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
355
+ self.end_headers()
356
+ self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
357
+ except Exception as e:
358
+ print(f"[serve_api_state] Uncaught exception: {e}", file=sys.stderr)
359
+ self.send_response(500)
360
+ self.send_header("Content-Type", "application/json; charset=utf-8")
361
+ self.end_headers()
362
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
363
+
364
+ def serve_api_session(self):
365
+ """GET /api/session — the CSRF token for same-origin JS (plan D3.3).
366
+
367
+ Exists so the Vite dev server (which cannot do the sentinel
368
+ substitution) still gets a working token; the built app keeps the
369
+ sentinel-injection path as primary.
370
+
371
+ SECURITY — Origin-checked FAIL-CLOSED: unlike the mutation endpoints
372
+ (where a missing Origin is tolerated because the token itself gates the
373
+ write), this endpoint HANDS OUT the token, so absence of an Origin
374
+ header is a refusal. Only the same local allowlist csrf.py uses
375
+ (127.0.0.1 / localhost / [::1]) is accepted.
376
+ """
377
+ origin = self.headers.get("Origin", "").strip()
378
+ if not _is_local_origin(origin):
379
+ self.send_response(403)
380
+ self.send_header("Content-Type", "application/json; charset=utf-8")
381
+ self.end_headers()
382
+ self.wfile.write(json.dumps(
383
+ {"error": "Forbidden: /api/session requires a local Origin header"}
384
+ ).encode('utf-8'))
385
+ return
386
+
387
+ self.send_response(200)
388
+ self.send_header("Content-Type", "application/json; charset=utf-8")
389
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
390
+ self.end_headers()
391
+ self.wfile.write(json.dumps({"token": csrf.SESSION_TOKEN}).encode('utf-8'))
392
+
393
+ def serve_api_cost(self):
394
+ """GET /api/cost — cost/scorecard summary from the outcomes ledger (plan D3.2)."""
395
+ try:
396
+ summary = cost.get_cost_summary()
397
+ self.send_response(200)
398
+ self.send_header("Content-Type", "application/json; charset=utf-8")
399
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
400
+ self.end_headers()
401
+ self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
402
+ except Exception as e:
403
+ print(f"[serve_api_cost] Uncaught exception: {e}", file=sys.stderr)
404
+ self.send_response(500)
405
+ self.send_header("Content-Type", "application/json; charset=utf-8")
406
+ self.end_headers()
407
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
408
+
409
+ def serve_api_wave_prs(self):
410
+ """GET /api/wave/prs — open PRs + PR-less feat/* branches for the PR board.
411
+
412
+ Read-only; runs `gh pr list` / `git for-each-ref` (short timeout, cached
413
+ a few seconds). Degrades to a well-formed {available:false, error:...}
414
+ payload when gh is missing or un-authenticated — never a 500 for those.
415
+ """
416
+ try:
417
+ payload = wave_prs.get_wave_prs()
418
+ self.send_response(200)
419
+ self.send_header("Content-Type", "application/json; charset=utf-8")
420
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
421
+ self.end_headers()
422
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
423
+ except Exception as e:
424
+ print(f"[serve_api_wave_prs] Uncaught exception: {e}", file=sys.stderr)
425
+ self.send_response(500)
426
+ self.send_header("Content-Type", "application/json; charset=utf-8")
427
+ self.end_headers()
428
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
429
+
77
430
  def serve_data(self):
78
431
  """Serve dashboard data as JSON."""
79
432
  data = {
@@ -107,10 +460,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
107
460
  self.end_headers()
108
461
  self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
109
462
  except Exception as e:
463
+ print(f"[serve_tracker] Uncaught exception: {e}", file=sys.stderr)
110
464
  self.send_response(500)
111
465
  self.send_header("Content-Type", "application/json")
112
466
  self.end_headers()
113
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
467
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
114
468
 
115
469
  def handle_tracker_create(self):
116
470
  """Handle POST /api/tracker (create item)."""
@@ -132,10 +486,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
132
486
  self.end_headers()
133
487
  self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
134
488
  except Exception as e:
489
+ print(f"[handle_tracker_create] Uncaught exception: {e}", file=sys.stderr)
135
490
  self.send_response(500)
136
491
  self.send_header("Content-Type", "application/json; charset=utf-8")
137
492
  self.end_headers()
138
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
493
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
139
494
 
140
495
  def handle_tracker_mutate(self):
141
496
  """Handle POST /api/tracker/<id> (update or delete)."""
@@ -180,10 +535,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
180
535
  self.end_headers()
181
536
  self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
182
537
  except Exception as e:
538
+ print(f"[handle_tracker_mutate] Uncaught exception: {e}", file=sys.stderr)
183
539
  self.send_response(500)
184
540
  self.send_header("Content-Type", "application/json; charset=utf-8")
185
541
  self.end_headers()
186
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
542
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
187
543
 
188
544
 
189
545
  def serve_backlog(self):
@@ -196,10 +552,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
196
552
  self.end_headers()
197
553
  self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
198
554
  except Exception as e:
555
+ print(f"[serve_backlog] Uncaught exception: {e}", file=sys.stderr)
199
556
  self.send_response(500)
200
557
  self.send_header("Content-Type", "application/json; charset=utf-8")
201
558
  self.end_headers()
202
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
559
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
203
560
 
204
561
  def serve_agents(self):
205
562
  """Serve rich agent list with metadata via GET /api/agents."""
@@ -211,10 +568,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
211
568
  self.end_headers()
212
569
  self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
213
570
  except Exception as e:
571
+ print(f"[serve_agents] Uncaught exception: {e}", file=sys.stderr)
214
572
  self.send_response(500)
215
573
  self.send_header("Content-Type", "application/json; charset=utf-8")
216
574
  self.end_headers()
217
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
575
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
218
576
 
219
577
  def serve_agent(self):
220
578
  """Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
@@ -257,6 +615,49 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
257
615
  print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
258
616
  self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
259
617
 
618
+ def serve_api_agent(self):
619
+ """GET /api/agent?id=<agent_id> — agent detail for the Inspector drawer.
620
+
621
+ Read-only. Returns the dispatch prompt/metadata PLUS a bounded, secret-
622
+ redacted transcript tail (last ~40 NDJSON lines, seek-read so a huge
623
+ transcript is never fully loaded). Same id-safety + status-code contract
624
+ as GET /agent: rejected input -> 400, well-formed id with no transcript
625
+ -> 404, unexpected failure -> 500. Never leaks a raw traceback.
626
+ """
627
+ try:
628
+ query = urllib.parse.urlparse(self.path).query
629
+ params = urllib.parse.parse_qs(query)
630
+ agent_id = params.get('id', [None])[0]
631
+
632
+ if not agent_id:
633
+ self.send_response(400)
634
+ self.send_header("Content-Type", "application/json; charset=utf-8")
635
+ self.end_headers()
636
+ self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
637
+ return
638
+
639
+ data = get_agent_detail(agent_id)
640
+
641
+ if "error" in data:
642
+ status = 400 if data.get("invalid") else 404
643
+ self.send_response(status)
644
+ self.send_header("Content-Type", "application/json; charset=utf-8")
645
+ self.end_headers()
646
+ self.wfile.write(json.dumps({"error": data["error"]}).encode('utf-8'))
647
+ return
648
+
649
+ self.send_response(200)
650
+ self.send_header("Content-Type", "application/json; charset=utf-8")
651
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
652
+ self.end_headers()
653
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
654
+ except Exception as e:
655
+ self.send_response(500)
656
+ self.send_header("Content-Type", "application/json; charset=utf-8")
657
+ self.end_headers()
658
+ print(f"[serve_api_agent] Uncaught exception: {e}", file=sys.stderr)
659
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
660
+
260
661
  def _write_sse_event(self, event_name, payload):
261
662
  """Write one SSE frame with timeout. Caller handles disconnect exceptions."""
262
663
  msg = f"event: {event_name}\ndata: {payload}\n\n"
@@ -325,10 +726,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
325
726
  initial["agents"] = json.dumps(get_fleet_agents(), default=str, sort_keys=True)
326
727
  initial["tracker"] = json.dumps(_snapshot_tracker(), default=str, sort_keys=True)
327
728
  initial["status"] = json.dumps(_snapshot_orchestrator_status(), default=str, sort_keys=True)
729
+ initial["cost"] = json.dumps(cost.get_cost_summary(), default=str, sort_keys=True)
328
730
  with _latest_lock:
329
731
  _latest_snapshots.update(initial)
330
732
 
331
- for name in ("data", "backlog", "agents", "tracker", "status"):
733
+ for name in _STATE_SECTIONS:
332
734
  payload = initial.get(name)
333
735
  if payload is not None:
334
736
  self._write_sse_event(name, payload)
@@ -373,7 +775,7 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
373
775
  return
374
776
 
375
777
  body_bytes = self.rfile.read(content_length)
376
- data = json.loads(body_bytes.decode('utf-8', errors='ignore'))
778
+ data = json.loads(body_bytes.decode('utf-8', errors='replace'))
377
779
  text = data.get("text", "").strip()
378
780
 
379
781
  if not text:
@@ -397,10 +799,44 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
397
799
  self.end_headers()
398
800
  self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
399
801
  except Exception as e:
802
+ print(f"[handle_submit] Uncaught exception: {e}", file=sys.stderr)
400
803
  self.send_response(500)
401
804
  self.send_header("Content-Type", "application/json; charset=utf-8")
402
805
  self.end_headers()
403
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
806
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
807
+
808
+
809
+ class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):
810
+ """ThreadingHTTPServer that suppresses expected socket disconnect exceptions.
811
+
812
+ During normal operation and especially during shutdown, ThreadingHTTPServer may
813
+ encounter ConnectionAbortedError (WinError 10053) or ConnectionResetError
814
+ (WinError 10054) when clients disconnect abruptly. These are expected, not errors,
815
+ and clutter stderr with tracebacks.
816
+
817
+ This server overrides handle_error() to suppress only these two exception types
818
+ while still reporting all other exceptions (real bugs, timeouts, etc.).
819
+ """
820
+
821
+ def handle_error(self, request, client_address):
822
+ """Suppress client disconnect exceptions; report all others.
823
+
824
+ Args:
825
+ request: The socket request object
826
+ client_address: The client address tuple
827
+ """
828
+ exc_type, exc_value, exc_tb = sys.exc_info()
829
+
830
+ # Suppress only the two disconnect exception types that occur during
831
+ # normal client aborts (especially on shutdown). All other exceptions
832
+ # (real bugs, timeouts, etc.) still get logged via super().
833
+ if exc_type in (ConnectionAbortedError, ConnectionResetError):
834
+ # Expected client disconnect; silent is correct.
835
+ return
836
+
837
+ # All other exceptions get the default handler (logged to stderr)
838
+ super().handle_error(request, client_address)
839
+
404
840
 
405
841
  def run_server():
406
842
  """Start the HTTP server.
@@ -409,9 +845,11 @@ def run_server():
409
845
  connection open for the life of the client, so a single-threaded server would
410
846
  wedge every other request (including the initial page load and /submit)
411
847
  behind that one held connection.
848
+
849
+ Uses QuietThreadingHTTPServer to suppress expected socket disconnect exceptions.
412
850
  """
413
851
  addr = ("127.0.0.1", config.PORT)
414
- httpd = http.server.ThreadingHTTPServer(addr, DashboardHandler)
852
+ httpd = QuietThreadingHTTPServer(addr, DashboardHandler)
415
853
  httpd.daemon_threads = True
416
854
  sse.start_collector_thread()
417
855
  print(f"Dashboard: http://localhost:{config.PORT}")
package/ui/render.py CHANGED
@@ -2,10 +2,15 @@
2
2
  """
3
3
  Aesop UI template rendering — stdlib-only.
4
4
 
5
- The dashboard HTML/CSS/JS lives in templates/dashboard.html (extracted from
6
- serve.py in the wave-9 split). The only server-side substitution is the
7
- per-session CSRF token, injected via a unique sentinel so the template stays a
8
- plain static file (no .format()/% the CSS/JS is full of { } and % literals).
5
+ The legacy dashboard HTML/CSS/JS lives in templates/dashboard.html (extracted
6
+ from serve.py in the wave-9 split). Wave-14 (dashboard rewrite, plan D3/D7)
7
+ retargets the same mechanism at the built React app: the handler passes
8
+ ui/web/dist/index.html as template_path when a committed dist is present, and
9
+ falls back to the legacy template otherwise. The only server-side substitution
10
+ is the per-session CSRF token, injected via a unique sentinel so the template
11
+ stays a plain static file (no .format()/% — the CSS/JS is full of { } and %
12
+ literals; the Vite build passes the sentinel-carrying inline script through
13
+ verbatim).
9
14
  """
10
15
  import json
11
16
  import os
@@ -17,13 +22,28 @@ _DASHBOARD_HTML = os.path.join(_TEMPLATE_DIR, "dashboard.html")
17
22
  _CSRF_SENTINEL = "__AESOP_CSRF_SENTINEL__"
18
23
 
19
24
 
20
- def render_dashboard(session_token):
25
+ def render_dashboard(session_token, template_path=None):
21
26
  """Return the dashboard HTML with the CSRF token substituted in.
22
27
 
28
+ Args:
29
+ session_token: the per-session CSRF token to inject.
30
+ template_path: path to the template file carrying the CSRF sentinel
31
+ (wave-14 U9 cutover: dist/index.html is always required; no
32
+ fallback to templates/dashboard.html).
33
+
23
34
  Reads the template fresh each call (cheap; keeps edits live in dev). The
24
35
  token is inserted as a JS string literal via json.dumps so it is always a
25
36
  valid, properly-quoted value.
37
+
38
+ Raises:
39
+ TypeError: if template_path is None (legacy fallback removed at U9).
40
+ FileNotFoundError: if the template file does not exist.
26
41
  """
27
- with open(_DASHBOARD_HTML, encoding="utf-8") as f:
42
+ if template_path is None:
43
+ raise TypeError(
44
+ "render_dashboard() requires template_path after wave-14 U9 cutover; "
45
+ "legacy fallback to templates/dashboard.html has been removed"
46
+ )
47
+ with open(template_path, encoding="utf-8") as f:
28
48
  html = f.read()
29
49
  return html.replace(_CSRF_SENTINEL, json.dumps(session_token))