@matt82198/aesop 0.2.0 → 0.3.2

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 (46) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +60 -263
  3. package/bin/cli.js +7 -3
  4. package/daemons/install-tasks.ps1 +193 -0
  5. package/daemons/run-hidden.vbs +39 -0
  6. package/docs/HOOK-INSTALL.md +15 -56
  7. package/docs/INSTALL.md +48 -3
  8. package/docs/PORTING.md +166 -0
  9. package/docs/TEAM-STATE.md +16 -184
  10. package/docs/reproduce.md +33 -3
  11. package/driver/CLAUDE.md +50 -48
  12. package/driver/codex_driver.py +59 -12
  13. package/driver/openai_transport.py +33 -0
  14. package/driver/wave_bridge.py +9 -1
  15. package/driver/wave_loop.py +437 -70
  16. package/driver/wave_scheduler.py +890 -0
  17. package/hooks/pre-push-policy.sh +22 -5
  18. package/monitor/collect-signals.mjs +69 -0
  19. package/package.json +6 -4
  20. package/state_store/__init__.py +2 -1
  21. package/state_store/projections.py +63 -0
  22. package/state_store/read_api.py +156 -0
  23. package/state_store/write_api.py +462 -0
  24. package/tools/cost_ceiling.py +106 -15
  25. package/tools/cost_projection.py +559 -0
  26. package/tools/crossos_drift.py +394 -0
  27. package/tools/eod_sweep.py +137 -33
  28. package/tools/mutation_test.py +130 -8
  29. package/tools/proposals.mjs +47 -2
  30. package/tools/reproduce.js +405 -0
  31. package/tools/secret_scan.py +73 -18
  32. package/tools/stall_check.py +247 -16
  33. package/tools/stateapi_lint.py +325 -0
  34. package/tools/test_battery.py +173 -0
  35. package/tools/verify_cost_panel.py +345 -0
  36. package/tools/wave_preflight.py +296 -29
  37. package/tools/wave_templates.py +170 -45
  38. package/ui/collectors.py +81 -0
  39. package/ui/handler.py +230 -85
  40. package/ui/sse.py +3 -3
  41. package/ui/wave_telemetry.py +24 -18
  42. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  43. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  44. package/ui/web/dist/index.html +2 -2
  45. package/docs/QUICKSTART.md +0 -80
  46. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
package/ui/collectors.py CHANGED
@@ -315,6 +315,87 @@ def get_alerts():
315
315
  print(f"[collectors] Failed to read alerts: {e}", file=sys.stderr)
316
316
  return alerts
317
317
 
