switchroom 0.16.38 → 0.16.46

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 (61) hide show
  1. package/dist/agent-scheduler/index.js +88 -82
  2. package/dist/auth-broker/index.js +87 -81
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +89 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1621 -737
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +271 -239
  10. package/dist/vault/approvals/kernel-server.js +90 -84
  11. package/dist/vault/broker/server.js +91 -85
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +2 -2
  15. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -1
  18. package/telegram-plugin/card-format.ts +7 -1
  19. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  20. package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
  21. package/telegram-plugin/dist/server.js +180 -162
  22. package/telegram-plugin/format.ts +305 -31
  23. package/telegram-plugin/gateway/gateway.ts +262 -63
  24. package/telegram-plugin/gateway/model-command.ts +173 -19
  25. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  26. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  27. package/telegram-plugin/package.json +1 -1
  28. package/telegram-plugin/session-tail.ts +47 -1
  29. package/telegram-plugin/stream-reply-handler.ts +19 -1
  30. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  31. package/telegram-plugin/tests/card-format.test.ts +28 -0
  32. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  33. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  34. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  35. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  36. package/telegram-plugin/tests/model-command.test.ts +213 -47
  37. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  38. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  40. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  41. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  42. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  43. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  44. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  45. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  46. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  47. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  48. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  49. package/telegram-plugin/text-voice-scrub.ts +68 -18
  50. package/telegram-plugin/tool-activity-summary.ts +20 -108
  51. package/telegram-plugin/tts-normalize.ts +377 -0
  52. package/telegram-plugin/uat/driver.ts +472 -22
  53. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  54. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  56. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  57. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  58. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  59. package/telegram-plugin/voice-ondemand.ts +161 -10
  60. package/telegram-plugin/voice-presynth.ts +242 -0
  61. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -22,6 +22,8 @@
22
22
  --yellow: #fbbf24;
23
23
  --blue: #60a5fa;
24
24
  --radius: 10px;
25
+ /* Names the mono stack already used inline throughout this file. */
26
+ --mono: "SF Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
25
27
  }
26
28
 
