@lelouchhe/webagent 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ import{b as m,k as f,l as g}from"./chunk.IJM5DBCO.js";import"./chunk.D4ZYHJAM.js";import"./chunk.AJZBJBMO.js";var C=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function u(t){return String(t).padStart(2,"0")}function h(t,r){if(!t)return"";let s=new Date(t),a=s.getTime();if(isNaN(a))return"";let l=Math.floor((r.getTime()-a)/1e3);if(l<60)return"just now";let i=Math.floor(l/60);if(i<60)return`${i}m ago`;let o=Math.floor(i/60);if(o<24)return`${o}h ago`;let e=Math.floor(o/24);if(e<7)return`${e}d ago`;let n=s.getUTCFullYear()===r.getUTCFullYear(),c=C[s.getUTCMonth()],d=s.getUTCDate();return n?`${c} ${d}`:`${c} ${d}, ${s.getUTCFullYear()}`}function p(t){if(!t)return"";let r=new Date(t);return isNaN(r.getTime())?"":`${r.getUTCFullYear()}-${u(r.getUTCMonth()+1)}-${u(r.getUTCDate())} ${u(r.getUTCHours())}:${u(r.getUTCMinutes())} UTC`}function y(t){let r=/^\/api\/v1\/sessions\/[^/]+\/attachments\/([A-Za-z0-9._-]+)(?:\?.*)?$/;return s=>{let a=r.exec(s);return a?`/s/${encodeURIComponent(t)}/attachments/${a[1]}`:s}}function E(t){if(typeof t.data=="string")try{return JSON.parse(t.data)}catch{return{}}return t.data}function T(t,r,s){let a=new Set;for(let n of t)if(n.type==="permission_response")try{let c=JSON.parse(n.data);typeof c.requestId=="string"&&a.add(c.requestId)}catch{}let l=new Map,i=new Map,o=null,e={rewriteAttachmentSrc:y(s),enhanceMarkdown:m,findToolCallEl:n=>l.get(n)??null,findPermissionEl:n=>i.get(n)??null,findBashEl:()=>o,isPermissionResolved:n=>a.has(n)};for(let n of t){if(!f(n.type))continue;let c=E(n),d=g(n.type,c,e);d?(r.appendChild(d),n.type==="tool_call"&&typeof c.id=="string"?l.set(c.id,d):n.type==="permission_request"&&typeof c.requestId=="string"?i.set(c.requestId,d):n.type==="bash_command"&&(o=d)):n.type==="bash_result"&&(o=null)}}async function v(){try{let e=localStorage.getItem("theme")??"auto";document.documentElement.setAttribute("data-theme",e)}catch{}let t=/^\/s\/([A-Za-z0-9_-]{24})(?:[/?#]|$)/.exec(location.pathname);if(!t){document.body.textContent="invalid share URL";return}let r=t[1],s=document.getElementById("session-info"),a=document.getElementById("messages"),l=document.querySelector(".share-footer-author"),i=document.querySelector(".share-footer-meta");if(!a)return;let o;try{let e=await fetch(`/api/v1/shared/${encodeURIComponent(r)}/events`,{headers:{Accept:"application/json"},credentials:"omit"});if(e.status===410){document.body.innerHTML="<div class='share-gone'>\u6B64\u94FE\u63A5\u5DF2\u64A4\u9500\u6216\u8FC7\u671F\u3002</div>";return}if(!e.ok){document.body.textContent=`error ${e.status}`;return}o=await e.json()}catch(e){document.body.textContent=`failed to load: ${String(e)}`;return}if(document.title=o.share.session_title?`${o.share.session_title} \u2014 shared`:"shared session",s&&(s.textContent=o.share.session_title??"(untitled)"),l){let e=o.share.display_name??"";l.textContent=e?`by ${e}`:""}if(T(o.events,a,r),i){let e=o.share.shared_at??o.share.created_at;i.textContent=h(e,new Date),i instanceof HTMLElement&&(i.title=p(e))}}v();
package/dist/login.html CHANGED
@@ -17,7 +17,7 @@
17
17
  media="(prefers-color-scheme: light)"
18
18
  />
19
19
  <script src="/theme-init.js"></script>
20
- <link rel="stylesheet" href="/styles.012p32dz.css" />
20
+ <link rel="stylesheet" href="/styles.00xfh3e6.css" />
21
21
  </head>
22
22
  <body class="login-page">
23
23
  <div class="login-card">
@@ -16,9 +16,10 @@
16
16
  media="(prefers-color-scheme: light)"
17
17
  />
18
18
  <meta name="robots" content="noindex, nofollow" />
19
- <link rel="stylesheet" href="/s/_/styles.012p32dz.css" />
19
+ <link rel="stylesheet" href="/s/_/styles.00xfh3e6.css" />
20
20
  <link rel="stylesheet" href="/s/_/share-viewer.00gubshk.css" />
21
- <link rel="modulepreload" href="/s/_/chunk.CGWFHJI2.js">
21
+ <link rel="modulepreload" href="/s/_/chunk.IJM5DBCO.js">
22
+ <link rel="modulepreload" href="/s/_/chunk.D4ZYHJAM.js">
22
23
  <link rel="modulepreload" href="/s/_/chunk.AJZBJBMO.js">
23
24
  </head>
24
25
  <body class="share-viewer">
@@ -48,6 +49,6 @@
48
49
  >
49
50
  </div>
50
51
  </footer>
51
- <script type="module" src="/s/_/viewer.6DT53STL.js"></script>
52
+ <script type="module" src="/s/_/viewer.FCSSTUVY.js"></script>
52
53
  </body>
53
54
  </html>
@@ -15,6 +15,8 @@
15
15
  --permission-bg: #1c1305;
16
16
  --permission-title: #d29922;
17
17
  --accent-soft: rgba(88, 166, 255, 0.18);
18
+ --user-image-max-width: 200px;
19
+ --user-image-max-height: 150px;
18
20
  }
19
21
  [data-theme="light"] {
20
22
  --bg: #ffffff;
@@ -485,10 +487,19 @@ body {
485
487
  border-left: 3px solid var(--blue);
486
488
  border-radius: 0 var(--radius) var(--radius) 0;
487
489
  }
488
- .plan-title {
490
+ .plan-summary {
489
491
  color: var(--blue);
490
492
  font-weight: bold;
491
493
  margin-bottom: 2px;
494
+ cursor: pointer;
495
+ }
496
+ .plan-counts {
497
+ color: var(--text-dim);
498
+ font-weight: normal;
499
+ margin-left: 14px;
500
+ }
501
+ .plan-entries {
502
+ margin-top: 2px;
492
503
  }
493
504
  .plan-entry {
494
505
  padding: 1px 0;
@@ -945,8 +956,8 @@ body {
945
956
  padding: 0;
946
957
  }
947
958
  .msg.user img.user-image {
948
- max-width: 200px;
949
- max-height: 150px;
959
+ max-width: var(--user-image-max-width);
960
+ max-height: var(--user-image-max-height);
950
961
  border-radius: 4px;
951
962
  border: 1px solid var(--border);
952
963
  display: block;
@@ -1076,6 +1087,45 @@ body.login-page {
1076
1087
  border-radius: 3px;
1077
1088
  }
1078
1089
 
1090
+ /* --- Math (Temml MathML output) --- */
1091
+ /* Block math is wrapped in <div class="math-block"> by the renderer.
1092
+ Safari's overflow on <math> is broken — long equations escape the
1093
+ element and push the parent's width, causing page-level horizontal
1094
+ scroll. The wrapper div absorbs the overflow into a local scrollbar. */
1095
+ .math-block {
1096
+ overflow-x: auto;
1097
+ overflow-y: hidden;
1098
+ max-width: 100%;
1099
+ }
1100
+ /* Long inline math: wrap in an inline-block box so it can carry its own
1101
+ max-width + overflow-x without pushing page width. Applying these
1102
+ directly to <math> doesn't work on WebKit. vertical-align: middle keeps
1103
+ the formula visually centered with surrounding CJK/Latin text. */
1104
+ .math-inline {
1105
+ display: inline-block;
1106
+ max-width: 100%;
1107
+ overflow-x: auto;
1108
+ overflow-y: hidden;
1109
+ vertical-align: middle;
1110
+ }
1111
+ math:not(.tml-display) {
1112
+ max-width: 100%;
1113
+ }
1114
+ /* When Temml fails to parse math, the renderer falls back to a span
1115
+ containing the literal $...$ source (instead of Temml's red inline
1116
+ error message, which breaks paragraph flow and pollutes copy/paste).
1117
+ Styling is intentionally minimal — just dim text. We tried dotted /
1118
+ wavy / dashed underlines and thicker custom strokes, but iOS Safari
1119
+ silently drops non-solid text-decoration-style on inline spans
1120
+ (WebKit bug #233181), and any solid/colored underline that *does*
1121
+ render reads as "wrong" rather than "soft warning". The dim color
1122
+ alone signals "something off here" without distracting from the
1123
+ surrounding correct prose. The actual parse error is preserved in
1124
+ the span's title attribute for desktop hover. */
1125
+ .math-error {
1126
+ color: var(--text-dim);
1127
+ }
1128
+
1079
1129
  /* --- highlight.js themes (vendored from highlight.js@common, BSD-3-Clause) --- */
1080
1130
  /* light: default */
1081
1131
  pre code.hljs {
@@ -1441,3 +1491,358 @@ code.hljs {
1441
1491
  }
1442
1492
  }
1443
1493
  }
1494
+
1495
+ /* --- Temml (LaTeX → MathML rendering, MIT) --- */
1496
+ /*
1497
+ Temml.woff2 is a clone of KaTeX_Script-Regular, except that the code points
1498
+ have been changed from ASCII to Unicode Mathematical Alphanumeric Symbols Script capitals,
1499
+ Unicode range 1D49C to 1D4B5.
1500
+ */
1501
+
1502
+ @font-face {
1503
+ font-family: 'Temml';
1504
+ src: url('/fonts/temml/Temml.woff2') format('woff2');
1505
+ font-weight: normal;
1506
+ font-style: normal;
1507
+ }
1508
+
1509
+ math {
1510
+ font-family: "Cambria Math", 'STIXTwoMath-Regular', 'NotoSansMath-Regular', math;
1511
+ font-style: normal;
1512
+ font-weight: normal;
1513
+ line-height: normal;
1514
+ font-size-adjust: none;
1515
+ text-indent: 0;
1516
+ text-transform: none;
1517
+ letter-spacing: normal;
1518
+ word-wrap: normal;
1519
+ direction: ltr;
1520
+ /* Prevent Firefox from omitting the dot on i or j. */
1521
+ font-feature-settings: "dtls" off;
1522
+ }
1523
+
1524
+ math * {
1525
+ border-color: currentColor;
1526
+ }
1527
+
1528
+ /* display: block is necessary in Firefox and Safari.
1529
+ * Not in Chromium, which recognizes display: "block math" written inline. */
1530
+ math.tml-display {
1531
+ display: block;
1532
+ width: 100%;
1533
+ }
1534
+
1535
+ *.mathcal {
1536
+ /* NotoSans */
1537
+ font-feature-settings: 'ss01';
1538
+ }
1539
+
1540
+ math .mathscr {
1541
+ font-family: "Temml";
1542
+ }
1543
+
1544
+ mo.tml-prime {
1545
+ font-family: Temml;
1546
+ }
1547
+
1548
+ /* Cramped superscripts in WebKit */
1549
+ mfrac > :nth-child(2),
1550
+ msqrt,
1551
+ mover > :first-child {
1552
+ math-shift: compact
1553
+ }
1554
+
1555
+ .menclose {
1556
+ display: inline-block;
1557
+ position: relative;
1558
+ padding: 0.5ex 0ex;
1559
+ }
1560
+ .tml-cancelto {
1561
+ display: inline-block;
1562
+ position: absolute;
1563
+ top: 0;
1564
+ left: 0;
1565
+ padding: 0.5ex 0ex;
1566
+ background-color: currentColor;
1567
+ /* Use the SVG as an alpha mask (painted by background-color) */
1568
+ -webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><defs><marker id='a' markerHeight='5' markerUnits='strokeWidth' markerWidth='7' orient='auto' refX='7' refY='2.5'><path fill='black' d='m0 0 7 2.5L0 5z'/></marker></defs><line x2='100%25' y1='100%25' stroke='black' stroke-width='.06em' marker-end='url(%23a)' vector-effect='non-scaling-stroke'/></svg>");
1569
+ mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><defs><marker id='a' markerHeight='5' markerUnits='strokeWidth' markerWidth='7' orient='auto' refX='7' refY='2.5'><path fill='black' d='m0 0 7 2.5L0 5z'/></marker></defs><line x2='100%25' y1='100%25' stroke='black' stroke-width='.06em' marker-end='url(%23a)' vector-effect='non-scaling-stroke'/></svg>");
1570
+ -webkit-mask-repeat: no-repeat;
1571
+ mask-repeat: no-repeat;
1572
+ -webkit-mask-size: 100% 100%;
1573
+ mask-size: 100% 100%;
1574
+ -webkit-mask-position: 0 0;
1575
+ mask-position: 0 0;
1576
+ }
1577
+
1578
+ @supports (-moz-appearance: none) {
1579
+ /* \vec w/o italic correction for Firefox */
1580
+ .tml-vec {
1581
+ transform: scale(0.75)
1582
+ }
1583
+ /* Fix \cancelto in Firefox */
1584
+ .ff-narrow {
1585
+ width: 0em;
1586
+ }
1587
+ .ff-nudge-left {
1588
+ margin-left: -0.2em;
1589
+ }
1590
+ }
1591
+
1592
+ @supports (not (-moz-appearance: none)) {
1593
+ /* Chromium and WebKit */
1594
+ /* prime vertical alignment */
1595
+ mo.tml-prime {
1596
+ font-family: Temml;
1597
+ }
1598
+ /* Italic correction on superscripts */
1599
+ .tml-sml-pad {
1600
+ padding-left: 0.05em;
1601
+ }
1602
+ .tml-med-pad {
1603
+ padding-left: 0.10em;
1604
+ }
1605
+ .tml-lrg-pad {
1606
+ padding-left: 0.15em;
1607
+ }
1608
+ }
1609
+
1610
+ @supports (-webkit-backdrop-filter: blur(1px)) {
1611
+ /* WebKit vertical & italic correction on accents */
1612
+ .wbk-acc {
1613
+ /* lower by x-height distance */
1614
+ transform: translate(0em, 0.431em);
1615
+ }
1616
+ .wbk-sml {
1617
+ transform: translate(0.07em, 0);
1618
+ }
1619
+ .wbk-sml-acc {
1620
+ transform: translate(0.07em, 0.431em);
1621
+ }
1622
+ .wbk-sml-vec {
1623
+ transform: scale(0.75) translate(0.07em, 0);
1624
+ }
1625
+ .wbk-med {
1626
+ transform: translate(0.14em, 0);
1627
+ }
1628
+ .wbk-med-acc {
1629
+ transform: translate(0.14em, 0.431em);
1630
+ }
1631
+ .wbk-med-vec {
1632
+ transform: scale(0.75) translate(0.14em, 0);
1633
+ }
1634
+ .wbk-lrg {
1635
+ transform: translate(0.21em, 0);
1636
+ }
1637
+ .wbk-lrg-acc {
1638
+ transform: translate(0.21em, 0.431em);
1639
+ }
1640
+ .wbk-lrg-vec {
1641
+ transform: scale(0.75) translate(0.21em, 0);
1642
+ }
1643
+ }
1644
+
1645
+ /* \cancel & \phase use background images. Get them to print. */
1646
+ menclose {
1647
+ -webkit-print-color-adjust: exact; /* Chrome & Edge */
1648
+ print-color-adjust: exact;
1649
+ }
1650
+
1651
+ /* Array cell justification in Firefox & WebKit */
1652
+ .tml-right {
1653
+ text-align: right;
1654
+ }
1655
+ .tml-left {
1656
+ text-align: left;
1657
+ }
1658
+
1659
+ /* For CD labels that grow to the left in Firefox and WebKit */
1660
+ .tml-shift-left { margin-left:-200% }
1661
+
1662
+ /* Styles for Chromium only */
1663
+ @supports (not (-webkit-backdrop-filter: blur(1px))) and (not (-moz-appearance: none)) {
1664
+ /* Italic correction on accents */
1665
+ .chr-sml {
1666
+ transform: translate(0.07em, 0)
1667
+ }
1668
+ .chr-sml-vec {
1669
+ transform: scale(0.75) translate(0.07em, 0)
1670
+ }
1671
+ .chr-med {
1672
+ transform: translate(0.14em, 0)
1673
+ }
1674
+ .chr-med-vec {
1675
+ transform: scale(0.75) translate(0.14em, 0)
1676
+ }
1677
+ .chr-lrg {
1678
+ transform: translate(0.21em, 0)
1679
+ }
1680
+ .chr-lrg-vec {
1681
+ transform: scale(0.75) translate(0.21em, 0)
1682
+ }
1683
+
1684
+ /* For CD labels that grow to the left */
1685
+ .tml-shift-left { margin-left:-100% }
1686
+
1687
+ /* MathML Core & Chromium do not support the MathML 3.0 element <menclose> attributes. */
1688
+ /* So use styles. */
1689
+ menclose {
1690
+ position: relative;
1691
+ padding: 0.5ex 0ex;
1692
+ }
1693
+
1694
+ .tml-overline {
1695
+ padding: 0.1em 0 0 0;
1696
+ border-top: 0.065em solid;
1697
+ }
1698
+
1699
+ .tml-underline {
1700
+ padding: 0 0 0.1em 0;
1701
+ border-bottom: 0.065em solid;
1702
+ }
1703
+
1704
+ .tml-cancel {
1705
+ display: inline-block;
1706
+ position: absolute;
1707
+ left: 0.5px;
1708
+ bottom: 0;
1709
+ width: 100%;
1710
+ height: 100%;
1711
+ background-color: currentColor;
1712
+ }
1713
+ .upstrike {
1714
+ clip-path: polygon(0.05em 100%, 0em calc(100% - 0.05em), calc(100% - 0.05em) 0em, 100% 0.05em);
1715
+ }
1716
+ .downstrike {
1717
+ clip-path: polygon(0em 0.05em, 0.05em 0em, 100% calc(100% - 0.05em), calc(100% - 0.05em) 100%);
1718
+ }
1719
+ .sout {
1720
+ clip-path: polygon(0em calc(55% + 0.0333em), 0em calc(55% - 0.0333em), 100% calc(55% - 0.0333em), 100% calc(55% + 0.0333em));
1721
+ }
1722
+ .tml-xcancel {
1723
+ clip-path: polygon(0.05em 0em, 0em 0.05em, calc(50% - 0.05em) 50%, 0em calc(100% - 0.05em), 0.05em 100%, 50% calc(50% + 0.05em), calc(100% - 0.05em) 100%, 100% calc(100% - 0.05em), calc(50% + 0.05em) 50%, 100% 0.05em, calc(100% - 0.05em) 0%, 50% calc(50% - 0.05em));
1724
+ }
1725
+
1726
+ .longdiv-top {
1727
+ border-top: 0.067em solid;
1728
+ padding: 0.1em 0.2em 0.2em 0.433em;
1729
+ }
1730
+ .longdiv-arc {
1731
+ position: absolute;
1732
+ top: 0;
1733
+ bottom: 0.1em;
1734
+ left: -0.4em;
1735
+ width: 0.7em;
1736
+ border: 0.067em solid;
1737
+ transform: translateY(-0.067em);
1738
+ border-radius: 70%;
1739
+ clip-path: inset(0 0 0 0.4em);
1740
+ box-sizing: border-box;}
1741
+ .menclose {display: inline-block;
1742
+ text-align: left;
1743
+ position: relative;
1744
+ }
1745
+
1746
+ .phasor-bottom {
1747
+ border-bottom: 0.067em solid;
1748
+ padding: 0.2em 0.2em 0.1em 0.6em;
1749
+ }
1750
+ .phasor-angle {
1751
+ display: inline-block;
1752
+ position: absolute;
1753
+ left: 0.5px;
1754
+ bottom: -0.04em;
1755
+ height: 100%;
1756
+ aspect-ratio: 0.5;
1757
+ background-color: currentColor;
1758
+ clip-path: polygon(0.05em 100%, 0em calc(100% - 0.05em), calc(100% - 0.05em) 0em, 100% 0.05em);
1759
+ }
1760
+
1761
+ .tml-fbox {
1762
+ padding: 3pt;
1763
+ border: 1px solid;
1764
+ }
1765
+
1766
+ .circle-pad {
1767
+ padding: 0.267em;
1768
+ }
1769
+ .textcircle {
1770
+ position: absolute;
1771
+ top: 0;
1772
+ bottom: 0;
1773
+ right: 0;
1774
+ left: 0;
1775
+ border: 0.067em solid;
1776
+ border-radius: 50%;
1777
+ }
1778
+
1779
+ .actuarial {
1780
+ padding: 0.03889em 0.03889em 0 0.03889em;
1781
+ border-width: 0.08em 0.08em 0em 0em;
1782
+ border-style: solid;
1783
+ margin-right: 0.03889em;
1784
+ }
1785
+
1786
+ /* Stretch \widetilde */
1787
+ .tml-crooked-2 {
1788
+ transform: scale(2.0, 1.1)
1789
+ }
1790
+ .tml-crooked-3 {
1791
+ transform: scale(3.0, 1.3)
1792
+ }
1793
+ .tml-crooked-4 {
1794
+ transform: scale(4.0, 1.4)
1795
+ }
1796
+ /* set array cell justification */
1797
+ .tml-right {
1798
+ text-align: -webkit-right;
1799
+ }
1800
+ .tml-left {
1801
+ text-align: -webkit-left;
1802
+ }
1803
+ }
1804
+
1805
+ .special-fraction {
1806
+ font-family: 'STIX TWO', 'Times New Roman', Times, Tinos, serif;
1807
+ }
1808
+
1809
+ /* flex-wrap for line-breaking in Chromium */
1810
+ math {
1811
+ display: inline-flex;
1812
+ flex-wrap: wrap;
1813
+ align-items: baseline;
1814
+ }
1815
+ math > mrow {
1816
+ padding: 0.5ex 0ex;
1817
+ }
1818
+
1819
+ /* Default mtd top padding is 0.5ex per MathML-Core and user-agent CSS */
1820
+ /* We adjust for jot and small */
1821
+ mtable.tml-jot mtd {
1822
+ padding-top: 0.7ex;
1823
+ padding-bottom: 0.7ex;
1824
+ }
1825
+ mtable.tml-small mtd {
1826
+ padding-top: 0.35ex;
1827
+ padding-bottom: 0.35ex;
1828
+ }
1829
+
1830
+ /* Firefox */
1831
+ @-moz-document url-prefix() {
1832
+ /* Avoid flex-wrap */
1833
+ math { display: inline; }
1834
+ math > mrow { padding: 0 }
1835
+ /* Adjust Firefox spacing between array rows */
1836
+ mtd, mtable.tml-small mtd { padding-top: 0; padding-bottom: 0; }
1837
+ mtable.tml-jot mtd { padding-top: 0.2ex; padding-bottom: 0.ex; }
1838
+ }
1839
+
1840
+ /* AMS environment auto-numbering via CSS counter. */
1841
+ .tml-eqn::before {
1842
+ counter-increment: tmlEqnNo;
1843
+ content: "(" counter(tmlEqnNo) ")";
1844
+ }
1845
+
1846
+ body {
1847
+ counter-reset: tmlEqnNo;
1848
+ }
@@ -18,6 +18,9 @@ const WHITELIST = [
18
18
  { method: "GET", test: (p) => p === "/styles.css" },
19
19
  // Icons directory (no traversal: enforced by the check below)
20
20
  { method: "GET", test: (p) => /^\/icons\/[A-Za-z0-9._-]+$/.test(p) },
21
+ // Temml math font (loaded from CSS @font-face, no Authorization header
22
+ // available). Single fixed file: dist/fonts/temml/Temml.woff2.
23
+ { method: "GET", test: (p) => p === "/fonts/temml/Temml.woff2" },
21
24
  // SSE streams — authenticated via short-lived ticket in query string
22
25
  // (EventSource cannot send custom headers).
23
26
  { method: "GET", test: (p) => p === "/api/v1/events/stream" },
@@ -0,0 +1,29 @@
1
+ // Builds the EventHandlerConfig object passed to handleAgentEvent for every
2
+ // bridge event. Lives in its own file so a unit test can lock the wiring
3
+ // without booting the full server.
4
+ //
5
+ // Why this matters: missing fields on the inline config object handed to
6
+ // handleAgentEvent are silent at runtime — the affected feature just stops
7
+ // working, and the bug only surfaces in dogfood. A small structural test
8
+ // next to this builder makes future "merge --theirs" / refactor passes
9
+ // fail loudly instead.
10
+ import { log } from "./log.js";
11
+ export function buildBridgeEventHandlerConfig(deps) {
12
+ return {
13
+ cancelTimeout: deps.cancelTimeout,
14
+ recentPathsLimit: deps.recentPathsLimit,
15
+ attachmentInterceptor: {
16
+ counters: deps.attachmentInterceptorCounters,
17
+ logger: log.scope("attachment-interceptor"),
18
+ onSchemaDrift: (ctx) => {
19
+ if (!deps.shouldLogSchemaDrift())
20
+ return;
21
+ log
22
+ .scope("attachment-interceptor")
23
+ .error("schema drift detected — rawInput has no known path key", {
24
+ ctx,
25
+ });
26
+ },
27
+ },
28
+ };
29
+ }
@@ -3,28 +3,37 @@
3
3
  *
4
4
  * Tracks per-client metadata that survives SSE disconnect:
5
5
  * - capabilities advertised by the client on /hello.
6
- * - focus: clientId's current sessionId (set via /focus). Used by push
7
- * visibility suppression to know which session each client is viewing.
6
+ * - visible / active / visibleSince: identity-layer visibility state used
7
+ * by TTS dispatch (voice branch) and push suppression (main).
8
8
  *
9
- * Lifecycle: clients call /hello on SSE connect (register) and /focus when
10
- * they switch sessions. SSE disconnect does not remove a client — it stays
11
- * in the registry until an explicit /goodbye or TTL eviction (caller's
12
- * responsibility). This lets visibility state outlive transient drops.
9
+ * Lifecycle: clients call /hello on SSE connect (register) and POST
10
+ * /visibility on visibilitychange + 15s heartbeat. SSE disconnect does
11
+ * not remove a client — it stays in the registry until an explicit
12
+ * /goodbye or TTL eviction (caller's responsibility). This lets visibility
13
+ * state outlive transient drops.
13
14
  */
14
15
  export class ClientRegistry {
15
16
  clients = new Map();
17
+ visibilityTtlMs;
18
+ now;
19
+ constructor(options = {}) {
20
+ this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
21
+ this.now = options.now ?? Date.now;
22
+ }
16
23
  register(id, data) {
17
24
  const existing = this.clients.get(id);
18
25
  if (existing) {
19
26
  existing.capabilities = data.capabilities;
20
- existing.lastSeen = Date.now();
27
+ existing.lastSeen = this.now();
21
28
  return existing;
22
29
  }
23
30
  const entry = {
24
31
  id,
25
32
  capabilities: data.capabilities,
26
- focus: null,
27
- lastSeen: Date.now(),
33
+ visible: false,
34
+ active: null,
35
+ visibleSince: 0,
36
+ lastSeen: this.now(),
28
37
  };
29
38
  this.clients.set(id, entry);
30
39
  return entry;
@@ -32,24 +41,104 @@ export class ClientRegistry {
32
41
  remove(id) {
33
42
  this.clients.delete(id);
34
43
  }
35
- setFocus(id, sessionId) {
44
+ /**
45
+ * Atomic visibility setter. Mirrors PushService.updateClient semantics:
46
+ * - `visible` omitted = preserve; bool = set (and stamp/clear visibleSince).
47
+ * - `active` omitted = preserve; null = clear; string = replace.
48
+ * - Returns `becameVisibleFor=X` only on first transition into
49
+ * (visible:true, active:X) — heartbeat refreshes return null so
50
+ * callers can fire edge-triggered side effects exactly once.
51
+ * - Session-switch while visible (active X→Y) restarts the TTL clock
52
+ * even when the patch doesn't carry an explicit visible:true.
53
+ *
54
+ * No-op on unknown client.
55
+ */
56
+ setVisibility(id, patch) {
36
57
  const entry = this.clients.get(id);
37
58
  if (!entry)
38
- return;
39
- entry.focus = sessionId;
40
- entry.lastSeen = Date.now();
59
+ return { becameVisibleFor: null };
60
+ const wasVisibleForSession = entry.visible && entry.active != null ? entry.active : null;
61
+ if (patch.visible !== undefined) {
62
+ entry.visible = patch.visible;
63
+ entry.visibleSince = patch.visible ? this.now() : 0;
64
+ }
65
+ if (patch.active !== undefined) {
66
+ entry.active = patch.active;
67
+ }
68
+ const becameVisibleFor = entry.visible &&
69
+ entry.active != null &&
70
+ entry.active !== wasVisibleForSession
71
+ ? entry.active
72
+ : null;
73
+ if (becameVisibleFor) {
74
+ // Any transition into "visible + active=X" restarts TTL — including
75
+ // session-switches that arrive without an explicit visible:true.
76
+ entry.visibleSince = this.now();
77
+ }
78
+ entry.lastSeen = this.now();
79
+ return { becameVisibleFor };
80
+ }
81
+ /** Is this specific client currently visible & viewing `sessionId` & fresh? */
82
+ isVisibleForSession(id, sessionId) {
83
+ const entry = this.clients.get(id);
84
+ if (!entry)
85
+ return false;
86
+ if (!entry.visible)
87
+ return false;
88
+ if (entry.active !== sessionId)
89
+ return false;
90
+ if (this.now() - entry.visibleSince > this.visibilityTtlMs)
91
+ return false;
92
+ return true;
93
+ }
94
+ /** Is at least one fresh visible client viewing `sessionId`? */
95
+ isSessionVisibleToAnyClient(sessionId) {
96
+ const now = this.now();
97
+ for (const e of this.clients.values()) {
98
+ if (!e.visible)
99
+ continue;
100
+ if (e.active !== sessionId)
101
+ continue;
102
+ if (now - e.visibleSince > this.visibilityTtlMs)
103
+ continue;
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+ /** Is this specific client currently fresh-visible (any session)? */
109
+ isClientVisible(id) {
110
+ const entry = this.clients.get(id);
111
+ if (!entry)
112
+ return false;
113
+ if (!entry.visible)
114
+ return false;
115
+ if (this.now() - entry.visibleSince > this.visibilityTtlMs)
116
+ return false;
117
+ return true;
118
+ }
119
+ /** Is at least one fresh visible client connected (any session)? */
120
+ hasAnyVisibleClient() {
121
+ const now = this.now();
122
+ for (const e of this.clients.values()) {
123
+ if (!e.visible)
124
+ continue;
125
+ if (now - e.visibleSince > this.visibilityTtlMs)
126
+ continue;
127
+ return true;
128
+ }
129
+ return false;
41
130
  }
42
131
  updateCapabilities(id, caps) {
43
132
  const entry = this.clients.get(id);
44
133
  if (!entry)
45
134
  return;
46
135
  entry.capabilities = caps;
47
- entry.lastSeen = Date.now();
136
+ entry.lastSeen = this.now();
48
137
  }
49
138
  touch(id) {
50
139
  const entry = this.clients.get(id);
51
140
  if (entry)
52
- entry.lastSeen = Date.now();
141
+ entry.lastSeen = this.now();
53
142
  }
54
143
  get(id) {
55
144
  return this.clients.get(id);