318
+ def get_agent_lifecycle_events():
319
+ """Collect agent lifecycle events from transcript analysis.
320
+
321
+ Scans agent-*.jsonl files to infer agent state transitions.
322
+ Returns structured events for Activity view rendering.
323
+
324
+ Event format (list of dicts):
325
+ [
326
+ {
327
+ "agent_id": "12345",
328
+ "state": "dispatch" | "working" | "done" | "stalled",
329
+ "last_activity": ISO 8601 timestamp (transcript mtime),
330
+ "age_sec": seconds since last activity,
331
+ "transitions": [
332
+ {"state": "dispatch", "at": "2026-07-22T...Z"},
333
+ {"state": "working", "at": "2026-07-22T...Z"},
334
+ ]
335
+ }
336
+ ]
337
+
338
+ If transcripts unavailable or no agents found, returns empty list.
339
+ """
340
+ events = []
341
+ try:
342
+ # Use wave_dispatch.py's existing logic for consistency
343
+ import wave_dispatch as wd
344
+
345
+ dispatch_data = wd.get_wave_dispatch(force=False)
346
+ if not dispatch_data.get("available"):
347
+ return events
348
+
349
+ # Convert phase and timing data to lifecycle event structure
350
+ agents_data = dispatch_data.get("agents", [])
351
+ now_ts = datetime.now(timezone.utc)
352
+
353
+ for agent in agents_data:
354
+ agent_id = agent.get("id")
355
+ phase = agent.get("phase")
356
+ age_sec = agent.get("last_activity_age_sec", 0)
357
+
358
+ if not agent_id:
359
+ continue
360
+
361
+ # Map phase to state
362
+ phase_to_state = {
363
+ "dispatch": "dispatch",
364
+ "thinking": "working",
365
+ "tool-use": "working",
366
+ "stall": "stalled",
367
+ "done": "done",
368
+ "unknown": "working",
369
+ }
370
+ state = phase_to_state.get(phase, "working")
371
+
372
+ # Estimate activity timestamp from age
373
+ if age_sec >= 0:
374
+ activity_ts = now_ts.timestamp() - age_sec
375
+ activity_dt = datetime.fromtimestamp(activity_ts, timezone.utc)
376
+ last_activity = activity_dt.isoformat(timespec='seconds').replace('+00:00', 'Z')
377
+ else:
378
+ last_activity = now_ts.isoformat(timespec='seconds').replace('+00:00', 'Z')
379
+
380
+ event = {
381
+ "agent_id": agent_id,
382
+ "state": state,
383
+ "last_activity": last_activity,
384
+ "age_sec": age_sec,
385
+ "transitions": [
386
+ {
387
+ "state": state,
388
+ "at": last_activity,
389
+ }
390
+ ],
391
+ }
392
+ events.append(event)
393
+
394
+ except Exception as e:
395
+ print(f"[collectors] Failed to collect agent lifecycle events: {e}", file=sys.stderr)
396
+
397
+ return events
398
+
318
399
  def load_tracker():
319
400
  """Load tracker.json, return empty tracker if missing or corrupt.
320
401
 
package/ui/handler.py CHANGED
@@ -82,6 +82,32 @@ _ASSET_MIME = {
82
82
  }
83
83
 
84
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
+
85
111
  def _is_local_origin(origin):
86
112
  """True if an Origin header value is on the local allowlist.
87
113
 
@@ -335,10 +361,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
335
361
  self.send_header("Cache-Control", "public, max-age=31536000, immutable")
336
362
  self.end_headers()
337
363
  self.wfile.write(content)
338
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
364
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
339
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
340
371
  except Exception as e:
341
- 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)
342
374
  self.send_error(500)
343
375
 
344
376
  def serve_api_state(self):
@@ -376,11 +408,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
376
408
  self.end_headers()
377
409
  self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
378
410
  except Exception as e:
411
+ if _is_client_disconnect_error(e):
412
+ return # client gone: nothing to send or log
379
413
  print(f"[serve_api_state] Uncaught exception: {e}", file=sys.stderr)
380
- self.send_response(500)
381
- self.send_header("Content-Type", "application/json; charset=utf-8")
382
- self.end_headers()
383
- 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
384
421
 
385
422
  def serve_api_session(self):