27
29
  body {
@@ -554,6 +556,438 @@
554
556
  .mm-attn { border-left: 3px solid var(--red); padding: .4rem .6rem; margin: .15rem 0 .35rem; background: rgba(248,113,113,.07); border-radius: 0 6px 6px 0; font-size: .86em; }
555
557
  .mm-attn.warn { border-left-color: var(--yellow); background: rgba(251,191,36,.06); }
556
558
  .mm-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
559
+
560
+ /* ==================================================================
561
+ Fleet Health redesign — worst-first triage (.fh-* + related).
562
+ Namespaced; ported from the design handoff mock. Reuses the
563
+ dashboard's existing tokens (+ the new --mono var).
564
+ ================================================================== */
565
+ /* ---------- Page header: owner + scan + L3 summary ---------- */
566
+ .fh-top {
567
+ display: flex; flex-wrap: wrap; align-items: flex-start;
568
+ gap: 1rem 1.5rem; margin-bottom: 1.25rem;
569
+ }
570
+ .fh-summary { flex: 1 1 380px; min-width: 300px; }
571
+ .fh-summary .fh-lede {
572
+ font-size: 1.05rem; color: var(--text); line-height: 1.45; text-wrap: pretty;
573
+ }
574
+ .fh-summary .fh-lede b { color: var(--text); font-weight: 600; }
575
+ .fh-attribution {
576
+ margin-top: 0.55rem; font-size: 0.8rem; color: var(--text-dim);
577
+ font-family: var(--mono); display: flex; flex-wrap: wrap; gap: 0.15rem 0.9rem;
578
+ }
579
+ .fh-attribution .k { opacity: 0.7; }
580
+ .fh-attribution .v { color: var(--text); }
581
+ .fh-attribution .sep { color: var(--border); }
582
+
583
+ /* fleet posture: how many jobs are rotting vs clean, at a glance */
584
+ .fh-posture {
585
+ flex: 0 0 auto; display: flex; gap: 0.6rem;
586
+ }
587
+ .fh-stat {
588
+ background: var(--surface); border: 1px solid var(--border);
589
+ border-radius: var(--radius); padding: 0.6rem 0.9rem; min-width: 76px;
590
+ }
591
+ .fh-stat .n { font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
592
+ .fh-stat .l { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); margin-top: 0.15rem; }
593
+ .fh-stat.attn { border-color: rgba(248,113,113,0.35); }
594
+ .fh-stat.attn .n { color: var(--red); }
595
+ .fh-stat.clean .n { color: var(--green); }
596
+
597
+ /* ---------- Score explainer (collapsible) ---------- */
598
+ .fh-explain {
599
+ background: var(--surface); border: 1px solid var(--border);
600
+ border-radius: var(--radius); margin-bottom: 1.25rem; overflow: hidden;
601
+ }
602
+ .fh-explain > summary {
603
+ list-style: none; cursor: pointer; user-select: none;
604
+ padding: 0.7rem 1rem; display: flex; align-items: center; gap: 0.5rem;
605
+ font-size: 0.85rem;
606
+ }
607
+ .fh-explain > summary::-webkit-details-marker { display: none; }
608
+ .fh-explain > summary .caret { color: var(--text-dim); font-size: 0.7rem; transition: transform 0.15s; }
609
+ .fh-explain[open] > summary .caret { transform: rotate(90deg); }
610
+ .fh-explain > summary .formula {
611
+ margin-left: auto; font-family: var(--mono); font-size: 0.76rem; color: var(--text-dim);
612
+ }
613
+ .fh-explain > summary .formula em { color: var(--blue); font-style: normal; }
614
+ .fh-explain-body { padding: 0 1rem 0.9rem; }
615
+ .fh-factors { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6rem; }
616
+ .fh-factor {
617
+ background: var(--surface-hover); border-radius: 8px; padding: 0.6rem 0.7rem;
618
+ border-top: 2px solid var(--fc, var(--border));
619
+ }
620
+ .fh-factor .fn { font-weight: 600; font-size: 0.85rem; }
621
+ .fh-factor .fd { color: var(--text-dim); font-size: 0.76rem; margin-top: 0.2rem; line-height: 1.4; }
622
+ .fh-factor.sev { --fc: var(--red); }
623
+ .fh-factor.freq { --fc: var(--yellow); }
624
+ .fh-factor.reach { --fc: var(--blue); }
625
+ .fh-factor.rec { --fc: var(--green); }
626
+
627
+ /* ---------- Section headings ---------- */
628
+ .fh-band {
629
+ display: flex; align-items: baseline; gap: 0.5rem;
630
+ margin: 0 0 0.7rem; font-size: 0.72rem; text-transform: uppercase;
631
+ letter-spacing: 0.08em; color: var(--text-dim);
632
+ }
633
+ .fh-band .n { color: var(--text); opacity: 0.55; font-variant-numeric: tabular-nums; }
634
+ .fh-band + .fh-band { margin-top: 0; }
635
+ .fh-attention-list { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1.75rem; }
636
+
637
+ /* ---------- Triage row (one job spec record) ---------- */
638
+ .fh-rec {
639
+ background: var(--surface); border: 1px solid var(--border);
640
+ border-radius: var(--radius); overflow: hidden; transition: border-color 0.15s;
641
+ }
642
+ .fh-rec:hover { border-color: #3a3e4e; }
643
+ .fh-rec.worst { border-color: rgba(248,113,113,0.4); }
644
+ .fh-rec.worst .fh-rec-head::before {
645
+ content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--red);
646
+ }
647
+
648
+ .fh-rec-head {
649
+ position: relative; display: grid;
650
+ grid-template-columns: 3.2rem 1fr auto; align-items: center;
651
+ gap: 0.9rem; padding: 0.85rem 1.1rem 0.85rem 1.25rem; cursor: pointer; user-select: none;
652
+ }
653
+ /* rank + score cluster */
654
+ .fh-rank { text-align: center; }
655
+ .fh-rank .score {
656
+ font-size: 1.35rem; font-weight: 700; font-variant-numeric: tabular-nums;
657
+ line-height: 1; letter-spacing: -0.02em;
658
+ }
659
+ .fh-rank .rk { font-family: var(--mono); font-size: 0.62rem; color: var(--text-dim); letter-spacing: 0.1em; margin-top: 0.15rem; }
660
+
661
+ .fh-rec-main { min-width: 0; }
662
+ .fh-rec-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
663
+ .fh-job {
664
+ font-family: var(--mono); font-size: 0.95rem; font-weight: 600; color: var(--text);
665
+ letter-spacing: -0.01em;
666
+ }
667
+ .fh-rec-sub {
668
+ margin-top: 0.28rem; font-size: 0.78rem; color: var(--text-dim);
669
+ display: flex; flex-wrap: wrap; gap: 0.1rem 0.8rem; font-family: var(--mono);
670
+ }
671
+ /* one-line intent: what this job spec promises the operator */
672
+ .fh-desc {
673
+ margin-top: 0.3rem; font-size: 0.82rem; color: var(--text-dim);
674
+ line-height: 1.4; max-width: 62ch; text-wrap: pretty;
675
+ }
676
+ .fh-rec-sub .v { color: var(--text); }
677
+ .fh-rec-sub .sep { color: var(--border); }
678
+
679
+ /* score-composition strip — the "why it ranks here" microviz */
680
+ .fh-compose { display: flex; align-items: center; gap: 0.55rem; margin-top: 0.5rem; flex-wrap: wrap; }
681
+ .fh-fpill {
682
+ display: inline-flex; align-items: baseline; gap: 0.28rem;
683
+ font-family: var(--mono); font-size: 0.72rem; color: var(--text-dim);
684
+ background: var(--surface-hover); border: 1px solid var(--border);
685
+ border-radius: 6px; padding: 0.12rem 0.42rem; white-space: nowrap;
686
+ }
687
+ .fh-fpill b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
688
+ .fh-fpill .lab { opacity: 0.7; }
689
+ .fh-fpill.sev b { color: var(--red); }
690
+ .fh-fpill.freq b { color: var(--yellow); }
691
+ .fh-fpill.reach b { color: var(--blue); }
692
+ .fh-fpill.rec b { color: var(--green); }
693
+ .fh-times { color: var(--border); font-size: 0.7rem; }
694
+
695
+ .fh-rec-right { display: flex; align-items: center; gap: 0.75rem; }
696
+ .fh-caret { color: var(--text-dim); font-size: 0.8rem; transition: transform 0.15s; }
697
+ .fh-rec.open .fh-caret { transform: rotate(90deg); }
698
+
699
+ /* severity badge */
700
+ .sev-badge {
701
+ display: inline-flex; align-items: center; gap: 0.32rem;
702
+ padding: 0.15rem 0.55rem; border-radius: 999px;
703
+ font-size: 0.72rem; font-weight: 500; white-space: nowrap;
704
+ }
705
+ .sev-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
706
+ .sev-badge.s3 { color: var(--red); background: rgba(248,113,113,0.12); }
707
+ .sev-badge.s3 .dot { background: var(--red); box-shadow: 0 0 6px var(--red); }
708
+ .sev-badge.s2 { color: var(--yellow); background: rgba(251,191,36,0.12); }
709
+ .sev-badge.s2 .dot { background: var(--yellow); }
710
+ .sev-badge.s1 { color: var(--blue); background: rgba(96,165,250,0.12); }
711
+ .sev-badge.s1 .dot { background: var(--blue); }
712
+
713
+ .issue-count {
714
+ font-size: 0.78rem; color: var(--text-dim); font-variant-numeric: tabular-nums;
715
+ white-space: nowrap;
716
+ }
717
+ .issue-count b { color: var(--text); font-weight: 600; }
718
+
719
+ /* ---------- Drill-down: distinct issues ---------- */
720
+ .fh-issues { display: none; border-top: 1px solid var(--border); padding: 0.4rem 1.1rem 0.9rem 1.25rem; }
721
+ .fh-rec.open .fh-issues { display: block; }
722
+
723
+ .fh-issue { padding: 0.8rem 0; border-bottom: 1px solid var(--border); }
724
+ .fh-issue:last-child { border-bottom: none; }
725
+ .fh-issue-top { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
726
+
727
+ /* failure-mode taxonomy chip */
728
+ .mode-chip {
729
+ font-family: var(--mono); font-size: 0.72rem; padding: 0.1rem 0.45rem;
730
+ border-radius: 5px; border: 1px solid var(--border); color: var(--text);
731
+ background: var(--surface-hover); white-space: nowrap;
732
+ }
733
+ .mode-chip.s3 { border-color: rgba(248,113,113,0.4); color: var(--red); }
734
+ .mode-chip.s2 { border-color: rgba(251,191,36,0.4); color: var(--yellow); }
735
+ .mode-chip.s1 { border-color: rgba(96,165,250,0.4); color: var(--blue); }
736
+
737
+ .status-tag {
738
+ font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
739
+ padding: 0.1rem 0.45rem; border-radius: 999px; font-weight: 500;
740
+ }
741
+ .status-tag.open { color: var(--text-dim); border: 1px solid var(--border); }
742
+ .status-tag.verify { color: var(--yellow); background: rgba(251,191,36,0.1); }
743
+ .status-tag.closed { color: var(--green); background: rgba(52,211,153,0.1); }
744
+
745
+ .gh-link {
746
+ margin-left: auto; font-family: var(--mono); font-size: 0.74rem;
747
+ color: var(--blue); text-decoration: none; display: inline-flex; align-items: center; gap: 0.25rem;
748
+ padding: 0.1rem 0.4rem; border-radius: 5px;
749
+ }
750
+ .gh-link:hover { background: var(--surface-hover); }
751
+
752
+ .fh-issue-meta {
753
+ margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem;
754
+ font-size: 0.78rem; color: var(--text-dim); font-family: var(--mono);
755
+ align-items: center;
756
+ }
757
+ .fh-issue-meta .v { color: var(--text); }
758
+ /* frequency trend: count + a self-verifying count-drop when resolving */
759
+ .fh-trend { display: inline-flex; align-items: center; gap: 0.3rem; }
760
+ .fh-trend .from { color: var(--text-dim); text-decoration: line-through; opacity: 0.7; }
761
+ .fh-trend .arrow { color: var(--green); }
762
+ .fh-trend .to { color: var(--green); font-weight: 600; }
763
+ .reach-agents { display: inline-flex; gap: 0.25rem; flex-wrap: wrap; }
764
+ .reach-agents .ag {
765
+ background: rgba(96,165,250,0.1); color: var(--blue); border-radius: 999px;
766
+ padding: 0.05rem 0.45rem; font-size: 0.72rem;
767
+ }
768
+
769
+ /* why it failed: the model-free root-cause read, above the raw artifacts */
770
+ .fh-why {
771
+ font-size: 0.82rem; color: var(--text); line-height: 1.5; text-wrap: pretty;
772
+ background: rgba(248,113,113,0.06); border-left: 2px solid rgba(248,113,113,0.5);
773
+ border-radius: 0 6px 6px 0; padding: 0.5rem 0.7rem; margin-bottom: 0.55rem;
774
+ }
775
+ .fh-why-lab {
776
+ display: block; font-family: var(--mono); font-size: 0.66rem; text-transform: uppercase;
777
+ letter-spacing: 0.06em; color: var(--red); opacity: 0.85; margin-bottom: 0.25rem;
778
+ }
779
+
780
+ /* evidence: the hard artifacts (turn_id join-key + log pointer) */
781
+ .fh-evidence { margin-top: 0.55rem; }
782
+ .fh-evidence-h {
783
+ font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
784
+ color: var(--text-dim); margin-bottom: 0.35rem; display: flex; align-items: center; gap: 0.4rem;
785
+ }
786
+ .fh-evidence-h .tier {
787
+ color: var(--green); border: 1px solid rgba(52,211,153,0.3);
788
+ border-radius: 4px; padding: 0 0.3rem; font-size: 0.62rem; letter-spacing: 0.04em; white-space: nowrap;
789
+ }
790
+ .occ {
791
+ display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 0.75rem; align-items: center;
792
+ background: rgba(0,0,0,0.22); border: 1px solid var(--border); border-radius: 6px;
793
+ padding: 0.4rem 0.6rem; font-family: var(--mono); font-size: 0.73rem;
794
+ }
795
+ .occ + .occ { margin-top: 0.3rem; }
796
+ .occ .occ-agent { color: var(--text); font-weight: 600; }
797
+ .occ .occ-body { min-width: 0; display: flex; flex-wrap: wrap; gap: 0.15rem 0.7rem; align-items: center; }
798
+ .occ .tid { color: var(--yellow); }
799
+ .occ .ptr { color: var(--text-dim); word-break: break-all; }
800
+ .occ .ptr .g { color: var(--text); }
801
+ .occ .more { color: var(--text-dim); font-family: var(--mono); font-size: 0.73rem; padding-left: 0.6rem; }
802
+
803
+ /* ---------- Healthy jobs: the quiet majority ---------- */
804
+ .fh-healthy-grid {
805
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0.4rem;
806
+ }
807
+ .fh-hc {
808
+ display: flex; align-items: center; gap: 0.55rem;
809
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
810
+ padding: 0.55rem 0.7rem; font-size: 0.8rem;
811
+ }
812
+ .fh-hc .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; align-self: flex-start; margin-top: 0.35rem; }
813
+ .fh-hc-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 0.1rem; }
814
+ .fh-hc .job { font-family: var(--mono); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
815
+ .fh-hc .hd { font-size: 0.72rem; color: var(--text-dim); line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
816
+ .fh-hc .when { margin-left: auto; color: var(--text-dim); font-size: 0.7rem; white-space: nowrap; align-self: flex-start; margin-top: 0.1rem; }
817
+
818
+ @media (max-width: 640px) {
819
+ .fh-factors { grid-template-columns: repeat(2, 1fr); }
820
+ .fh-rec-head { grid-template-columns: 2.8rem 1fr; }
821
+ .fh-rec-right { grid-column: 1 / -1; justify-content: flex-start; padding-left: 3.7rem; }
822
+ .fh-explain > summary .formula { display: none; }
823
+ }
824
+
825
+ /* ==================================================================
826
+ Summary cockpit redesign (.sm-*). Namespaced; ported from mock.
827
+ ================================================================== */
828
+ /* ---------- cockpit header: posture verdict + freshness ---------- */
829
+ .sm-verdict {
830
+ display: flex; align-items: center; gap: 0.85rem; margin-bottom: 0.35rem;
831
+ }
832
+ .sm-verdict .pulse { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
833
+ .sm-verdict.attn .pulse { background: var(--red); box-shadow: 0 0 10px var(--red); }
834
+ .sm-verdict.ok .pulse { background: var(--green); box-shadow: 0 0 10px var(--green); }
835
+ .sm-verdict h2 { font-size: 1.35rem; font-weight: 600; letter-spacing: -0.02em; }
836
+ .sm-verdict h2 b { color: var(--red); }
837
+ .sm-asof { font-size: 0.75rem; color: var(--text-dim); margin-bottom: 1.1rem; font-family: var(--mono); }
838
+
839
+ .sm-band { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin: 0 0 0.6rem; display: flex; align-items: baseline; gap: 0.45rem; }
840
+ .sm-band .n { color: var(--text); opacity: 0.55; }
841
+
842
+ /* ---------- attention hero: what needs you, worst-first ---------- */
843
+ .sm-attn-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.6rem; }
844
+ .sm-attn {
845
+ display: flex; align-items: flex-start; gap: 0.7rem; text-align: left;
846
+ width: 100%; background: var(--surface); border: 1px solid var(--border);
847
+ border-left-width: 3px; border-radius: var(--radius); padding: 0.75rem 0.95rem;
848
+ cursor: pointer; transition: background 0.12s, border-color 0.12s; color: inherit; font: inherit;
849
+ }
850
+ .sm-attn:hover { background: var(--surface-hover); }
851
+ .sm-attn.critical { border-left-color: var(--red); }
852
+ .sm-attn.warn { border-left-color: var(--yellow); }
853
+ .sm-attn.info { border-left-color: var(--blue); }
854
+ .sm-attn .sev {
855
+ font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600;
856
+ padding: 0.15rem 0.45rem; border-radius: 5px; margin-top: 0.1rem; white-space: nowrap; font-family: var(--mono);
857
+ }
858
+ .sm-attn.critical .sev { color: var(--red); background: rgba(248,113,113,0.12); }
859
+ .sm-attn.warn .sev { color: var(--yellow); background: rgba(251,191,36,0.12); }
860
+ .sm-attn.info .sev { color: var(--blue); background: rgba(96,165,250,0.12); }
861
+ .sm-attn-body { min-width: 0; flex: 1; }
862
+ .sm-attn-title { font-weight: 600; font-size: 0.9rem; }
863
+ .sm-attn-detail { color: var(--text-dim); font-size: 0.82rem; margin-top: 0.15rem; text-wrap: pretty; }
864
+ .sm-attn-go { color: var(--text-dim); font-size: 0.75rem; margin-top: 0.2rem; align-self: center; white-space: nowrap; font-family: var(--mono); }
865
+ .sm-attn:hover .sm-attn-go { color: var(--blue); }
866
+ .sm-attn.calm { border-left-color: var(--green); cursor: default; }
867
+ .sm-attn.calm:hover { background: var(--surface); }
868
+
869
+ /* ---------- vitals: the things that actually bite ---------- */
870
+ .sm-vitals { display: grid; grid-template-columns: 1.4fr 1fr; gap: 0.75rem; margin-bottom: 1.6rem; }
871
+ .sm-vcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.95rem 1.1rem; }
872
+ .sm-vcard .vh { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 0.6rem; display: flex; align-items: center; gap: 0.4rem; }
873
+ .sm-vcard .vh a { margin-left: auto; color: var(--text-dim); font-size: 0.72rem; text-decoration: none; cursor: pointer; }
874
+ .sm-vcard .vh a:hover { color: var(--blue); }
875
+
876
+ /* agents posture */
877
+ .sm-agents-n { font-size: 2rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1; }
878
+ .sm-agents-n .of { font-size: 1rem; color: var(--text-dim); font-weight: 400; }
879
+ .sm-agents-sub { color: var(--text-dim); font-size: 0.82rem; margin-top: 0.25rem; }
880
+ .sm-dotrow { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 0.7rem; }
881
+ .sm-dotrow .d { width: 12px; height: 12px; border-radius: 3px; }
882
+ .sm-dotrow .d.up { background: var(--green); }
883
+ .sm-dotrow .d.down { background: var(--red); }
884
+ .sm-dotrow .d.warn { background: var(--yellow); }
885
+
886
+ /* quota gauges (shared with Accounts tab) */
887
+ .sm-quota-acct { font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.55rem; }
888
+ .sm-quota-acct b { color: var(--text); font-family: var(--mono); }
889
+ .gauge { margin-bottom: 0.55rem; }
890
+ .gauge:last-child { margin-bottom: 0; }
891
+ .gauge-top { display: flex; justify-content: space-between; align-items: baseline; font-size: 0.78rem; margin-bottom: 0.22rem; }
892
+ .gauge-top .lab { color: var(--text-dim); }
893
+ .gauge-top .val { font-variant-numeric: tabular-nums; font-weight: 600; font-family: var(--mono); }
894
+ .gauge-track { height: 7px; border-radius: 4px; background: rgba(255,255,255,0.08); overflow: hidden; }
895
+ .gauge-fill { height: 100%; border-radius: 4px; }
896
+ .val.lo, .gauge-fill.lo { color: var(--green); }
897
+ .gauge-fill.lo { background: var(--green); }
898
+ .val.mid { color: var(--yellow); }
899
+ .gauge-fill.mid { background: var(--yellow); }
900
+ .val.hi { color: var(--red); }
901
+ .gauge-fill.hi { background: var(--red); }
902
+ .val.na { color: var(--text-dim); }
903
+ .gauge-reset { font-size: 0.7rem; color: var(--text-dim); margin-left: 0.4rem; font-weight: 400; }
904
+ .gauge.stale .val, .gauge.stale .gauge-fill { opacity: 0.5; }
905
+
906
+ /* ---------- services rail: quiet infra, compact ---------- */
907
+ .sm-services { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 0.5rem; }
908
+ .sm-svc { display: flex; align-items: center; gap: 0.6rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; }
909
+ .sm-svc .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
910
+ .sm-svc .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
911
+ .sm-svc .dot.bad { background: var(--red); }
912
+ .sm-svc .dot.idle { background: var(--text-dim); }
913
+ .sm-svc-text { min-width: 0; }
914
+ .sm-svc-name { font-size: 0.85rem; font-weight: 600; }
915
+ .sm-svc-meta { font-size: 0.72rem; color: var(--text-dim); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
916
+
917
+ @media (max-width: 640px) {
918
+ .sm-vitals { grid-template-columns: 1fr; }
919
+ }
920
+
921
+ /* ==================================================================
922
+ Accounts quota-triage redesign (.ac-* / .acct-*). Namespaced;
923
+ ported from mock. Reuses the shared .gauge-* rules above.
924
+ ================================================================== */
925
+ /* ---------- top control bar ---------- */
926
+ .ac-bar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
927
+ .ac-bar .hint { font-size: 0.78rem; color: var(--text-dim); }
928
+ .ac-posture { display: flex; gap: 0.6rem; margin-left: auto; }
929
+ .ac-stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.5rem 0.85rem; text-align: center; min-width: 70px; }
930
+ .ac-stat .n { font-size: 1.35rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
931
+ .ac-stat .l { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-dim); }
932
+ .ac-stat.attn { border-color: rgba(248,113,113,0.35); }
933
+ .ac-stat.attn .n { color: var(--red); }
934
+ .ac-stat.clean .n { color: var(--green); }
935
+
936
+ .ac-band { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin: 0 0 0.7rem; display: flex; align-items: baseline; gap: 0.45rem; }
937
+ .ac-band .n { color: var(--text); opacity: 0.55; }
938
+ .ac-list { display: flex; flex-direction: column; gap: 0.7rem; margin-bottom: 1.75rem; }
939
+
940
+ /* ---------- account row ---------- */
941
+ .acct {
942
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
943
+ padding: 1rem 1.15rem; position: relative; overflow: hidden; transition: border-color 0.15s;
944
+ }
945
+ .acct:hover { border-color: #3a3e4e; }
946
+ .acct.worst { border-color: rgba(248,113,113,0.4); }
947
+ .acct.worst::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--red); }
948
+
949
+ .acct-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
950
+ .acct-label { font-family: var(--mono); font-size: 1rem; font-weight: 600; letter-spacing: -0.01em; }
951
+ .acct-fleet { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--blue); border: 1px solid rgba(96,165,250,0.4); border-radius: 5px; padding: 0.08rem 0.4rem; }
952
+ /* health-badge: additive to the existing base rule — the redesign
953
+ gives the badge a leading dot, so switch to inline-flex + gap and
954
+ add the .missing / .exhausted variants the mock uses. */
955
+ .health-badge { display: inline-flex; align-items: center; gap: 0.3rem; }
956
+ .health-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
957
+ .health-badge.healthy .dot { background: var(--green); }
958
+ .health-badge.expired, .health-badge.missing { color: var(--red); background: rgba(248,113,113,0.12); }
959
+ .health-badge.expired .dot, .health-badge.missing .dot { background: var(--red); }
960
+ .health-badge.exhausted { color: var(--yellow); background: rgba(251,191,36,0.12); }
961
+ .health-badge.exhausted .dot { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
962
+ .acct-actions { margin-left: auto; display: flex; gap: 0.4rem; }
963
+
964
+ /* exhaustion / expiry callout */
965
+ .acct-alert {
966
+ margin-top: 0.7rem; font-size: 0.82rem; line-height: 1.45;
967
+ border-radius: 0 6px 6px 0; padding: 0.5rem 0.7rem;
968
+ }
969
+ .acct-alert .lab { display: block; font-family: var(--mono); font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.2rem; }
970
+ .acct-alert.crit { background: rgba(248,113,113,0.07); border-left: 2px solid var(--red); }
971
+ .acct-alert.crit .lab { color: var(--red); }
972
+ .acct-alert.warn { background: rgba(251,191,36,0.06); border-left: 2px solid var(--yellow); }
973
+ .acct-alert.warn .lab { color: var(--yellow); }
974
+
975
+ /* quota gauges */
976
+ .acct-quota { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem 1.4rem; margin-top: 0.8rem; }
977
+
978
+ /* footer meta: blast radius + secondary facts */
979
+ .acct-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 1.3rem; margin-top: 0.85rem; padding-top: 0.7rem; border-top: 1px solid var(--border); font-size: 0.78rem; color: var(--text-dim); }
980
+ .acct-foot .k { opacity: 0.7; font-family: var(--mono); font-size: 0.72rem; }
981
+ .used-by { display: inline-flex; gap: 0.25rem; flex-wrap: wrap; }
982
+ .used-by .ag { background: rgba(96,165,250,0.1); color: var(--blue); border-radius: 999px; padding: 0.05rem 0.5rem; font-size: 0.74rem; }
983
+ .used-by .none { color: var(--text-dim); font-style: italic; }
984
+ .acct-foot .probed { font-family: var(--mono); }
985
+ .acct-foot .probed .src { opacity: 0.7; }
986
+
987
+ @media (max-width: 640px) {
988
+ .acct-quota { grid-template-columns: 1fr; }
989
+ .ac-posture { margin-left: 0; }
990
+ }
557
991
  </style>
558
992
  </head>
559
993
  <body>
@@ -570,6 +1004,7 @@
570
1004
  <button id="tab-connections" onclick="switchTab('connections')">Connections</button>
571
1005
  <button id="tab-schedule" onclick="switchTab('schedule')">Schedule</button>
572
1006
  <button id="tab-approvals" onclick="switchTab('approvals')">Approvals</button>
1007
+ <button id="tab-fleet-health" onclick="switchTab('fleet-health')">Fleet Health</button>
573
1008
  </nav>
574
1009
  <main>
575
1010
  <div id="error"></div>
@@ -581,6 +1016,7 @@
581
1016
  <div id="connections" style="display:none"></div>
582
1017
  <div id="schedule" style="display:none"></div>
583
1018
  <div id="approvals" style="display:none"></div>
1019
+ <div id="fleet-health" style="display:none"></div>
584
1020
  </main>
585
1021
 
586
1022
  <script>
@@ -591,6 +1027,10 @@
591
1027
  let openDetails = new Set();
592
1028
  let agentDetails = {};
593
1029
  let accounts = [];
1030
+ // The broker's current fleet-active account label, stashed by fetchSummary
1031
+ // (there's no fleet-active flag on /api/accounts). Drives the Accounts tab's
1032
+ // "fleet-active" pill; null until Summary has loaded at least once.
1033
+ let fleetActiveLabel = null;
594
1034
  let ws = null;
595
1035
 
596
1036
  function authHeaders() {
@@ -1257,10 +1697,228 @@
1257
1697
  }
1258
1698
  }
1259
1699
 
1700
+ async function fetchFleetHealth() {
1701
+ try {
1702
+ const res = await fetch(`${API}/api/fleet-health`, { headers: authHeaders() });
1703
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1704
+ renderFleetHealth(await res.json());
1705
+ clearError();
1706
+ } catch (err) {
1707
+ showError(`Failed to fetch fleet health: ${err.message}`);
1708
+ }
1709
+ }
1710
+
1711
+ // Fleet Health — the 23 job specs ranked worst-first by priority_score
1712
+ // (severity × frequency × reach × recency). Each spec drills down to its
1713
+ // distinct issues; each issue links to its occurrences (turn_ids + log
1714
+ // pointers) and its GitHub issue. Empty state when no owner agent is
1715
+ // assigned yet (feature inert). Design: reference/rfcs/fleet-health.md.
1716
+ function renderFleetHealth(data) {
1717
+ const el = document.getElementById('fleet-health');
1718
+
1719
+ if (!data || data.empty) {
1720
+ el.innerHTML =
1721
+ `<div class="loading" style="color:var(--text-dim)">${escapeHtml((data && data.reason) || 'No fleet-health ledger yet.')}</div>`;
1722
+ return;
1723
+ }
1724
+
1725
+ // Fleet-health timestamps are ISO strings on the wire; formatTimestamp
1726
+ // wants epoch ms, so parse first. Date.parse(null/undefined) → NaN →
1727
+ // formatTimestamp returns "—", preserving the mock's "no value" behaviour.
1728
+ const rel = (iso) => formatTimestamp(Date.parse(iso));
1729
+ const SEV_LABEL = { 3: 'severe', 2: 'moderate', 1: 'minor', 0: 'clear' };
1730
+ const scoreColor = (s) => s >= 15 ? 'var(--red)' : s >= 8 ? 'var(--yellow)' : s > 0 ? 'var(--blue)' : 'var(--text-dim)';
1731
+ // `factors` mirrors the read-model scoring inputs but isn't always on the
1732
+ // wire; derive per the RFC when absent (see reference/rfcs/fleet-health.md).
1733
+ const factorsFor = (iss) => {
1734
+ if (iss.factors) return iss.factors;
1735
+ const ms = Date.parse(iss.recency);
1736
+ let rec = 0.1;
1737
+ if (!isNaN(ms)) {
1738
+ const ageDays = (Date.now() - ms) / 86400000;
1739
+ rec = ageDays <= 1 ? 1.0 : Math.max(0.1, 1.0 - (ageDays / 30) * 0.9);
1740
+ }
1741
+ return {
1742
+ freq: Math.log10(1 + (iss.frequency || 0)),
1743
+ reach: (iss.reach || []).length,
1744
+ rec,
1745
+ };
1746
+ };
1747
+
1748
+ // `last_deep_dive` is a PER-RECORD field, not a top-level payload field —
1749
+ // derive the fleet's most-recent deep-dive as the max across records.
1750
+ const lastDeepDiveMs = data.records.reduce((acc, r) => {
1751
+ const t = Date.parse(r.last_deep_dive);
1752
+ return isNaN(t) ? acc : Math.max(acc, t);
1753
+ }, NaN);
1754
+
1755
+ const attention = data.records.filter((r) => r.open_issue_count > 0);
1756
+ const healthy = data.records.filter((r) => r.open_issue_count === 0);
1757
+ // `issues` is not coerced server-side (only `priority_score` is), so a
1758
+ // record with open_issue_count > 0 but missing/undefined `issues` would
1759
+ // throw on deref and blank the whole tab — guard to [] at every read.
1760
+ const issuesOf = (r) => Array.isArray(r.issues) ? r.issues : [];
1761
+ const worstSev = (r) => Math.max(0, ...issuesOf(r).filter(i => i.status !== 'closed').map(i => i.severity));
1762
+
1763
+ // ---- page header: L3 summary + attribution + posture stats ----
1764
+ const top = `
1765
+ <div class="fh-top">
1766
+ <div class="fh-summary">
1767
+ <div class="fh-lede">${data.summary ? escapeHtml(data.summary) : 'No synthesis yet.'}</div>
1768
+ <div class="fh-attribution">
1769
+ <span><span class="k">owner</span> <span class="v">@${escapeHtml(data.owner_agent || 'unassigned')}</span></span>
1770
+ <span class="sep">·</span>
1771
+ <span><span class="k">last scan</span> <span class="v">${rel(data.generated_at)}</span></span>
1772
+ <span class="sep">·</span>
1773
+ <span><span class="k">last deep-dive</span> <span class="v">${formatTimestamp(lastDeepDiveMs)}</span></span>
1774
+ <span class="sep">·</span>
1775
+ <span><span class="k">jobs tracked</span> <span class="v">${data.records.length}</span></span>
1776
+ </div>
1777
+ </div>
1778
+ <div class="fh-posture">
1779
+ <div class="fh-stat attn"><div class="n">${attention.length}</div><div class="l">need attention</div></div>
1780
+ <div class="fh-stat clean"><div class="n">${healthy.length}</div><div class="l">clean</div></div>
1781
+ </div>
1782
+ </div>`;
1783
+
1784
+ // ---- score explainer (why the ranking is trustworthy) ----
1785
+ const explain = `
1786
+ <details class="fh-explain">
1787
+ <summary>
1788
+ <span class="caret">▶</span>
1789
+ <span>How the ranking works — impact, not raw event count</span>
1790
+ <span class="formula">priority = <em>severity</em> × <em>frequency</em> × <em>reach</em> × <em>recency</em></span>
1791
+ </summary>
1792
+ <div class="fh-explain-body">
1793
+ <div class="fh-factors">
1794
+ <div class="fh-factor sev"><div class="fn">Severity <span style="color:var(--text-dim);font-weight:400">1–3</span></div><div class="fd">Did the wrong thing or nothing while reporting success = 3. From the failure-mode taxonomy, not a guess.</div></div>
1795
+ <div class="fh-factor freq"><div class="fn">Frequency</div><div class="fd">log₁₀(1 + count) over the 30-day window — 282 vs 20 is a real gap without swamping the rest.</div></div>
1796
+ <div class="fh-factor reach"><div class="fn">Reach</div><div class="fd">Distinct agents hit. A fleet-wide failure outranks a one-agent quirk.</div></div>
1797
+ <div class="fh-factor rec"><div class="fn">Recency</div><div class="fd">1.0 within 24h, decaying to 0.1 at the window edge. A fixed issue sinks; this is what closes the loop.</div></div>
1798
+ </div>
1799
+ <div style="margin-top:0.7rem;font-size:0.78rem;color:var(--text-dim)">Every factor comes from the model-free nightly sensor — no model judgment enters the score. Evidence is <span style="color:var(--green)">hard-artifact</span> (a structured turns.jsonl record or a precise gateway signature), independently verifiable by the turn id.</div>
1800
+ </div>
1801
+ </details>`;
1802
+
1803
+ // ---- attention list ----
1804
+ const rows = attention.map((r, idx) => {
1805
+ const recIssues = issuesOf(r);
1806
+ const sev = worstSev(r);
1807
+ const worst = idx === 0 ? ' worst' : '';
1808
+ // drive the composition strip off the worst OPEN issue
1809
+ const lead = recIssues.filter(i => i.status !== 'closed')
1810
+ .sort((a, b) => b.severity - a.severity || b.frequency - a.frequency)[0] || recIssues[0];
1811
+ // `lead` can be undefined when open_issue_count > 0 but the issues
1812
+ // array is empty/absent — skip the composition strip rather than throw.
1813
+ const f = lead ? factorsFor(lead) : null;
1814
+ const compose = lead && f ? `
1815
+ <div class="fh-compose" title="priority = severity × frequency × reach × recency">
1816
+ <span class="fh-fpill sev"><b>${Number(lead.severity)}</b><span class="lab">sev</span></span>
1817
+ <span class="fh-times">×</span>
1818
+ <span class="fh-fpill freq"><b>${f.freq.toFixed(2)}</b><span class="lab">freq · ${Number(lead.frequency)}</span></span>
1819
+ <span class="fh-times">×</span>
1820
+ <span class="fh-fpill reach"><b>${f.reach}</b><span class="lab">reach</span></span>
1821
+ <span class="fh-times">×</span>
1822
+ <span class="fh-fpill rec"><b>${f.rec.toFixed(1)}</b><span class="lab">recency</span></span>
1823
+ </div>` : '';
1824
+
1825
+ const issues = recIssues.map((iss) => {
1826
+ // Ledger numerics are not coerced server-side; normalize before
1827
+ // interpolating so a non-numeric value can't leak into markup.
1828
+ const occTotal = Number(iss.occ_total ?? iss.frequency ?? (iss.occurrences || []).length) || 0;
1829
+ const freqFrom = iss.freq_from != null ? Number(iss.freq_from) : null;
1830
+ const trend = freqFrom != null
1831
+ ? `<span class="fh-trend"><span class="from">${freqFrom}</span><span class="arrow">→</span><span class="to">${occTotal}</span></span> <span style="opacity:.6">since fix</span>`
1832
+ : `<span class="v">${occTotal}</span> in window`;
1833
+ const occ = (iss.occurrences || []).map((o) => `
1834
+ <div class="occ">
1835
+ <span class="occ-agent">${escapeHtml(o.agent)}</span>
1836
+ <div class="occ-body">
1837
+ <span class="tid">${escapeHtml(o.turn_id)}</span>
1838
+ <span class="ptr">${escapeHtml(o.log_pointer || '').replace(/^([^:]+):/, '<span class="g">$1</span>:')}</span>
1839
+ </div>
1840
+ </div>`).join('');
1841
+ const remaining = occTotal - (iss.occurrences || []).length;
1842
+ const more = remaining > 0 ? `<div class="more">+${remaining} more occurrence${remaining === 1 ? '' : 's'} joined by turn id</div>` : '';
1843
+ const statusCls = iss.status === 'resolved-pending-verify' ? 'verify' : iss.status === 'closed' ? 'closed' : 'open';
1844
+ const statusTxt = iss.status === 'resolved-pending-verify' ? 'verifying' : (iss.status || 'open');
1845
+ const ghLink = iss.gh_issue != null
1846
+ ? `<a class="gh-link" href="https://github.com/switchroom/switchroom/issues/${encodeURIComponent(iss.gh_issue)}" target="_blank" rel="noopener">#${escapeHtml(String(iss.gh_issue))} ↗</a>`
1847
+ : '';
1848
+ return `
1849
+ <div class="fh-issue">
1850
+ <div class="fh-issue-top">
1851
+ <span class="mode-chip s${Number(iss.severity) || 0}">${escapeHtml(iss.failure_mode)}</span>
1852
+ <span class="status-tag ${statusCls}">${escapeHtml(statusTxt)}</span>
1853
+ ${ghLink}
1854
+ </div>
1855
+ <div class="fh-issue-meta">
1856
+ <span><span class="k" style="opacity:.7">freq</span> ${trend}</span>
1857
+ <span><span class="k" style="opacity:.7">reach</span> <span class="reach-agents">${(iss.reach || []).map(a => `<span class="ag">${escapeHtml(a)}</span>`).join('')}</span></span>
1858
+ <span><span class="k" style="opacity:.7">latest</span> <span class="v">${rel(iss.recency)}</span></span>
1859
+ </div>
1860
+ <div class="fh-evidence">
1861
+ <div class="fh-evidence-h">evidence <span class="tier">hard-artifact</span></div>
1862
+ ${iss.detail ? `<div class="fh-why"><span class="fh-why-lab">why it failed</span>${escapeHtml(iss.detail)}</div>` : ''}
1863
+ ${occ}${more}
1864
+ </div>
1865
+ </div>`;
1866
+ }).join('');
1867
+
1868
+ return `
1869
+ <div class="fh-rec${worst}" data-open="0">
1870
+ <div class="fh-rec-head" onclick="this.parentNode.classList.toggle('open')">
1871
+ <div class="fh-rank">
1872
+ <div class="score" style="color:${scoreColor(r.priority_score)}">${r.priority_score.toFixed(1)}</div>
1873
+ <div class="rk">#${String(idx + 1).padStart(2, '0')}</div>
1874
+ </div>
1875
+ <div class="fh-rec-main">
1876
+ <div class="fh-rec-title">
1877
+ <span class="fh-job">${escapeHtml(r.job_spec)}</span>
1878
+ </div>
1879
+ <div class="fh-rec-sub">
1880
+ <span><span class="k" style="opacity:.7">scanned</span> <span class="v">${rel(r.last_scanned)}</span></span>
1881
+ <span class="sep">·</span>
1882
+ <span><span class="k" style="opacity:.7">deep-dive</span> <span class="v">${rel(r.last_deep_dive)}</span></span>
1883
+ </div>
1884
+ ${compose}
1885
+ </div>
1886
+ <div class="fh-rec-right">
1887
+ <span class="sev-badge s${sev}"><span class="dot"></span>${SEV_LABEL[sev]}</span>
1888
+ <span class="issue-count"><b>${r.open_issue_count}</b> open</span>
1889
+ <span class="fh-caret">▶</span>
1890
+ </div>
1891
+ </div>
1892
+ <div class="fh-issues">${issues}</div>
1893
+ </div>`;
1894
+ }).join('');
1895
+
1896
+ // ---- healthy strip ----
1897
+ const healthyCards = healthy.map((r) => `
1898
+ <div class="fh-hc" title="${escapeHtml(r.job_spec)}">
1899
+ <span class="dot"></span>
1900
+ <div class="fh-hc-text">
1901
+ <span class="job">${escapeHtml(r.job_spec)}</span>
1902
+ </div>
1903
+ <span class="when">${rel(r.last_scanned)}</span>
1904
+ </div>`).join('');
1905
+
1906
+ el.innerHTML = `
1907
+ ${top}
1908
+ ${explain}
1909
+ <div class="fh-band">Needs attention <span class="n">${attention.length}</span> · ranked worst-first</div>
1910
+ <div class="fh-attention-list">${rows}</div>
1911
+ <div class="fh-band">Clean <span class="n">${healthy.length}</span> · scanned, no open issues</div>
1912
+ <div class="fh-healthy-grid">${healthyCards}</div>`;
1913
+
1914
+ // Open the worst offender by default so the operator lands on the evidence.
1915
+ el.querySelector('.fh-rec.worst')?.classList.add('open');
1916
+ }
1917
+
1260
1918
  // Canonical tab list — kept in sync with the nav buttons above and with
1261
1919
  // SPA_TAB_ROUTES in src/web/server.ts (the server serves the SPA shell for
1262
1920
  // these paths so a reload/deep-link doesn't 404).
1263
- const TAB_NAMES = ['summary', 'agents', 'accounts', 'system', 'memory', 'connections', 'schedule', 'approvals'];
1921
+ const TAB_NAMES = ['summary', 'agents', 'accounts', 'system', 'memory', 'connections', 'schedule', 'approvals', 'fleet-health'];
1264
1922
 
1265
1923
  // Derive the active tab from the URL path (`/connections` → 'connections',
1266
1924
  // '/' → 'summary'). Unknown paths fall back to summary.
@@ -1291,6 +1949,7 @@
1291
1949
  if (tab === 'connections') fetchConnections();
1292
1950
  if (tab === 'schedule') fetchSchedule();
1293
1951
  if (tab === 'approvals') fetchApprovals();
1952
+ if (tab === 'fleet-health') fetchFleetHealth();
1294
1953
  }
1295
1954
 
1296
1955
  // Back/forward: restore the tab from the history entry (or the path).
@@ -1322,87 +1981,106 @@
1322
1981
  appr = summary.approvals, sched = summary.schedule,
1323
1982
  accts = summary.accounts;
1324
1983
  clearError();
1325
- const tile = (title, body, ok) => `
1326
- <div class="agent-card" style="min-width:220px">
1327
- <div class="card-header" style="cursor:default">
1328
- <span class="status-dot ${ok === null ? '' : ok ? 'active' : 'inactive'}" style="display:inline-block;vertical-align:middle"></span>
1329
- <span class="agent-name">${escapeHtml(title)}</span>
1330
- </div>
1331
- <div style="padding:0 1.25rem 1rem;font-size:0.9rem">${body}</div>
1984
+ el.classList.remove('loading');
1985
+
1986
+ // Stash the broker's fleet-active account label so the Accounts tab can
1987
+ // flag it (there's no fleet-active flag on /api/accounts itself).
1988
+ if (sys && sys.broker && sys.broker.active) fleetActiveLabel = sys.broker.active;
1989
+
1990
+ const agList = Array.isArray(ag) ? ag : [];
1991
+ const up = agList.filter(a => a.active === 'active').length;
1992
+ const attnItems = Array.isArray(summary.attention) ? summary.attention : [];
1993
+ const crit = attnItems.filter(a => a.severity === 'critical').length;
1994
+ const warn = attnItems.filter(a => a.severity === 'warn').length;
1995
+ const attnCount = attnItems.length;
1996
+
1997
+ // ---- posture verdict ----
1998
+ const verdict = attnCount === 0
1999
+ ? `<div class="sm-verdict ok"><span class="pulse"></span><h2>Fleet is healthy</h2></div>`
2000
+ : `<div class="sm-verdict attn"><span class="pulse"></span><h2><b>${crit || attnCount}</b> ${crit ? 'critical' : 'item' + (attnCount === 1 ? '' : 's')}${crit && warn ? ` · ${warn} warning${warn === 1 ? '' : 's'}` : ''} need${(crit || attnCount) === 1 ? 's' : ''} you</h2></div>`;
2001
+ const acctCount = (sys && sys.broker && sys.broker.accounts != null) ? sys.broker.accounts : '—';
2002
+ const asOfVal = (typeof summary.dataAsOf === 'number' && summary.dataAsOf > 0)
2003
+ ? formatTimestamp(summary.dataAsOf) : '—';
2004
+ const asOf = `<div class="sm-asof">data as of ${escapeHtml(asOfVal)} · ${agList.length} agents · ${escapeHtml(String(acctCount))} accounts</div>`;
2005
+
2006
+ // ---- attention hero (renderAttention builds the worst-first list) ----
2007
+ const attnHtml = renderAttention(attnItems);
2008
+
2009
+ // ---- vitals: agents + quota ----
2010
+ const dots = agList.map(a => `<span class="d ${a.active === 'active' ? 'up' : 'down'}" title="${escapeHtml(a.name)} — ${escapeHtml(a.active)}"></span>`).join('');
2011
+ const down = agList.length - up;
2012
+ const agentsCard = `
2013
+ <div class="sm-vcard">
2014
+ <div class="vh">Agents <a onclick="switchTab('agents')">agents →</a></div>
2015
+ <div class="sm-agents-n">${up}<span class="of"> / ${agList.length}</span> <span class="of">active</span></div>
2016
+ <div class="sm-agents-sub">${down === 0 ? 'all running' : `${down} down — ${agList.filter(a => a.active !== 'active').map(a => escapeHtml(a.name)).join(', ')}`}</div>
2017
+ <div class="sm-dotrow">${dots}</div>
1332
2018
  </div>`;
1333
- const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
1334
2019
 
1335
- // Agents up/total
1336
- let agentsTile;
1337
- if (Array.isArray(ag)) {
1338
- const up = ag.filter(x => x.active === 'active').length;
1339
- agentsTile = tile('Agents', `<strong>${up}/${ag.length}</strong> active`, up === ag.length);
1340
- } else agentsTile = tile('Agents', dim('unavailable'), null);
1341
-
1342
- // Broker / hindsight / hostd from system-health
1343
- let brokerTile, hsTile, hostdTile;
1344
- if (sys && sys.broker) {
1345
- brokerTile = tile('Auth-broker',
1346
- sys.broker.reachable
1347
- ? `reachable · active <strong>${escapeHtml(sys.broker.active || '—')}</strong><br>${sys.broker.accounts ?? '?'} accts · ${sys.broker.agents ?? '?'} agents`
1348
- : `<span style="color:var(--red)">unreachable</span>`,
1349
- !!sys.broker.reachable);
1350
- } else brokerTile = tile('Auth-broker', dim('unavailable'), null);
1351
- if (sys && sys.hindsight) {
1352
- hsTile = tile('Hindsight',
1353
- sys.hindsight.running
1354
- ? `running<br>${escapeHtml(sys.hindsight.model || '?')} · ${sys.hindsight.mcpStateless == null ? '?' : sys.hindsight.mcpStateless ? 'stateless' : 'stateful'}`
1355
- : `<span style="color:var(--red)">not running</span>`,
1356
- !!sys.hindsight.running);
1357
- } else hsTile = tile('Hindsight', dim('unavailable'), null);
1358
- if (sys && sys.hostd) {
1359
- hostdTile = tile('Hostd',
1360
- sys.hostd.auditLogPresent
1361
- ? `audit present · ${(sys.hostd.recent || []).length} recent`
1362
- : dim('no audit log yet'),
1363
- sys.hostd.auditLogPresent ? true : null);
1364
- } else hostdTile = tile('Hostd', dim('unavailable'), null);
1365
-
1366
- // Approvals
1367
- let apprTile;
1368
- if (appr && appr.reachable !== false) {
1369
- const d = appr.decisions || [];
1370
- const active = d.filter(x => !x.revoked_at && !(x.ttl_expires_at && x.ttl_expires_at < Date.now())).length;
1371
- apprTile = tile('Approvals', `<strong>${active}</strong> active · ${d.length} total`, true);
1372
- } else apprTile = tile('Approvals', dim((appr && appr.error) ? 'kernel unreachable' : 'unavailable'), null);
1373
-
1374
- // Schedule
1375
- let schedTile;
1376
- if (sched && Array.isArray(sched.entries)) {
1377
- const agents = new Set(sched.entries.map(e => e.agent));
1378
- schedTile = tile('Schedule', `<strong>${sched.entries.length}</strong> cron entr${sched.entries.length === 1 ? 'y' : 'ies'} · ${agents.size} agent(s)`, true);
1379
- } else schedTile = tile('Schedule', dim('unavailable'), null);
1380
-
1381
- // Quota headroom — worst cached 5h/7d across accounts (no probe;
1382
- // shows "—" until someone refreshes on the Accounts tab).
1383
- let quotaTile;
1384
- if (Array.isArray(accts) && accts.length) {
1385
- const used = accts.filter(a => a.quotaUsage);
1386
- if (used.length === 0) {
1387
- quotaTile = tile('Quota', dim('not probed yet — see Accounts tab'), null);
1388
- } else {
1389
- const worst5 = Math.max(...used.map(a => a.quotaUsage.fiveHourPct));
1390
- const worst7 = Math.max(...used.map(a => a.quotaUsage.sevenDayPct));
1391
- const anyStale = accts.some(a => a.quotaStale);
1392
- quotaTile = tile('Quota (worst acct)',
1393
- `5h <strong>${Math.round(worst5)}%</strong> · 7d <strong>${Math.round(worst7)}%</strong>${anyStale ? '<br>' + dim('some stale') : ''}`,
1394
- worst5 < 90 && worst7 < 90);
1395
- }
1396
- } else quotaTile = tile('Quota', dim('unavailable'), null);
2020
+ // Quota — worst probed account, computed client-side from the accounts
2021
+ // payload (same Math.max(5h,7d) worst-across-accounts as the old tile).
2022
+ const qband = (p) => p >= 90 ? 'hi' : p >= 70 ? 'mid' : 'lo';
2023
+ const accts2 = Array.isArray(accts) ? accts : [];
2024
+ const usedAccts = accts2.filter(a => a.quotaUsage);
2025
+ let quotaCard;
2026
+ if (accts2.length === 0) {
2027
+ quotaCard = `<div class="sm-vcard"><div class="vh">Quota headroom <a onclick="switchTab('accounts')">accounts →</a></div><div class="sm-quota-acct">unavailable</div></div>`;
2028
+ } else if (usedAccts.length === 0) {
2029
+ quotaCard = `<div class="sm-vcard"><div class="vh">Quota headroom <a onclick="switchTab('accounts')">accounts →</a></div><div class="sm-quota-acct">not probed yet — see accounts tab</div></div>`;
2030
+ } else {
2031
+ const pk = (a) => a.quota && a.quota.exhausted ? 1000 : Math.max(a.quotaUsage.fiveHourPct, a.quotaUsage.sevenDayPct);
2032
+ const worst = usedAccts.slice().sort((a, b) => pk(b) - pk(a))[0];
2033
+ const q = {
2034
+ label: worst.label,
2035
+ fiveHourPct: worst.quotaUsage.fiveHourPct,
2036
+ sevenDayPct: worst.quotaUsage.sevenDayPct,
2037
+ exhausted: !!(worst.quota && worst.quota.exhausted),
2038
+ exhaustedUntil: (worst.quota && worst.quota.exhausted_until) || null,
2039
+ };
2040
+ const gauge = (lab, pct, reset) => {
2041
+ const b = qband(pct);
2042
+ return `<div class="gauge">
2043
+ <div class="gauge-top"><span class="lab">${lab}${reset ? `<span class="gauge-reset">resets ${escapeHtml(formatTimestamp(reset))}</span>` : ''}</span><span class="val ${b}">${Math.round(pct)}%</span></div>
2044
+ <div class="gauge-track"><span class="gauge-fill ${b}" style="width:${Math.min(100, pct)}%"></span></div>
2045
+ </div>`;
2046
+ };
2047
+ quotaCard = `
2048
+ <div class="sm-vcard">
2049
+ <div class="vh">Quota headroom <a onclick="switchTab('accounts')">accounts →</a></div>
2050
+ <div class="sm-quota-acct">worst account · <b>${escapeHtml(q.label || '—')}</b>${q.exhausted ? ` · <span style="color:var(--red)">exhausted</span>` : ''}</div>
2051
+ ${gauge('5-hour window', q.fiveHourPct ?? 0, q.exhausted ? q.exhaustedUntil : null)}
2052
+ ${gauge('7-day window', q.sevenDayPct ?? 0, null)}
2053
+ </div>`;
2054
+ }
1397
2055
 
1398
- el.classList.remove('loading');
1399
- // "data as of" the summary's freshness is its OLDEST part (server
1400
- // sends the min dataAsOf). A tiny muted line; refreshed on every
1401
- // tab-open. `formatTimestamp` renders it as "Ns/Nm ago".
1402
- const asOf = (typeof summary.dataAsOf === 'number' && summary.dataAsOf > 0)
1403
- ? `<div style="color:var(--text-dim);font-size:.75rem;margin:.25rem 0 .5rem">data as of ${escapeHtml(formatTimestamp(summary.dataAsOf))}</div>`
1404
- : '';
1405
- el.innerHTML = `${renderAttention(summary.attention)}${asOf}<div class="agents-grid">${agentsTile}${brokerTile}${hsTile}${hostdTile}${apprTile}${schedTile}${quotaTile}</div>`;
2056
+ // ---- services rail ----
2057
+ const svc = (name, ok, meta, idle) => `
2058
+ <div class="sm-svc"><span class="dot ${idle ? 'idle' : ok ? 'ok' : 'bad'}"></span>
2059
+ <div class="sm-svc-text"><div class="sm-svc-name">${escapeHtml(name)}</div><div class="sm-svc-meta">${meta}</div></div></div>`;
2060
+ const b = (sys && sys.broker) || {}, hs = (sys && sys.hindsight) || {}, hd = (sys && sys.hostd) || {};
2061
+ // Approvals active grants — same non-revoked/non-expired count the old
2062
+ // tile used (the payload carries `decisions`, not a pre-derived count).
2063
+ const apprReachable = !!(appr && appr.reachable !== false);
2064
+ const apprDecisions = (appr && appr.decisions) || [];
2065
+ const activeGrants = apprDecisions.filter(x => !x.revoked_at && !(x.ttl_expires_at && x.ttl_expires_at < Date.now())).length;
2066
+ const schedEntries = (sched && Array.isArray(sched.entries)) ? sched.entries : [];
2067
+ const schedAgents = new Set(schedEntries.map(e => e.agent)).size;
2068
+ const services = [
2069
+ svc('auth-broker', !!b.reachable, b.reachable ? `active ${escapeHtml(b.active || '—')} · ${b.accounts ?? '?'} accts` : 'unreachable'),
2070
+ svc('hindsight', !!hs.running, hs.running ? `${escapeHtml(hs.model || '?')} · ${hs.mcpStateless ? 'stateless' : 'stateful'}` : 'not running'),
2071
+ svc('hostd', !!hd.auditLogPresent, hd.auditLogPresent ? `${(hd.recent || []).length} recent verbs` : 'no audit log'),
2072
+ svc('schedule', schedEntries.length > 0, `${schedEntries.length} crons · ${schedAgents} agents`),
2073
+ svc('approvals', apprReachable, apprReachable ? `${activeGrants} active grants` : 'unreachable'),
2074
+ ].join('');
2075
+
2076
+ el.innerHTML = `
2077
+ ${verdict}${asOf}
2078
+ <div class="sm-band">Needs attention <span class="n">${attnCount}</span> · worst-first</div>
2079
+ <div class="sm-attn-list">${attnHtml}</div>
2080
+ <div class="sm-band">Fleet vitals</div>
2081
+ <div class="sm-vitals">${agentsCard}${quotaCard}</div>
2082
+ <div class="sm-band">Services <span class="n">all nominal</span></div>
2083
+ <div class="sm-services">${services}</div>`;
1406
2084
  }
1407
2085
 
1408
2086
  // "Needs attention" triage strip — the FIRST block on the Summary tab.
@@ -1413,20 +2091,24 @@
1413
2091
  // "nothing needs attention" line. Every interpolated value is escaped.
1414
2092
  function renderAttention(items) {
1415
2093
  if (!Array.isArray(items) || items.length === 0) {
1416
- return `<div class="problem muted" style="margin-bottom:.75rem"><div class="problem-title">✓ Nothing needs attention</div></div>`;
2094
+ return `<button class="sm-attn calm"><span class="sev" style="color:var(--green);background:rgba(52,211,153,.12)">clear</span><div class="sm-attn-body"><div class="sm-attn-title">Nothing needs attention</div><div class="sm-attn-detail">Every job spec, account, and service is within tolerance.</div></div></button>`;
1417
2095
  }
1418
- const rows = items.map((it) => {
2096
+ const rank = { critical: 0, warn: 1, info: 2 };
2097
+ const sorted = items.slice().sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9));
2098
+ return sorted.map((it) => {
1419
2099
  const sev = (it && it.severity) || 'info';
1420
- const muted = sev === 'info' ? ' muted' : '';
1421
2100
  const tab = String((it && it.tab) || 'summary');
1422
- const title = `<div class="problem-title">${escapeHtml((it && it.title) || 'Attention')}</div>`;
1423
- const detail = it && it.detail
1424
- ? `<div class="problem-detail">${escapeHtml(it.detail)}</div>`
1425
- : '';
1426
2101
  // Whole row clickable → jump to the tab to act on it.
1427
- return `<div class="problem${muted}" role="button" tabindex="0" style="cursor:pointer;margin-bottom:.4rem" onclick="switchTab('${escapeHtml(tab)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchTab('${escapeHtml(tab)}')}">${title}${detail}</div>`;
2102
+ return `
2103
+ <button class="sm-attn ${escapeHtml(sev)}" onclick="switchTab('${escapeHtml(tab)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchTab('${escapeHtml(tab)}')}">
2104
+ <span class="sev">${escapeHtml(sev)}</span>
2105
+ <div class="sm-attn-body">
2106
+ <div class="sm-attn-title">${escapeHtml((it && it.title) || 'Attention')}</div>
2107
+ ${it && it.detail ? `<div class="sm-attn-detail">${escapeHtml(it.detail)}</div>` : ''}
2108
+ </div>
2109
+ <span class="sm-attn-go">${escapeHtml(tab)} →</span>
2110
+ </button>`;
1428
2111
  }).join('');
1429
- return `<div style="margin-bottom:.75rem">${rows}</div>`;
1430
2112
  }
1431
2113
 
1432
2114
  function showError(msg) {
@@ -1828,124 +2510,121 @@
1828
2510
  }
1829
2511
 
1830
2512
  function renderAccounts() {
1831
- const container = document.getElementById('accounts');
2513
+ const el = document.getElementById('accounts');
1832
2514
  if (!accounts || accounts.length === 0) {
1833
- container.innerHTML = '<div class="loading">No accounts. Run <code>switchroom auth account add &lt;label&gt;</code>.</div>';
2515
+ el.innerHTML = '<div class="loading">No accounts. Run <code>switchroom auth account add &lt;label&gt;</code>.</div>';
1834
2516
  return;
1835
2517
  }
1836
- // RFC-H shape: the API returns `usedBy` (agents bound via the
1837
- // fleet-active account or a per-agent override) and a broker
1838
- // `quota` AccountState ({exhausted, exhausted_until,
1839
- // threshold_violations, last_refreshed_at}). The pre-RFC-H
1840
- // primaryFor/fallbackFor lists and quota.fiveHourPct utilization
1841
- // fields no longer exist on the wire.
1842
- // usage % cell: live 5h/7d utilization from the last cached
1843
- // probe (cost-gated see refreshQuota). null "—" + a per-
1844
- // account that force-probes. quotaStale value shown muted
1845
- // with to refresh. When `resetAt` is provided we append a
1846
- // muted "resets in Xh" line so the operator can see WHEN the
1847
- // window rolls over without hovering for a tooltip — matches
1848
- // anthropic-ratelimit-unified-{5h,7d}-reset headers exposed by
1849
- // the broker probe (src/auth/quota.ts:97-98).
1850
- const pctCell = (pct, label, stale, resetAt) => {
1851
- if (pct == null) return '<span style="color:var(--text-dim)">—</span>';
1852
- let cls = 'quota-pct';
1853
- if (pct >= 90) cls += ' high';
1854
- else if (pct >= 70) cls += ' mid';
1855
- const v = `${Math.round(pct)}%`;
1856
- const reset = resetAt
1857
- ? `<div style="font-size:.72rem;color:var(--text-dim);font-weight:normal;margin-top:.1rem">resets ${formatTimestamp(resetAt)}</div>`
1858
- : '';
1859
- const pctSpan = stale
1860
- ? `<span class="${cls}" title="stale — click ↻" style="opacity:.55">${v}</span>`
1861
- : `<span class="${cls}">${v}</span>`;
1862
- return reset ? `<div>${pctSpan}${reset}</div>` : pctSpan;
2518
+ // Worst-first quota triage. RFC-H shape: the API returns `usedBy`
2519
+ // (agents bound via the fleet-active account or a per-agent override),
2520
+ // a broker `quota` AccountState ({exhausted, exhausted_until,
2521
+ // threshold_violations}), `quotaUsage` (5h/7d + reset/capturedAt ms),
2522
+ // `quotaUsageSource`, `quotaStale`, `health`, and `expiresAt` (ms).
2523
+ // Timestamps are already epoch-ms on the wire, so formatTimestamp
2524
+ // takes them directly (the mock's relTime maps to formatTimestamp).
2525
+ const qband = (p) => p == null ? 'na' : p >= 90 ? 'hi' : p >= 70 ? 'mid' : 'lo';
2526
+ // health badge bucket (exhausted always wins). Server sends
2527
+ // "missing-credentials"/"missing-refresh-token" collapse to "missing".
2528
+ const healthBucket = (a) => {
2529
+ if (a.quota && a.quota.exhausted) return 'exhausted';
2530
+ const h = a.health || '';
2531
+ if (h === 'expired') return 'expired';
2532
+ if (h.indexOf('missing') === 0) return 'missing';
2533
+ return 'healthy';
1863
2534
  };
1864
- const enriched = accounts.map(a => {
1865
- const q = a.quota || null;
1866
- const u = a.quotaUsage || null;
1867
- const exhausted = q ? !!q.exhausted : null;
1868
- return {
1869
- a,
1870
- usedBy: a.usedBy || [],
1871
- fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale, u ? u.fiveHourResetAt : null),
1872
- sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale, u ? u.sevenDayResetAt : null),
1873
- captured: u && u.capturedAt
1874
- ? `${formatTimestamp(u.capturedAt)}${a.quotaUsageSource === 'broker-cache' ? '<div style="font-size:.7rem;color:var(--text-dim)">broker cache (free)</div>' : ''}`
1875
- : '<span style="color:var(--text-dim)">never</span>',
1876
- exhaustedCell: q == null
1877
- ? '<span style="color:var(--text-dim)">broker offline</span>'
1878
- : exhausted
1879
- ? `<span class="quota-pct high">exhausted${q.exhausted_until ? '' + formatTimestamp(q.exhausted_until) : ''}</span>`
1880
- : '<span class="quota-pct">ok</span>',
1881
- violations: q && q.threshold_violations
1882
- ? `<span class="quota-pct mid">${q.threshold_violations}</span>`
1883
- : '<span style="color:var(--text-dim)">0</span>',
1884
- expires: formatTimestamp(a.expiresAt),
1885
- };
1886
- });
1887
- const refreshBtn = (label) =>
1888
- `<button class="btn" title="Probe live quota now (billed Anthropic call)" onclick="refreshQuota('${escapeHtml(label)}', true)">↻</button>`;
1889
- const rows = enriched.map(e => {
1890
- const { a, usedBy, fiveH, sevenD, captured, exhaustedCell, violations, expires } = e;
1891
- return `
1892
- <tr>
1893
- <td>${escapeHtml(a.label)}</td>
1894
- <td><span class="health-badge ${escapeHtml(a.health)}">${escapeHtml(a.health)}</span></td>
1895
- <td>${renderUsedByCell(usedBy)}</td>
1896
- <td>${fiveH}</td>
1897
- <td>${sevenD}</td>
1898
- <td title="quota probed at">${captured}</td>
1899
- <td>${exhaustedCell}</td>
1900
- <td>${violations}</td>
1901
- <td>${expires}</td>
1902
- <td>${refreshBtn(a.label)} <button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button></td>
1903
- </tr>
1904
- `;
1905
- }).join('');
1906
- const cards = enriched.map(e => {
1907
- const { a, usedBy, fiveH, sevenD, captured, exhaustedCell, violations, expires } = e;
2535
+ const isExpiredish = (a) => a.health === 'expired' || (a.health || '').indexOf('missing') === 0;
2536
+ // higher = more urgent → sorts to top
2537
+ const pressure = (a) => {
2538
+ if (a.quota && a.quota.exhausted) return 100 + ((a.quotaUsage && a.quotaUsage.fiveHourPct) || 0);
2539
+ // expired-but-unused isn't more urgent than a near-limit account that's
2540
+ // actively serving agents — rank it below live quota pressure.
2541
+ if (isExpiredish(a)) return (a.usedBy && a.usedBy.length ? 95 : 80);
2542
+ const u = a.quotaUsage;
2543
+ return u ? Math.max(u.fiveHourPct, u.sevenDayPct) : -1;
2544
+ };
2545
+ const needsAttention = (a) => (a.quota && a.quota.exhausted) || isExpiredish(a)
2546
+ || (a.quotaUsage && Math.max(a.quotaUsage.fiveHourPct, a.quotaUsage.sevenDayPct) >= 85);
2547
+
2548
+ const gauge = (lab, pct, reset, stale) => {
2549
+ const b = qband(pct);
2550
+ const val = pct == null ? '' : Math.round(pct) + '%';
2551
+ return `<div class="gauge ${stale ? 'stale' : ''}">
2552
+ <div class="gauge-top"><span class="lab">${lab}${reset && pct != null ? `<span class="gauge-reset">resets ${escapeHtml(formatTimestamp(reset))}</span>` : ''}</span><span class="val ${b}">${val}${stale ? ' ·stale' : ''}</span></div>
2553
+ <div class="gauge-track"><span class="gauge-fill ${b}" style="width:${pct == null ? 0 : Math.min(100, pct)}%"></span></div>
2554
+ </div>`;
2555
+ };
2556
+
2557
+ const sorted = accounts.slice().sort((a, b) => pressure(b) - pressure(a));
2558
+ const attention = sorted.filter(needsAttention);
2559
+ const clean = sorted.filter(a => !needsAttention(a));
2560
+
2561
+ const card = (a, worst) => {
2562
+ const hb = healthBucket(a);
2563
+ const hlabel = (a.quota && a.quota.exhausted) ? 'quota-exhausted' : (a.health || 'healthy');
2564
+ const fleetActive = fleetActiveLabel != null && a.label === fleetActiveLabel;
2565
+ const usedBy = a.usedBy || [];
2566
+ // alert callout
2567
+ let alert = '';
2568
+ if (a.quota && a.quota.exhausted) {
2569
+ alert = `<div class="acct-alert crit"><span class="lab">quota exhausted</span>5-hour window is spent — bound agents' next turns will fail until it resets <b>${escapeHtml(formatTimestamp(a.quota.exhausted_until))}</b> (${escapeHtml(usedBy.join(', ')) || 'no agents bound'}).</div>`;
2570
+ } else if (isExpiredish(a)) {
2571
+ alert = `<div class="acct-alert crit"><span class="lab">token expired</span>Re-auth needed — <code>switchroom auth account refresh ${escapeHtml(a.label)}</code>.${usedBy.length ? '' : ' No agents are currently bound, so nothing is failing yet.'}</div>`;
2572
+ } else if (a.quotaUsage && Math.max(a.quotaUsage.fiveHourPct, a.quotaUsage.sevenDayPct) >= 85) {
2573
+ alert = `<div class="acct-alert warn"><span class="lab">approaching limit</span>7-day window at ${Math.round(a.quotaUsage.sevenDayPct)}% — expect throttling for ${escapeHtml(usedBy.join(', ')) || 'bound agents'} soon.</div>`;
2574
+ }
2575
+
2576
+ const u = a.quotaUsage;
2577
+ const quota = u
2578
+ ? `<div class="acct-quota">
2579
+ ${gauge('5-hour', u.fiveHourPct, u.fiveHourResetAt, a.quotaStale)}
2580
+ ${gauge('7-day', u.sevenDayPct, u.sevenDayResetAt, a.quotaStale)}
2581
+ </div>`
2582
+ : `<div class="acct-quota"><div style="grid-column:1/-1;font-size:.8rem;color:var(--text-dim)">Quota not probed${isExpiredish(a) ? ' — token expired' : ''}. <span style="color:var(--blue);cursor:pointer" onclick="refreshQuota('${escapeHtml(a.label)}', true)">↻ probe now</span></div></div>`;
2583
+
2584
+ const usedByHtml = usedBy.length
2585
+ ? `<span class="used-by">${usedBy.map(n => `<span class="ag">${escapeHtml(n)}</span>`).join('')}</span>`
2586
+ : `<span class="used-by"><span class="none">unused</span></span>`;
2587
+ const probed = (u && u.capturedAt)
2588
+ ? `<span class="probed">${escapeHtml(formatTimestamp(u.capturedAt))} <span class="src">${a.quotaUsageSource === 'broker-cache' ? '(cache, free)' : '(live probe)'}</span></span>`
2589
+ : `<span class="probed">never</span>`;
2590
+ const viol = (a.quota && a.quota.threshold_violations)
2591
+ ? `<span style="color:var(--yellow)">${a.quota.threshold_violations}</span>` : '0';
2592
+
1908
2593
  return `
1909
- <div class="account-card">
1910
- <div class="account-card-header">
1911
- <div class="account-label">${escapeHtml(a.label)}</div>
1912
- <span class="health-badge ${escapeHtml(a.health)}" style="margin-left:auto">${escapeHtml(a.health)}</span>
1913
- </div>
1914
- <div class="account-usage">${renderUsedByCell(usedBy)}</div>
1915
- <div class="card-meta" style="padding:0">
1916
- <div class="meta-item"><label>5h usage </label><span>${fiveH}</span></div>
1917
- <div class="meta-item"><label>7d usage </label><span>${sevenD}</span></div>
1918
- <div class="meta-item"><label>Probed </label><span>${captured}</span></div>
1919
- <div class="meta-item"><label>Quota </label><span>${exhaustedCell}</span></div>
1920
- <div class="meta-item"><label>Threshold viols </label><span>${violations}</span></div>
1921
- <div class="meta-item"><label>Expires </label><span>${expires}</span></div>
1922
- </div>
1923
- <div style="margin-top:0.75rem">
1924
- ${refreshBtn(a.label)}
1925
- <button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button>
1926
- </div>
1927
- </div>
1928
- `;
1929
- }).join('');
1930
- container.innerHTML = `
1931
- <div style="margin-bottom:0.6rem;display:flex;align-items:center;gap:0.75rem">
2594
+ <div class="acct ${worst ? 'worst' : ''}">
2595
+ <div class="acct-head">
2596
+ <span class="acct-label">${escapeHtml(a.label)}</span>
2597
+ ${fleetActive ? `<span class="acct-fleet">fleet-active</span>` : ''}
2598
+ <span class="health-badge ${hb}"><span class="dot"></span>${escapeHtml(hlabel)}</span>
2599
+ <span class="acct-actions">
2600
+ <button class="btn" title="Probe live quota now (billed Anthropic call)" onclick="refreshQuota('${escapeHtml(a.label)}', true)">↻ Probe</button>
2601
+ ${fleetActive ? '' : `<button class="btn" onclick="openPromoteModal('${escapeHtml(a.label)}')">Make fleet-active…</button>`}
2602
+ </span>
2603
+ </div>
2604
+ ${alert}
2605
+ ${quota}
2606
+ <div class="acct-foot">
2607
+ <span><span class="k">used by</span> ${usedByHtml}</span>
2608
+ <span><span class="k">probed</span> ${probed}</span>
2609
+ <span><span class="k">threshold viols</span> ${viol}</span>
2610
+ <span><span class="k">token expires</span> ${escapeHtml(formatTimestamp(a.expiresAt))}</span>
2611
+ </div>
2612
+ </div>`;
2613
+ };
2614
+
2615
+ el.innerHTML = `
2616
+ <div class="ac-bar">
1932
2617
  <button class="btn" onclick="refreshQuota(null, true)">↻ Refresh all quota</button>
1933
- <span style="font-size:0.78rem;color:var(--text-dim)">
1934
- 5h/7d usage is cached (≤10 min); ↻ forces a live probe (one billed Anthropic call per account).
2618
+ <span class="hint">5h/7d usage is cached (≤10 min); ↻ forces a live probe — one billed call per account.</span>
2619
+ <span class="ac-posture">
2620
+ <span class="ac-stat attn"><span class="n">${attention.length}</span><span class="l">need attention</span></span>
2621
+ <span class="ac-stat clean"><span class="n">${clean.length}</span><span class="l">healthy</span></span>
1935
2622
  </span>
1936
2623
  </div>
1937
- <div class="accounts-table-wrap">
1938
- <table class="accounts-table">
1939
- <thead><tr>
1940
- <th>Label</th><th>Health</th><th>Used by</th>
1941
- <th>5h&nbsp;%</th><th>7d&nbsp;%</th><th>Probed</th>
1942
- <th>Quota</th><th>Threshold viols</th><th>Expires</th><th></th>
1943
- </tr></thead>
1944
- <tbody>${rows}</tbody>
1945
- </table>
1946
- </div>
1947
- <div class="accounts-grid">${cards}</div>
1948
- `;
2624
+ ${attention.length ? `<div class="ac-band">Needs attention <span class="n">${attention.length}</span> · worst-first</div>
2625
+ <div class="ac-list">${attention.map((a, i) => card(a, i === 0)).join('')}</div>` : ''}
2626
+ <div class="ac-band">Healthy <span class="n">${clean.length}</span> · within tolerance</div>
2627
+ <div class="ac-list">${clean.map(a => card(a, false)).join('') || '<div style="color:var(--text-dim);font-size:.85rem">No healthy accounts.</div>'}</div>`;
1949
2628
  }
1950
2629
 
1951
2630
  // Refresh cached quota. label=null → all accounts. force=true →
@@ -1970,22 +2649,6 @@
1970
2649
  }
1971
2650
  }
1972
2651
 
1973
- function renderUsedByCell(usedBy) {
1974
- // RFC-H: a single fleet-active account plus optional per-agent
1975
- // overrides. `usedBy` is the flat list of agents currently bound
1976
- // to this account (either via fleet-active or an override). No
1977
- // more primary/fallback split — binding is singular post-RFC-H.
1978
- if (!usedBy || usedBy.length === 0) {
1979
- return '<span style="color:var(--text-dim)">unused</span>';
1980
- }
1981
- const MAX_INLINE = 4;
1982
- const title = `bound: ${usedBy.join(', ')}`;
1983
- const shown = usedBy.length <= MAX_INLINE
1984
- ? usedBy.map(escapeHtml).join(', ')
1985
- : `${usedBy.slice(0, MAX_INLINE).map(escapeHtml).join(', ')} +${usedBy.length - MAX_INLINE}`;
1986
- return `<span class="usage-pill primary" title="${escapeHtml(title)}">${shown}</span>`;
1987
- }
1988
-
1989
2652
  function renderSystemHealth(h) {
1990
2653
  const container = document.getElementById('system');
1991
2654
  const b = h.broker || {};