@matt82198/aesop 0.1.0 → 0.3.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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/ui/handler.py CHANGED
@@ -12,8 +12,15 @@ from pathlib import Path
12
12
  import config
13
13
  import cost
14
14
  import csrf
15
+ import quality_scorecard
15
16
  import sse
16
17
  import wave_prs
18
+ import wave_telemetry
19
+ import wave_failure
20
+ import wave_dispatch
21
+ import wave_gantt
22
+ import wave_audit_tail
23
+ import wave_reasoning_tail
17
24
  import api
18
25
  import api.tracker
19
26
  import api.submit
@@ -75,6 +82,32 @@ _ASSET_MIME = {
75
82
  }
76
83
 
77
84
 
85
+ def _is_client_disconnect_error(exc):
86
+ """True if exception indicates a normal client disconnect (not an error).
87
+
88
+ When clients close connections mid-stream (tab closed, network drop, etc.),
89
+ we get BrokenPipeError, ConnectionAbortedError, or ConnectionResetError.
90
+ On Windows, rare plain OSError with winerror 10053/10054 may also indicate
91
+ a disconnect. These are normal lifecycle events, not bugs, and should not
92
+ be logged as errors.
93
+
94
+ Args:
95
+ exc: The exception to check
96
+
97
+ Returns:
98
+ bool: True if this is a normal disconnect, False if it's an unexpected error
99
+ """
100
+ if isinstance(exc, (BrokenPipeError, ConnectionAbortedError,
101
+ ConnectionResetError)):
102
+ return True
103
+ if isinstance(exc, OSError):
104
+ # Windows disconnect errors: 10053 (WSAECONNABORTED), 10054 (WSAECONNRESET)
105
+ winerror = getattr(exc, 'winerror', None)
106
+ if winerror in (10053, 10054):
107
+ return True
108
+ return False
109
+
110
+
78
111
  def _is_local_origin(origin):
79
112
  """True if an Origin header value is on the local allowlist.
80
113
 
@@ -197,6 +230,20 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
197
230
  self.serve_api_cost()
198
231
  elif self.path == "/api/wave/prs":
199
232
  self.serve_api_wave_prs()
233
+ elif self.path == "/api/wave/telemetry":
234
+ self.serve_api_wave_telemetry()
235
+ elif self.path == "/api/wave/dispatch":
236
+ self.serve_api_wave_dispatch()
237
+ elif self.path == "/api/wave/gantt":
238
+ self.serve_api_wave_gantt()
239
+ elif self.path == "/api/wave/audit-tail":
240
+ self.serve_api_wave_audit_tail()
241
+ elif self.path == "/api/wave/reasoning-tail":
242
+ self.serve_api_wave_reasoning_tail()
243
+ elif self.path == "/api/wave/quality-scorecards":
244
+ self.serve_api_wave_quality_scorecards()
245
+ elif self.path.startswith("/api/wave/failure"):
246
+ self.serve_api_wave_failure()
200
247
  elif self.path == "/api/backlog":
201
248
  self.serve_backlog()
202
249
  elif self.path == "/api/agents":
@@ -314,10 +361,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
314
361
  self.send_header("Cache-Control", "public, max-age=31536000, immutable")
315
362
  self.end_headers()
316
363
  self.wfile.write(content)
317
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
364
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
318
365
  pass # client went away mid-response — normal, not an error
366
+ except OSError as e:
367
+ if not _is_client_disconnect_error(e):
368
+ print(f"[serve_asset] Uncaught OSError: {e}", file=sys.stderr)
369
+ self.send_error(500)
370
+ # else: silently pass for disconnect errors
319
371
  except Exception as e:
320
- print(f"[serve_asset] Uncaught exception: {e}", file=sys.stderr)
372
+ if not _is_client_disconnect_error(e):
373
+ print(f"[serve_asset] Uncaught exception: {e}", file=sys.stderr)
321
374
  self.send_error(500)
322
375
 
323
376
  def serve_api_state(self):
@@ -355,11 +408,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
355
408
  self.end_headers()
356
409
  self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
357
410
  except Exception as e:
411
+ if _is_client_disconnect_error(e):
412
+ return # client gone: nothing to send or log
358
413
  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'))
414
+ try:
415
+ self.send_response(500)
416
+ self.send_header("Content-Type", "application/json; charset=utf-8")
417
+ self.end_headers()
418
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
419
+ except Exception:
420
+ pass # best-effort error response; client may be gone
363
421
 
364
422
  def serve_api_session(self):
365
423
  """GET /api/session — the CSRF token for same-origin JS (plan D3.3).
@@ -400,11 +458,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
400
458
  self.end_headers()
401
459
  self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
402
460
  except Exception as e:
461
+ if _is_client_disconnect_error(e):
462
+ return # client gone: nothing to send or log
403
463
  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'))
464
+ try:
465
+ self.send_response(500)
466
+ self.send_header("Content-Type", "application/json; charset=utf-8")
467
+ self.end_headers()
468
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
469
+ except Exception:
470
+ pass # best-effort error response; client may be gone
408
471
 
409
472
  def serve_api_wave_prs(self):
410
473
  """GET /api/wave/prs — open PRs + PR-less feat/* branches for the PR board.
@@ -421,28 +484,244 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
421
484
  self.end_headers()
422
485
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
423
486
  except Exception as e:
487
+ if _is_client_disconnect_error(e):
488
+ return # client gone: nothing to send or log
424
489
  print(f"[serve_api_wave_prs] Uncaught exception: {e}", file=sys.stderr)
425
- self.send_response(500)
490
+ try:
491
+ self.send_response(500)
492
+ self.send_header("Content-Type", "application/json; charset=utf-8")
493
+ self.end_headers()
494
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
495
+ except Exception:
496
+ pass # best-effort error response; client may be gone
497
+
498
+ def serve_api_wave_telemetry(self):
499
+ """GET /api/wave/telemetry — current wave phase, cost metrics, and top blocker.
500
+
501
+ Read-only; reads state at call time (no caching). Returns wave phase info,
502
+ current cost metrics, and top blocker from AUDIT-BACKLOG.md.
503
+ """
504
+ try:
505
+ payload = wave_telemetry.get_wave_telemetry()
506
+ self.send_response(200)
426
507
  self.send_header("Content-Type", "application/json; charset=utf-8")
508
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
427
509
  self.end_headers()
428
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
510
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
511
+ except Exception as e:
512
+ if _is_client_disconnect_error(e):
513
+ return # client gone: nothing to send or log
514
+ print(f"[serve_api_wave_telemetry] Uncaught exception: {e}", file=sys.stderr)
515
+ try:
516
+ self.send_response(500)
517
+ self.send_header("Content-Type", "application/json; charset=utf-8")
518
+ self.end_headers()
519
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
520
+ except Exception:
521
+ pass # best-effort error response; client may be gone
522
+
523
+ def serve_api_wave_dispatch(self):
524
+ """GET /api/wave/dispatch — live per-agent phase and activity visibility.
525
+
526
+ Read-only; reads at call time (no caching). Returns per-agent phase,
527
+ last-activity age, and token burn estimates. Degrades to {available:false}
528
+ when no active workflow.
529
+ """
530
+ try:
531
+ payload = wave_dispatch.get_wave_dispatch()
532
+ self.send_response(200)
533
+ self.send_header("Content-Type", "application/json; charset=utf-8")
534
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
535
+ self.end_headers()
536
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
537
+ except Exception as e:
538
+ if _is_client_disconnect_error(e):
539
+ return # client gone: nothing to send or log
540
+ print(f"[serve_api_wave_dispatch] Uncaught exception: {e}", file=sys.stderr)
541
+ try:
542
+ self.send_response(500)
543
+ self.send_header("Content-Type", "application/json; charset=utf-8")
544
+ self.end_headers()
545
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
546
+ except Exception:
547
+ pass # best-effort error response; client may be gone
548
+
549
+ def serve_api_wave_gantt(self):
550
+ """GET /api/wave/gantt — Gantt timeline data for agents in current wave.
551
+
552
+ Read-only; reads at call time (no caching). Returns per-agent rows with
553
+ phase timing spans suitable for Gantt visualization. Degrades to
554
+ {available:false} when no active workflow.
555
+ """
556
+ try:
557
+ payload = wave_gantt.get_wave_gantt()
558
+ self.send_response(200)
559
+ self.send_header("Content-Type", "application/json; charset=utf-8")
560
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
561
+ self.end_headers()
562
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
563
+ except Exception as e:
564
+ if _is_client_disconnect_error(e):
565
+ return # client gone: nothing to send or log
566
+ print(f"[serve_api_wave_gantt] Uncaught exception: {e}", file=sys.stderr)
567
+ try:
568
+ self.send_response(500)
569
+ self.send_header("Content-Type", "application/json; charset=utf-8")
570
+ self.end_headers()
571
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
572
+ except Exception:
573
+ pass # best-effort error response; client may be gone
574
+
575
+ def serve_api_wave_audit_tail(self):
576
+ """GET /api/wave/audit-tail — latest audit/verification outcomes.
577
+
578
+ Read-only; reads at call time (no caching). Returns recent audit backlog
579
+ items and ledger verdicts as a compact tail. Shows latest findings from
580
+ adversarial reviews and verify verdicts.
581
+ """
582
+ try:
583
+ payload = wave_audit_tail.get_wave_audit_tail()
584
+ self.send_response(200)
585
+ self.send_header("Content-Type", "application/json; charset=utf-8")
586
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
587
+ self.end_headers()
588
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
589
+ except Exception as e:
590
+ if _is_client_disconnect_error(e):
591
+ return # client gone: nothing to send or log
592
+ print(f"[serve_api_wave_audit_tail] Uncaught exception: {e}", file=sys.stderr)
593
+ try:
594
+ self.send_response(500)
595
+ self.send_header("Content-Type", "application/json; charset=utf-8")
596
+ self.end_headers()
597
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
598
+ except Exception:
599
+ pass # best-effort error response; client may be gone
600
+
601
+ def serve_api_wave_reasoning_tail(self):
602
+ """GET /api/wave/reasoning-tail — per-agent live reasoning/transcript summary.
603
+
604
+ Read-only; reads at call time (no caching). Returns latest transcript activity
605
+ summary for each live agent (redacted). Shows thinking/tool-call sequences
606
+ in a compact format suitable for Activity view transparency.
607
+ """
608
+ try:
609
+ payload = wave_reasoning_tail.get_wave_reasoning_tail()
610
+ self.send_response(200)
611
+ self.send_header("Content-Type", "application/json; charset=utf-8")
612
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
613
+ self.end_headers()
614
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
615
+ except Exception as e:
616
+ if _is_client_disconnect_error(e):
617
+ return # client gone: nothing to send or log
618
+ print(f"[serve_api_wave_reasoning_tail] Uncaught exception: {e}", file=sys.stderr)
619
+ try:
620
+ self.send_response(500)
621
+ self.send_header("Content-Type", "application/json; charset=utf-8")
622
+ self.end_headers()
623
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
624
+ except Exception:
625
+ pass # best-effort error response; client may be gone
626
+
627
+ def serve_api_wave_quality_scorecards(self):
628
+ """GET /api/wave/quality-scorecards — per-agent-specialty quality metrics.
629
+
630
+ Read-only; reads at call time (no caching). Returns per-specialty quality:
631
+ success rate (green/total) and retry/repair frequency from ledger.
632
+ Includes top rankings by success and retry frequency.
633
+ """
634
+ try:
635
+ payload = quality_scorecard.get_quality_scorecard()
636
+ self.send_response(200)
637
+ self.send_header("Content-Type", "application/json; charset=utf-8")
638
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
639
+ self.end_headers()
640
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
641
+ except Exception as e:
642
+ if _is_client_disconnect_error(e):
643
+ return # client gone: nothing to send or log
644
+ print(f"[serve_api_wave_quality_scorecards] Uncaught exception: {e}", file=sys.stderr)
645
+ try:
646
+ self.send_response(500)
647
+ self.send_header("Content-Type", "application/json; charset=utf-8")
648
+ self.end_headers()
649
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
650
+ except Exception:
651
+ pass # best-effort error response; client may be gone
652
+
653
+ def serve_api_wave_failure(self):
654
+ """GET /api/wave/failure?pr=N — CI job logs and failure details for a PR.
655
+
656
+ Read-only; shells `gh run view --json jobs` / `gh api .../logs` (short timeout,
657
+ cached a few seconds). Degrades to {available:false, error:...} when gh is
658
+ missing or un-authenticated — never a 500 for those.
659
+
660
+ Query params:
661
+ pr: PR number (required)
662
+ """
663
+ try:
664
+ # Parse query string for pr parameter
665
+ query = urllib.parse.urlparse(self.path).query
666
+ params = urllib.parse.parse_qs(query)
667
+ pr_str = params.get('pr', [None])[0]
668
+
669
+ if not pr_str or not pr_str.isdigit():
670
+ self.send_response(400)
671
+ self.send_header("Content-Type", "application/json; charset=utf-8")
672
+ self.end_headers()
673
+ self.wfile.write(json.dumps({"error": "pr parameter required and must be a number"}).encode('utf-8'))
674
+ return
675
+
676
+ pr_number = int(pr_str)
677
+ payload = wave_failure.get_wave_failure(pr_number)
678
+ self.send_response(200)
679
+ self.send_header("Content-Type", "application/json; charset=utf-8")
680
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
681
+ self.end_headers()
682
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
683
+ except Exception as e:
684
+ if _is_client_disconnect_error(e):
685
+ return # client gone: nothing to send or log
686
+ print(f"[serve_api_wave_failure] Uncaught exception: {e}", file=sys.stderr)
687
+ try:
688
+ self.send_response(500)
689
+ self.send_header("Content-Type", "application/json; charset=utf-8")
690
+ self.end_headers()
691
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
692
+ except Exception:
693
+ pass # best-effort error response; client may be gone
429
694
 
430
695
  def serve_data(self):
431
696
  """Serve dashboard data as JSON."""
432
- data = {
433
- "watchdog": get_heartbeat_status(),
434
- "monitor": get_monitor_heartbeat_status(),
435
- "agents": get_fleet_agents(),
436
- "repos": get_repos_status(),
437
- "events": get_recent_events(),
438
- "alerts": get_alerts(),
439
- "messages": get_main_thread_messages(),
440
- }
441
- self.send_response(200)
442
- self.send_header("Content-Type", "application/json; charset=utf-8")
443
- self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
444
- self.end_headers()
445
- self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
697
+ try:
698
+ data = {
699
+ "watchdog": get_heartbeat_status(),
700
+ "monitor": get_monitor_heartbeat_status(),
701
+ "agents": get_fleet_agents(),
702
+ "repos": get_repos_status(),
703
+ "events": get_recent_events(),
704
+ "alerts": get_alerts(),
705
+ "messages": get_main_thread_messages(),
706
+ }
707
+ self.send_response(200)
708
+ self.send_header("Content-Type", "application/json; charset=utf-8")
709
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
710
+ self.end_headers()
711
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
712
+ except Exception as e:
713
+ if _is_client_disconnect_error(e):
714
+ return # client gone: nothing to send or log
715
+ print(f"[serve_data] Uncaught exception: {e}", file=sys.stderr)
716
+ try:
717
+ # Best-effort 500 response
718
+ self.send_response(500)
719
+ self.send_header("Content-Type", "application/json; charset=utf-8")
720
+ self.end_headers()
721
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
722
+ except Exception:
723
+ # Sending to a dead client must not cascade
724
+ pass
446
725
 
447
726
  def serve_tracker(self):
448
727
  """Serve tracker items as JSON via GET /api/tracker."""
@@ -460,11 +739,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
460
739
  self.end_headers()
461
740
  self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
462
741
  except Exception as e:
742
+ if _is_client_disconnect_error(e):
743
+ return # client gone: nothing to send or log
463
744
  print(f"[serve_tracker] Uncaught exception: {e}", file=sys.stderr)
464
- self.send_response(500)
465
- self.send_header("Content-Type", "application/json")
466
- self.end_headers()
467
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
745
+ try:
746
+ self.send_response(500)
747
+ self.send_header("Content-Type", "application/json")
748
+ self.end_headers()
749
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
750
+ except Exception:
751
+ pass # best-effort error response; client may be gone
468
752
 
469
753
  def handle_tracker_create(self):
470
754
  """Handle POST /api/tracker (create item)."""
@@ -485,12 +769,19 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
485
769
  self.send_header("Content-Type", "application/json; charset=utf-8")
486
770
  self.end_headers()
487
771
  self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
772
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
773
+ pass # Client disconnected — normal lifecycle, not an error.
488
774
  except Exception as e:
775
+ if _is_client_disconnect_error(e):
776
+ return # client gone: nothing to send or log
489
777
  print(f"[handle_tracker_create] Uncaught exception: {e}", file=sys.stderr)
490
- self.send_response(500)
491
- self.send_header("Content-Type", "application/json; charset=utf-8")
492
- self.end_headers()
493
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
778
+ try:
779
+ self.send_response(500)
780
+ self.send_header("Content-Type", "application/json; charset=utf-8")
781
+ self.end_headers()
782
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
783
+ except Exception:
784
+ pass # best-effort error response; client may be gone
494
785
 
495
786
  def handle_tracker_mutate(self):
496
787
  """Handle POST /api/tracker/<id> (update or delete)."""
@@ -534,12 +825,19 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
534
825
  self.send_header("Content-Type", "application/json; charset=utf-8")
535
826
  self.end_headers()
536
827
  self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
828
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
829
+ pass # Client disconnected — normal lifecycle, not an error.
537
830
  except Exception as e:
831
+ if _is_client_disconnect_error(e):
832
+ return # client gone: nothing to send or log
538
833
  print(f"[handle_tracker_mutate] Uncaught exception: {e}", file=sys.stderr)
539
- self.send_response(500)
540
- self.send_header("Content-Type", "application/json; charset=utf-8")
541
- self.end_headers()
542
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
834
+ try:
835
+ self.send_response(500)
836
+ self.send_header("Content-Type", "application/json; charset=utf-8")
837
+ self.end_headers()
838
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
839
+ except Exception:
840
+ pass # best-effort error response; client may be gone
543
841
 
544
842
 
545
843
  def serve_backlog(self):
@@ -552,11 +850,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
552
850
  self.end_headers()
553
851
  self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
554
852
  except Exception as e:
853
+ if _is_client_disconnect_error(e):
854
+ return # client gone: nothing to send or log
555
855
  print(f"[serve_backlog] Uncaught exception: {e}", file=sys.stderr)
556
- self.send_response(500)
557
- self.send_header("Content-Type", "application/json; charset=utf-8")
558
- self.end_headers()
559
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
856
+ try:
857
+ self.send_response(500)
858
+ self.send_header("Content-Type", "application/json; charset=utf-8")
859
+ self.end_headers()
860
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
861
+ except Exception:
862
+ pass # best-effort error response; client may be gone
560
863
 
561
864
  def serve_agents(self):
562
865
  """Serve rich agent list with metadata via GET /api/agents."""
@@ -568,11 +871,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
568
871
  self.end_headers()
569
872
  self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
570
873
  except Exception as e:
874
+ if _is_client_disconnect_error(e):
875
+ return # client gone: nothing to send or log
571
876
  print(f"[serve_agents] Uncaught exception: {e}", file=sys.stderr)
572
- self.send_response(500)
573
- self.send_header("Content-Type", "application/json; charset=utf-8")
574
- self.end_headers()
575
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
877
+ try:
878
+ self.send_response(500)
879
+ self.send_header("Content-Type", "application/json; charset=utf-8")
880
+ self.end_headers()
881
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
882
+ except Exception:
883
+ pass # best-effort error response; client may be gone
576
884
 
577
885
  def serve_agent(self):
578
886
  """Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
@@ -612,7 +920,8 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
612
920
  self.send_response(500)
613
921
  self.send_header("Content-Type", "application/json; charset=utf-8")
614
922
  self.end_headers()
615
- print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
923
+ if not _is_client_disconnect_error(e):
924
+ print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
616
925
  self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
617
926
 
618
927
  def serve_api_agent(self):
@@ -655,7 +964,8 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
655
964
  self.send_response(500)
656
965
  self.send_header("Content-Type", "application/json; charset=utf-8")
657
966
  self.end_headers()
658
- print(f"[serve_api_agent] Uncaught exception: {e}", file=sys.stderr)
967
+ if not _is_client_disconnect_error(e):
968
+ print(f"[serve_api_agent] Uncaught exception: {e}", file=sys.stderr)
659
969
  self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
660
970
 
661
971
  def _write_sse_event(self, event_name, payload):
@@ -700,8 +1010,12 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
700
1010
  self.send_header("Retry-After", "30")
701
1011
  self.end_headers()
702
1012
  self.wfile.write(b"Service overloaded: too many concurrent clients\n")
703
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
1013
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
704
1014
  pass
1015
+ except OSError as e:
1016
+ if not _is_client_disconnect_error(e):
1017
+ print(f"[serve_events cap-exceeded] Uncaught OSError: {e}", file=sys.stderr)
1018
+ # else: silently pass for disconnect errors
705
1019
  return
706
1020
 
707
1021
  try:
@@ -711,7 +1025,12 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
711
1025
  self.send_header("Connection", "keep-alive")
712
1026
  self.send_header("X-Accel-Buffering", "no")
713
1027
  self.end_headers()
714
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
1028
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
1029
+ unregister_sse_client(q)
1030
+ return
1031
+ except OSError as e:
1032
+ if not _is_client_disconnect_error(e):
1033
+ print(f"[serve_events headers] Uncaught OSError: {e}", file=sys.stderr)
715
1034
  unregister_sse_client(q)
716
1035
  return
717
1036
  try:
@@ -742,9 +1061,13 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
742
1061
  except queue.Empty:
743
1062
  self.wfile.write(b": keepalive\n\n")
744
1063
  self.wfile.flush()
745
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
1064
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
746
1065
  # Client disconnected (tab closed, network drop) — normal, not an error.
747
1066
  pass
1067
+ except OSError as e:
1068
+ if not _is_client_disconnect_error(e):
1069
+ print(f"[serve_events stream] Uncaught OSError: {e}", file=sys.stderr)
1070
+ # else: silently pass for disconnect errors
748
1071
  except Exception:
749
1072
  pass
750
1073
  finally:
@@ -799,11 +1122,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
799
1122
  self.end_headers()
800
1123
  self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
801
1124
  except Exception as e:
1125
+ if _is_client_disconnect_error(e):
1126
+ return # client gone: nothing to send or log
802
1127
  print(f"[handle_submit] Uncaught exception: {e}", file=sys.stderr)
803
- self.send_response(500)
804
- self.send_header("Content-Type", "application/json; charset=utf-8")
805
- self.end_headers()
806
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
1128
+ try:
1129
+ self.send_response(500)
1130
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1131
+ self.end_headers()
1132
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
1133
+ except Exception:
1134
+ pass # best-effort error response; client may be gone
807
1135
 
808
1136
 
809
1137
  class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):