386
423
  """GET /api/session — the CSRF token for same-origin JS (plan D3.3).
@@ -421,11 +458,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
421
458
  self.end_headers()
422
459
  self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
423
460
  except Exception as e:
461
+ if _is_client_disconnect_error(e):
462
+ return # client gone: nothing to send or log
424
463
  print(f"[serve_api_cost] 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'))
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
429
471
 
430
472
  def serve_api_wave_prs(self):
431
473
  """GET /api/wave/prs — open PRs + PR-less feat/* branches for the PR board.
@@ -442,11 +484,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
442
484
  self.end_headers()
443
485
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
444
486
  except Exception as e:
487
+ if _is_client_disconnect_error(e):
488
+ return # client gone: nothing to send or log
445
489
  print(f"[serve_api_wave_prs] Uncaught exception: {e}", file=sys.stderr)
446
- self.send_response(500)
447
- self.send_header("Content-Type", "application/json; charset=utf-8")
448
- self.end_headers()
449
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
450
497
 
451
498
  def serve_api_wave_telemetry(self):
452
499
  """GET /api/wave/telemetry — current wave phase, cost metrics, and top blocker.
@@ -462,11 +509,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
462
509
  self.end_headers()
463
510
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
464
511
  except Exception as e:
512
+ if _is_client_disconnect_error(e):
513
+ return # client gone: nothing to send or log
465
514
  print(f"[serve_api_wave_telemetry] Uncaught exception: {e}", file=sys.stderr)
466
- self.send_response(500)
467
- self.send_header("Content-Type", "application/json; charset=utf-8")
468
- self.end_headers()
469
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
470
522
 
471
523
  def serve_api_wave_dispatch(self):
472
524
  """GET /api/wave/dispatch — live per-agent phase and activity visibility.
@@ -483,11 +535,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
483
535
  self.end_headers()
484
536
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
485
537
  except Exception as e:
538
+ if _is_client_disconnect_error(e):
539
+ return # client gone: nothing to send or log
486
540
  print(f"[serve_api_wave_dispatch] Uncaught exception: {e}", file=sys.stderr)
487
- self.send_response(500)
488
- self.send_header("Content-Type", "application/json; charset=utf-8")
489
- self.end_headers()
490
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
491
548
 
492
549
  def serve_api_wave_gantt(self):
493
550
  """GET /api/wave/gantt — Gantt timeline data for agents in current wave.
@@ -504,11 +561,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
504
561
  self.end_headers()
505
562
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
506
563
  except Exception as e:
564
+ if _is_client_disconnect_error(e):
565
+ return # client gone: nothing to send or log
507
566
  print(f"[serve_api_wave_gantt] Uncaught exception: {e}", file=sys.stderr)
508
- self.send_response(500)
509
- self.send_header("Content-Type", "application/json; charset=utf-8")
510
- self.end_headers()
511
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
512
574
 
513
575
  def serve_api_wave_audit_tail(self):
514
576
  """GET /api/wave/audit-tail — latest audit/verification outcomes.
@@ -525,11 +587,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
525
587
  self.end_headers()
526
588
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
527
589
  except Exception as e:
590
+ if _is_client_disconnect_error(e):
591
+ return # client gone: nothing to send or log
528
592
  print(f"[serve_api_wave_audit_tail] Uncaught exception: {e}", file=sys.stderr)
529
- self.send_response(500)
530
- self.send_header("Content-Type", "application/json; charset=utf-8")
531
- self.end_headers()
532
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
533
600
 
534
601
  def serve_api_wave_reasoning_tail(self):
535
602
  """GET /api/wave/reasoning-tail — per-agent live reasoning/transcript summary.
@@ -546,11 +613,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
546
613
  self.end_headers()
547
614
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
548
615
  except Exception as e:
616
+ if _is_client_disconnect_error(e):
617
+ return # client gone: nothing to send or log
549
618
  print(f"[serve_api_wave_reasoning_tail] Uncaught exception: {e}", file=sys.stderr)
550
- self.send_response(500)
551
- self.send_header("Content-Type", "application/json; charset=utf-8")
552
- self.end_headers()
553
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
554
626
 
555
627
  def serve_api_wave_quality_scorecards(self):
556
628
  """GET /api/wave/quality-scorecards — per-agent-specialty quality metrics.
@@ -567,11 +639,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
567
639
  self.end_headers()
568
640
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
569
641
  except Exception as e:
642
+ if _is_client_disconnect_error(e):
643
+ return # client gone: nothing to send or log
570
644
  print(f"[serve_api_wave_quality_scorecards] Uncaught exception: {e}", file=sys.stderr)
571
- self.send_response(500)
572
- self.send_header("Content-Type", "application/json; charset=utf-8")
573
- self.end_headers()
574
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
575
652
 
576
653
  def serve_api_wave_failure(self):
577
654
  """GET /api/wave/failure?pr=N — CI job logs and failure details for a PR.
@@ -604,28 +681,47 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
604
681
  self.end_headers()
605
682
  self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
606
683
  except Exception as e:
684
+ if _is_client_disconnect_error(e):
685
+ return # client gone: nothing to send or log
607
686
  print(f"[serve_api_wave_failure] Uncaught exception: {e}", file=sys.stderr)
608
- self.send_response(500)
609
- self.send_header("Content-Type", "application/json; charset=utf-8")
610
- self.end_headers()
611
- self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
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
612
694
 
613
695
  def serve_data(self):
614
696
  """Serve dashboard data as JSON."""
615
- data = {
616
- "watchdog": get_heartbeat_status(),
617
- "monitor": get_monitor_heartbeat_status(),
618
- "agents": get_fleet_agents(),
619
- "repos": get_repos_status(),
620
- "events": get_recent_events(),
621
- "alerts": get_alerts(),
622
- "messages": get_main_thread_messages(),
623
- }
624
- self.send_response(200)
625
- self.send_header("Content-Type", "application/json; charset=utf-8")
626
- self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
627
- self.end_headers()
628
- 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
629
725
 
630
726
  def serve_tracker(self):
631
727
  """Serve tracker items as JSON via GET /api/tracker."""
@@ -643,11 +739,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
643
739
  self.end_headers()
644
740
  self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
645
741
  except Exception as e:
742
+ if _is_client_disconnect_error(e):
743
+ return # client gone: nothing to send or log
646
744
  print(f"[serve_tracker] Uncaught exception: {e}", file=sys.stderr)
647
- self.send_response(500)
648
- self.send_header("Content-Type", "application/json")
649
- self.end_headers()
650
- 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
651
752
 
652
753
  def handle_tracker_create(self):
653
754
  """Handle POST /api/tracker (create item)."""
@@ -668,12 +769,19 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
668
769
  self.send_header("Content-Type", "application/json; charset=utf-8")
669
770
  self.end_headers()
670
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.
671
774
  except Exception as e:
775
+ if _is_client_disconnect_error(e):
776
+ return # client gone: nothing to send or log
672
777
  print(f"[handle_tracker_create] Uncaught exception: {e}", file=sys.stderr)
673
- self.send_response(500)
674
- self.send_header("Content-Type", "application/json; charset=utf-8")
675
- self.end_headers()
676
- 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
677
785
 
678
786
  def handle_tracker_mutate(self):
679
787
  """Handle POST /api/tracker/<id> (update or delete)."""
@@ -717,12 +825,19 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
717
825
  self.send_header("Content-Type", "application/json; charset=utf-8")
718
826
  self.end_headers()
719
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.
720
830
  except Exception as e:
831
+ if _is_client_disconnect_error(e):
832
+ return # client gone: nothing to send or log
721
833
  print(f"[handle_tracker_mutate] Uncaught exception: {e}", file=sys.stderr)
722
- self.send_response(500)
723
- self.send_header("Content-Type", "application/json; charset=utf-8")
724
- self.end_headers()
725
- 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
726
841
 
727
842
 
728
843
  def serve_backlog(self):
@@ -735,11 +850,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
735
850
  self.end_headers()
736
851
  self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
737
852
  except Exception as e:
853
+ if _is_client_disconnect_error(e):
854
+ return # client gone: nothing to send or log
738
855
  print(f"[serve_backlog] Uncaught exception: {e}", file=sys.stderr)
739
- self.send_response(500)
740
- self.send_header("Content-Type", "application/json; charset=utf-8")
741
- self.end_headers()
742
- 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
743
863
 
744
864
  def serve_agents(self):
745
865
  """Serve rich agent list with metadata via GET /api/agents."""
@@ -751,11 +871,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
751
871
  self.end_headers()
752
872
  self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
753
873
  except Exception as e:
874
+ if _is_client_disconnect_error(e):
875
+ return # client gone: nothing to send or log
754
876
  print(f"[serve_agents] Uncaught exception: {e}", file=sys.stderr)
755
- self.send_response(500)
756
- self.send_header("Content-Type", "application/json; charset=utf-8")
757
- self.end_headers()
758
- 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
759
884
 
760
885
  def serve_agent(self):
761
886
  """Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
@@ -795,7 +920,8 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
795
920
  self.send_response(500)
796
921
  self.send_header("Content-Type", "application/json; charset=utf-8")
797
922
  self.end_headers()
798
- 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)
799
925
  self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
800
926
 
801
927
  def serve_api_agent(self):
@@ -838,7 +964,8 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
838
964
  self.send_response(500)
839
965
  self.send_header("Content-Type", "application/json; charset=utf-8")
840
966
  self.end_headers()
841
- 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)
842
969
  self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
843
970
 
844
971
  def _write_sse_event(self, event_name, payload):
@@ -883,8 +1010,12 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
883
1010
  self.send_header("Retry-After", "30")
884
1011
  self.end_headers()
885
1012
  self.wfile.write(b"Service overloaded: too many concurrent clients\n")
886
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
1013
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
887
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
888
1019
  return
889
1020
 
890
1021
  try:
@@ -894,7 +1025,12 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
894
1025
  self.send_header("Connection", "keep-alive")
895
1026
  self.send_header("X-Accel-Buffering", "no")
896
1027
  self.end_headers()
897
- 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)
898
1034
  unregister_sse_client(q)
899
1035
  return
900
1036
  try:
@@ -925,9 +1061,13 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
925
1061
  except queue.Empty:
926
1062
  self.wfile.write(b": keepalive\n\n")
927
1063
  self.wfile.flush()
928
- except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
1064
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
929
1065
  # Client disconnected (tab closed, network drop) — normal, not an error.
930
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
931
1071
  except Exception:
932
1072
  pass
933
1073
  finally:
@@ -982,11 +1122,16 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
982
1122
  self.end_headers()
983
1123
  self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
984
1124
  except Exception as e:
1125
+ if _is_client_disconnect_error(e):
1126
+ return # client gone: nothing to send or log
985
1127
  print(f"[handle_submit] Uncaught exception: {e}", file=sys.stderr)
986
- self.send_response(500)
987
- self.send_header("Content-Type", "application/json; charset=utf-8")
988
- self.end_headers()
989
- 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
990
1135
 
991
1136
 
992
1137
  class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):
package/ui/sse.py CHANGED
@@ -165,14 +165,14 @@ def collector_loop(stop_event):
165
165
  # Wave-19: Gate data section on mtimes+sizes to avoid expensive file reads every tick.
166
166
  # Only regenerate the snapshot if one of the underlying log files changed.
167
167
  try:
168
- backup_log_stat = config.BACKUP_LOG.stat() if config.BACKUP_LOG.exists() else None
168
+ backup_log_stat = config.BACKUP_LOG.stat() if config.BACKUP_LOG and config.BACKUP_LOG.exists() else None
169
169
  backup_log_mtime = backup_log_stat.st_mtime if backup_log_stat else None
170
170
  backup_log_size = backup_log_stat.st_size if backup_log_stat else None
171
171
  except OSError:
172
172
  backup_log_mtime = None
173
173
  backup_log_size = None
174
174
  try:
175
- alerts_log_stat = config.ALERTS_LOG.stat() if config.ALERTS_LOG.exists() else None
175
+ alerts_log_stat = config.ALERTS_LOG.stat() if config.ALERTS_LOG and config.ALERTS_LOG.exists() else None
176
176
  alerts_log_mtime = alerts_log_stat.st_mtime if alerts_log_stat else None
177
177
  alerts_log_size = alerts_log_stat.st_size if alerts_log_stat else None
178
178
  except OSError:
@@ -236,7 +236,7 @@ def collector_loop(stop_event):
236
236
 
237
237
  # Emit cost section (mtime+size-gated on the outcomes ledger)
238
238
  try:
239
- cost_stat = config.LEDGER_FILE.stat() if config.LEDGER_FILE.exists() else None
239
+ cost_stat = config.LEDGER_FILE.stat() if config.LEDGER_FILE and config.LEDGER_FILE.exists() else None
240
240
  cost_mtime = cost_stat.st_mtime if cost_stat else None
241
241
  cost_size = cost_stat.st_size if cost_stat else None
242
242
  except OSError: