agent-yes 1.122.2 → 1.123.0

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 (56) hide show
  1. package/default.config.yaml +19 -0
  2. package/dist/{SUPPORTED_CLIS-BTu2brih.js → SUPPORTED_CLIS-B4O2cFlt.js} +2 -2
  3. package/dist/SUPPORTED_CLIS-DHkqGoNv.js +8 -0
  4. package/dist/{agent-yes.config-z-IPzH5U.js → agent-yes.config-D6ycMApr.js} +2 -65
  5. package/dist/cli.js +6 -6
  6. package/dist/configShared-C5QaNPnz.js +71 -0
  7. package/dist/{globalPidIndex-gZuTvTBs.js → globalPidIndex-C7r2m6s7.js} +19 -20
  8. package/dist/index.js +4 -4
  9. package/dist/pidStore-C4c2O15q.js +5 -0
  10. package/dist/{pidStore-B5vBu8Px.js → pidStore-CGKIhaJO.js} +5 -4
  11. package/dist/reaper-BLVA780B.js +3 -0
  12. package/dist/{reaper-Dj8R7ltI.js → reaper-BkjPN7mw.js} +24 -2
  13. package/dist/{remotes-CpGcTr7A.js → remotes-BRCDVnR7.js} +1 -1
  14. package/dist/{remotes-D2fqaRU8.js → remotes-D8GvSbhf.js} +1 -1
  15. package/dist/{schedule-DgRrdA_n.js → schedule-DULdIkU9.js} +7 -7
  16. package/dist/{serve-tn7ZetZs.js → serve-r_2v9EKc.js} +202 -58
  17. package/dist/{setup-dZhgpNse.js → setup-DHa6fX8M.js} +3 -3
  18. package/dist/{share-CksllWW-.js → share-YuM6-Q6A.js} +78 -4
  19. package/dist/{subcommands-D9BWZilr.js → subcommands-B13Kto-u.js} +647 -32
  20. package/dist/subcommands-Tv6AwUkD.js +7 -0
  21. package/dist/{tray-DjCIyakK.js → tray-BVnJLThD.js} +1 -1
  22. package/dist/{ts-CIf0uaR7.js → ts-DgukRoEI.js} +10 -7
  23. package/dist/{versionChecker-DjxKi4qe.js → versionChecker-BqOr1YqC.js} +2 -2
  24. package/dist/{workspaceConfig-XP2NEWmV.js → workspaceConfig-BJO4fzEn.js} +1 -1
  25. package/lab/ui/console-logic.js +222 -10
  26. package/lab/ui/icon.svg +5 -0
  27. package/lab/ui/index.html +689 -14
  28. package/lab/ui/landing.html +276 -0
  29. package/lab/ui/manifest.webmanifest +14 -0
  30. package/lab/ui/sw.js +56 -0
  31. package/package.json +5 -1
  32. package/ts/agentTree.spec.ts +92 -0
  33. package/ts/agentTree.ts +149 -0
  34. package/ts/configShared.ts +4 -0
  35. package/ts/globalPidIndex.ts +28 -20
  36. package/ts/idleWaiter.spec.ts +7 -1
  37. package/ts/index.ts +9 -0
  38. package/ts/lsWatch.spec.ts +61 -0
  39. package/ts/lsWatch.ts +94 -0
  40. package/ts/needsInput.spec.ts +55 -0
  41. package/ts/needsInput.ts +68 -0
  42. package/ts/pidStore.ts +3 -0
  43. package/ts/reaper.spec.ts +26 -2
  44. package/ts/reaper.ts +25 -0
  45. package/ts/resultEnvelope.spec.ts +43 -0
  46. package/ts/resultEnvelope.ts +88 -0
  47. package/ts/serve.ts +276 -41
  48. package/ts/share.ts +156 -3
  49. package/ts/subcommands.ts +0 -0
  50. package/ts/todoParse.spec.ts +68 -0
  51. package/ts/todoParse.ts +88 -0
  52. package/ts/utils.spec.ts +4 -1
  53. package/dist/SUPPORTED_CLIS-DcOKE9Nz.js +0 -8
  54. package/dist/pidStore-7y1cTcAE.js +0 -5
  55. package/dist/reaper-HqcUms2d.js +0 -3
  56. package/dist/subcommands-D8sHibKu.js +0 -6
package/lab/ui/index.html CHANGED
@@ -4,6 +4,18 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agent-yes · console</title>
7
+ <!-- PWA: installable console under /w/ (manifest scope is /w/). The service
8
+ worker is network-first so it never serves a stale wire protocol — the
9
+ cache is only an offline-launch fallback. -->
10
+ <meta name="theme-color" content="#0d1117" />
11
+ <link rel="manifest" href="./manifest.webmanifest" />
12
+ <link rel="icon" href="./icon.svg" />
13
+ <link rel="apple-touch-icon" href="./icon.svg" />
14
+ <script>
15
+ if ("serviceWorker" in navigator) {
16
+ addEventListener("load", () => navigator.serviceWorker.register("./sw.js").catch(() => {}));
17
+ }
18
+ </script>
7
19
  <style>
8
20
  /* Palette borrowed from codehost (GitHub-dark) so the two feel like one system. */
9
21
  :root {
@@ -124,6 +136,167 @@
124
136
  h1 .tag {
125
137
  color: var(--accent);
126
138
  }
139
+ /* Connection-path pill in the header: at a glance, is the terminal you're
140
+ watching on the fast local/lan path or the slow relayed path? */
141
+ /* Per-peer connection-type pill in the terminal header — describes how this
142
+ viewer reaches the SELECTED agent's host (local/lan/wan/relay). */
143
+ .rhead .ctype {
144
+ position: relative;
145
+ font-size: 10px;
146
+ font-weight: 600;
147
+ letter-spacing: 0.02em;
148
+ align-self: center;
149
+ padding: 1px 7px;
150
+ border-radius: 999px;
151
+ border: 1px solid currentColor;
152
+ text-transform: uppercase;
153
+ opacity: 0.9;
154
+ cursor: help;
155
+ }
156
+ .ctype[hidden] {
157
+ display: none;
158
+ }
159
+ /* Hover popover with the connection debug readout. */
160
+ .ctype .ctip {
161
+ display: none;
162
+ position: absolute;
163
+ top: 150%;
164
+ right: 0;
165
+ z-index: 30;
166
+ min-width: 240px;
167
+ padding: 8px 10px;
168
+ background: var(--panel);
169
+ border: 1px solid var(--line);
170
+ border-radius: 8px;
171
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
172
+ color: var(--fg);
173
+ font-family: var(--mono);
174
+ font-size: 10.5px;
175
+ font-weight: 400;
176
+ line-height: 1.6;
177
+ letter-spacing: 0;
178
+ text-transform: none;
179
+ text-align: left;
180
+ white-space: nowrap;
181
+ }
182
+ .ctype:hover .ctip {
183
+ display: block;
184
+ }
185
+ .ctip-h {
186
+ color: var(--muted);
187
+ margin-bottom: 5px;
188
+ white-space: normal;
189
+ }
190
+ .ctip-k {
191
+ display: inline-block;
192
+ width: 56px;
193
+ color: var(--muted);
194
+ }
195
+ /* ⋯ overflow menu in the terminal header + its dropdown. */
196
+ .rmenu {
197
+ position: relative;
198
+ margin-left: auto;
199
+ }
200
+ .rmenubtn {
201
+ background: none;
202
+ border: none;
203
+ color: var(--muted);
204
+ font-size: 16px;
205
+ line-height: 1;
206
+ cursor: pointer;
207
+ padding: 2px 6px;
208
+ border-radius: 6px;
209
+ }
210
+ .rmenubtn:hover {
211
+ background: var(--panel);
212
+ color: var(--fg);
213
+ }
214
+ .rmenupanel {
215
+ position: absolute;
216
+ top: 26px;
217
+ right: 0;
218
+ z-index: 15;
219
+ background: var(--panel);
220
+ border: 1px solid var(--line);
221
+ border-radius: 8px;
222
+ padding: 4px;
223
+ min-width: 140px;
224
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
225
+ }
226
+ .rmenupanel[hidden] {
227
+ display: none;
228
+ }
229
+ .rmenuitem {
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 7px;
233
+ padding: 5px 8px;
234
+ font-size: 12.5px;
235
+ border-radius: 6px;
236
+ cursor: pointer;
237
+ user-select: none;
238
+ }
239
+ .rmenuitem:hover {
240
+ background: var(--bg);
241
+ }
242
+ .rmenuitem input {
243
+ margin: 0;
244
+ cursor: pointer;
245
+ }
246
+ /* Action items (Stop / Force-kill) reuse the menu-item look as buttons. */
247
+ .rmenuact {
248
+ width: 100%;
249
+ background: none;
250
+ border: none;
251
+ text-align: left;
252
+ font: inherit;
253
+ color: var(--fg);
254
+ }
255
+ .rmenuact.danger {
256
+ color: var(--red);
257
+ }
258
+ .rmenusep {
259
+ height: 1px;
260
+ background: var(--line);
261
+ margin: 4px 2px;
262
+ }
263
+ /* Live performance HUD — opt-in via the ⋯ menu, hidden by default. */
264
+ .perfhud {
265
+ position: absolute;
266
+ top: 44px;
267
+ right: 12px;
268
+ z-index: 14;
269
+ background: rgba(13, 17, 23, 0.82);
270
+ color: var(--fg);
271
+ border: 1px solid var(--line);
272
+ border-radius: 8px;
273
+ padding: 7px 10px;
274
+ font-family: var(--mono);
275
+ font-size: 11px;
276
+ line-height: 1.5;
277
+ pointer-events: none;
278
+ white-space: pre;
279
+ backdrop-filter: blur(3px);
280
+ }
281
+ .perfhud[hidden] {
282
+ display: none;
283
+ }
284
+ .perfhud b {
285
+ color: var(--accent);
286
+ font-weight: 600;
287
+ }
288
+ /* Per-peer connection-type tag on peer-group headers in the tree. */
289
+ .ghead .ctag {
290
+ font-size: 9.5px;
291
+ font-weight: 600;
292
+ text-transform: uppercase;
293
+ letter-spacing: 0.02em;
294
+ margin-left: 6px;
295
+ padding: 0 5px;
296
+ border-radius: 999px;
297
+ border: 1px solid currentColor;
298
+ opacity: 0.85;
299
+ }
127
300
  .sub {
128
301
  color: var(--muted);
129
302
  font-size: 12.5px;
@@ -516,6 +689,23 @@
516
689
  .git.dirty {
517
690
  color: var(--amber);
518
691
  }
692
+ /* Task-progress badge ("2/5") parsed from the agent's todo block, sits next
693
+ to the git chip. Pill so it reads as a discrete counter; green when all
694
+ tasks are done. */
695
+ .tasks {
696
+ font-family: var(--mono);
697
+ font-size: 10.5px;
698
+ color: var(--muted);
699
+ white-space: nowrap;
700
+ flex: none;
701
+ padding: 0 5px;
702
+ border: 1px solid var(--line);
703
+ border-radius: 8px;
704
+ }
705
+ .tasks.done {
706
+ color: var(--green);
707
+ border-color: #2a3a2a;
708
+ }
519
709
  .detail {
520
710
  color: var(--muted);
521
711
  font-size: 12.5px;
@@ -539,6 +729,46 @@
539
729
  gap: 8px;
540
730
  padding: 6px 18px;
541
731
  }
732
+ /* Tree branch prefix for nested (sub)agents — monospace so the box-drawing
733
+ glyphs line up into continuous │ ├ └ rails across rows. */
734
+ .tbranch {
735
+ font-family: var(--mono);
736
+ color: var(--muted);
737
+ white-space: pre;
738
+ flex: none;
739
+ opacity: 0.7;
740
+ margin-right: -2px;
741
+ }
742
+ /* Room/peer group header rows (the upper layers of the tree). Non-selectable,
743
+ dimmer and smaller than agent rows so they read as containers, not agents. */
744
+ .ghead {
745
+ display: flex;
746
+ align-items: center;
747
+ gap: 6px;
748
+ padding: 5px 18px 2px;
749
+ font-size: 11.5px;
750
+ color: var(--muted);
751
+ user-select: none;
752
+ }
753
+ .ghead .gicon {
754
+ opacity: 0.6;
755
+ flex: none;
756
+ }
757
+ .ghead .glabel {
758
+ font-weight: 600;
759
+ color: var(--fg);
760
+ overflow: hidden;
761
+ text-overflow: ellipsis;
762
+ white-space: nowrap;
763
+ }
764
+ .ghead.g-room .glabel {
765
+ color: var(--accent);
766
+ }
767
+ .ghead .gkind {
768
+ font-size: 10px;
769
+ opacity: 0.45;
770
+ flex: none;
771
+ }
542
772
  .crow .cident {
543
773
  font-family: var(--mono);
544
774
  font-size: 11.5px;
@@ -601,6 +831,7 @@
601
831
  flex-direction: column;
602
832
  min-width: 0;
603
833
  min-height: 0;
834
+ position: relative; /* anchor for the absolutely-positioned perf HUD */
604
835
  }
605
836
  .rhead {
606
837
  padding: 14px 20px;
@@ -811,12 +1042,32 @@
811
1042
  <span class="live"
812
1043
  ><span class="dot" id="livedot"></span><span id="livetxt">connecting…</span></span
813
1044
  >
1045
+ <span
1046
+ id="ctype"
1047
+ class="ctype"
1048
+ title="how your browser reaches this agent's host"
1049
+ hidden
1050
+ ></span>
1051
+ <span class="rmenu">
1052
+ <button class="rmenubtn" id="rmenubtn" title="more" aria-label="more">⋯</button>
1053
+ <div class="rmenupanel" id="rmenupanel" hidden>
1054
+ <label class="rmenuitem"
1055
+ ><input type="checkbox" id="perfToggle" /> <span>Perf HUD</span></label
1056
+ >
1057
+ <div class="rmenusep"></div>
1058
+ <button class="rmenuitem rmenuact" id="stopAgent" type="button">⏹ Stop agent</button>
1059
+ <button class="rmenuitem rmenuact danger" id="killAgent" type="button">
1060
+ ✕ Force-kill (SIGKILL)
1061
+ </button>
1062
+ </div>
1063
+ </span>
814
1064
  </div>
815
1065
  <div class="log" id="log">
816
1066
  <div class="placeholder">
817
1067
  ← pick an agent to tail its log; type directly into the terminal
818
1068
  </div>
819
1069
  </div>
1070
+ <div class="perfhud" id="perfhud" hidden></div>
820
1071
  </div>
821
1072
  </div>
822
1073
 
@@ -842,6 +1093,8 @@
842
1093
  fullIdent,
843
1094
  hasIdent,
844
1095
  deviceCount,
1096
+ layeredRows,
1097
+ taskLabel,
845
1098
  } from "./console-logic.js";
846
1099
  import {
847
1100
  MARKER as E2E_MARKER,
@@ -1415,6 +1668,382 @@
1415
1668
  const srcFor = (e) => (e && sources.get(e._room)) || sources.get(LOCAL) || null;
1416
1669
  const txFor = (e) => srcFor(e)?.tx || localTx;
1417
1670
 
1671
+ // ---- connection-path classification (header [local/lan/wan/relay] pill) ----
1672
+ // Tells the user, at a glance, whether the terminal they're watching rides the
1673
+ // fast direct path or a relayed one — the difference between snappy and laggy.
1674
+ const isLoopbackIp = (ip) => ip === "::1" || /^127\./.test(ip);
1675
+ const isPrivateIp = (ip) =>
1676
+ /^10\./.test(ip) ||
1677
+ /^192\.168\./.test(ip) ||
1678
+ /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
1679
+ /^169\.254\./.test(ip) || // IPv4 link-local
1680
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip) || // CGNAT / Tailscale (100.64.0.0/10)
1681
+ /^fe80:/i.test(ip) || // IPv6 link-local
1682
+ /^f[cd][0-9a-f]{2}:/i.test(ip) || // IPv6 ULA (fc00::/7, incl. Tailscale fd7a:…)
1683
+ /\.local$/i.test(ip); // mDNS host candidate (browser-obfuscated LAN IP)
1684
+ // Inspect the selected ICE candidate pair and return the full picture for the
1685
+ // hover tooltip: kind, RTT, both candidates, a NAT-traversal hint, and the open
1686
+ // data channel. `kind` is classified by how we reach the REMOTE host: relay on
1687
+ // either end = TURN-proxied; otherwise the remote candidate's type/address
1688
+ // decides local/lan/wan. (The LOCAL end is often prflx/srflx even on a fast
1689
+ // private path — e.g. a Tailscale/CGNAT mesh shows local=prflx, remote=host
1690
+ // 100.x at ~1ms — so we key on the remote, not on both ends being `host`.)
1691
+ async function inspectConn(pc) {
1692
+ if (!pc || typeof pc.getStats !== "function") return null;
1693
+ try {
1694
+ const stats = await pc.getStats();
1695
+ let pairId = null;
1696
+ stats.forEach((s) => {
1697
+ if (s.type === "transport" && s.selectedCandidatePairId)
1698
+ pairId = s.selectedCandidatePairId;
1699
+ });
1700
+ let pair = null;
1701
+ stats.forEach((s) => {
1702
+ if (
1703
+ pairId
1704
+ ? s.id === pairId
1705
+ : s.type === "candidate-pair" && s.state === "succeeded" && s.nominated
1706
+ )
1707
+ pair = s;
1708
+ });
1709
+ if (!pair) return null;
1710
+ let local = null,
1711
+ remote = null;
1712
+ stats.forEach((s) => {
1713
+ if (s.id === pair.localCandidateId) local = s;
1714
+ if (s.id === pair.remoteCandidateId) remote = s;
1715
+ });
1716
+ if (!local || !remote) return null;
1717
+ const lt = local.candidateType,
1718
+ rt = remote.candidateType,
1719
+ rip = remote.address || remote.ip || "";
1720
+ let kind, nat;
1721
+ if (lt === "relay" || rt === "relay") {
1722
+ kind = "relay";
1723
+ nat = "TURN-relayed — symmetric NAT or UDP blocked";
1724
+ } else if (rt === "host") {
1725
+ kind = isLoopbackIp(rip) ? "local" : !rip || isPrivateIp(rip) ? "lan" : "wan";
1726
+ nat =
1727
+ lt === "host"
1728
+ ? "direct — both ends on-net"
1729
+ : "direct to host" + (lt === "srflx" || lt === "prflx" ? " (you behind NAT)" : "");
1730
+ } else {
1731
+ kind = "wan";
1732
+ nat = "STUN-reflexive P2P (NAT-traversed)";
1733
+ }
1734
+ const fmt = (c) => c.candidateType + "/" + (c.protocol || "?");
1735
+ return {
1736
+ kind,
1737
+ nat,
1738
+ rttMs:
1739
+ pair.currentRoundTripTime != null
1740
+ ? Math.round(pair.currentRoundTripTime * 1000)
1741
+ : null,
1742
+ local:
1743
+ fmt(local) + (local.address || local.ip ? " " + (local.address || local.ip) : ""),
1744
+ remote: fmt(remote) + (rip ? " " + rip : ""),
1745
+ dc:
1746
+ [...stats.values()]
1747
+ .filter((v) => v.type === "data-channel" && v.state === "open")
1748
+ .map((v) => v.label)
1749
+ .join(",") || null,
1750
+ };
1751
+ } catch {
1752
+ return null;
1753
+ }
1754
+ }
1755
+ // Resolve the live RTCPeerConnection that carries a SPECIFIC agent. A codehost
1756
+ // room holds one peer per host (room.rtcs keyed by peerId); byPid maps the
1757
+ // agent's pid to its peerId, so each agent resolves to ITS host's connection —
1758
+ // a VPS agent reads wan while a tak.local agent in the same room reads lan.
1759
+ function pcForAgent(s, e) {
1760
+ if (!s) return null;
1761
+ if (s.kind === "rtc") return s.client?.pc || null; // share room: one host
1762
+ if (s.kind === "ch") {
1763
+ const peerId = s.client?.byPid?.get(String(e?.pid));
1764
+ return (peerId && s.client?.room?.rtcs?.get(peerId)?.pc) || null;
1765
+ }
1766
+ return null; // local ay-serve has no peer connection
1767
+ }
1768
+ // Two cache views, both filled from one classification pass:
1769
+ // - connKinds keyed by the true PEER (room + peerId) drives the header badge,
1770
+ // so two machines that happen to share a hostname never collide.
1771
+ // - hostKinds keyed by (room, host) drives the peer-group header tags, which
1772
+ // only ever render when hosts are already distinct.
1773
+ const hostKeyOf = (e) => (e?._room || "") + " " + (e?._host || "");
1774
+ function peerKeyOf(s, e) {
1775
+ if (!s || s.id === LOCAL || s.kind == null) return LOCAL;
1776
+ if (s.kind === "ch") {
1777
+ const peerId = s.client?.byPid?.get(String(e?.pid));
1778
+ return s.id + " " + (peerId || e?._host || "");
1779
+ }
1780
+ return s.id; // rtc share: one peer per room
1781
+ }
1782
+ const connKinds = new Map(); // peerKey -> "local" | "lan" | "wan" | "relay"
1783
+ const hostKinds = new Map(); // hostKey -> same (peer-header tags)
1784
+ const connInfo = new Map(); // peerKey -> full detail for the hover tooltip
1785
+ let connSig = "";
1786
+ // The signaling server backing a source: codehost rooms use the codehost
1787
+ // signal DO; agent-yes share rooms use their own host; local has none.
1788
+ const signalingOf = (s) =>
1789
+ !s || s.id === LOCAL || s.kind == null
1790
+ ? null
1791
+ : s.kind === "ch"
1792
+ ? "wss://signal.codehost.dev"
1793
+ : "wss://" + (s.host || SIG_DEFAULT);
1794
+ const CTYPE_COLORS = {
1795
+ local: "var(--green)",
1796
+ lan: "var(--green)",
1797
+ wan: "var(--amber)",
1798
+ relay: "var(--red)",
1799
+ };
1800
+ const CTYPE_TIPS = {
1801
+ local: "this machine (ay serve, local)",
1802
+ lan: "direct over the local network (fast)",
1803
+ wan: "direct over the internet, peer-to-peer (STUN — RTT-bound)",
1804
+ relay: "TURN-relayed — proxied through a server (slowest path)",
1805
+ };
1806
+ // A small colored pill for a connection kind — reused by the header badge and
1807
+ // every peer-group header in the tree.
1808
+ function connTagHtml(kind) {
1809
+ if (!kind) return "";
1810
+ return `<span class="ctag" style="color:${CTYPE_COLORS[kind]}" title="${esc(CTYPE_TIPS[kind])}">${kind}</span>`;
1811
+ }
1812
+ // Classify every distinct peer in the fleet, caching by (room,host). Because
1813
+ // each agent reads its own host's path, a mixed room (VPS + laptop) shows both
1814
+ // wan and lan at once — exactly what tells you a "local" agent is wrongly remote.
1815
+ async function refreshConnKinds() {
1816
+ // Probe each distinct peer once (not once per agent), keyed by peerKey.
1817
+ const reps = new Map(); // peerKey -> a representative entry for that peer
1818
+ for (const e of entries) {
1819
+ const k = peerKeyOf(srcFor(e), e);
1820
+ if (!reps.has(k)) reps.set(k, e);
1821
+ }
1822
+ connKinds.clear();
1823
+ connInfo.clear();
1824
+ await Promise.all(
1825
+ [...reps].map(async ([key, e]) => {
1826
+ const s = srcFor(e);
1827
+ if (!s || s.id === LOCAL || s.kind == null) {
1828
+ if (s && s.live) {
1829
+ connKinds.set(key, "local");
1830
+ connInfo.set(key, {
1831
+ kind: "local",
1832
+ nat: "this machine (ay serve)",
1833
+ signaling: null,
1834
+ });
1835
+ }
1836
+ return;
1837
+ }
1838
+ const info = await inspectConn(pcForAgent(s, e));
1839
+ if (info) {
1840
+ connKinds.set(key, info.kind);
1841
+ connInfo.set(key, { ...info, signaling: signalingOf(s) });
1842
+ }
1843
+ }),
1844
+ );
1845
+ // Project peer kinds onto (room,host) keys for the tree's peer headers.
1846
+ hostKinds.clear();
1847
+ for (const e of entries) {
1848
+ const kind = connKinds.get(peerKeyOf(srcFor(e), e));
1849
+ if (kind) hostKinds.set(hostKeyOf(e), kind);
1850
+ }
1851
+ paintHeaderBadge();
1852
+ // Repaint the list only when the peer-header tags actually changed — the poll
1853
+ // already re-renders on data deltas, so this avoids churning the DOM every tick.
1854
+ const sig = [...hostKinds]
1855
+ .sort()
1856
+ .map(([k, v]) => k + "=" + v)
1857
+ .join(";");
1858
+ if (sig !== connSig) {
1859
+ connSig = sig;
1860
+ renderList();
1861
+ }
1862
+ }
1863
+ // The terminal-header pill shows how this viewer reaches the SELECTED agent's
1864
+ // host peer (local/lan/wan/relay) — per-peer, contextual to what's open. Reads
1865
+ // the cache only (kept sync so renders never await); hidden when no agent is
1866
+ // open (the whole rhead is hidden then anyway).
1867
+ function paintHeaderBadge() {
1868
+ const badge = $("ctype");
1869
+ if (!badge) return;
1870
+ const e = sel ? entries.find((x) => x._key === sel) : null;
1871
+ const key = e ? peerKeyOf(srcFor(e), e) : null;
1872
+ const kind = key ? connKinds.get(key) : null;
1873
+ if (!kind) {
1874
+ badge.hidden = true;
1875
+ return;
1876
+ }
1877
+ badge.hidden = false;
1878
+ badge.style.color = CTYPE_COLORS[kind] || "var(--muted)";
1879
+ badge.removeAttribute("title"); // the hover popover replaces the native title
1880
+ badge.innerHTML = esc(kind) + connTipHtml(kind, connInfo.get(key));
1881
+ }
1882
+ // The on-hover debug popover for the connection pill: latency, both ICE
1883
+ // candidates, NAT-traversal hint, the open data channel, and the signaling
1884
+ // server. Pure detail readout — the same getStats the classifier already ran.
1885
+ function connTipHtml(kind, info) {
1886
+ const rows = [];
1887
+ if (info?.rttMs != null) rows.push(["latency", info.rttMs + " ms RTT"]);
1888
+ if (info?.remote) rows.push(["remote", info.remote]);
1889
+ if (info?.local) rows.push(["local", info.local]);
1890
+ if (info?.nat) rows.push(["nat", info.nat]);
1891
+ if (info?.dc) rows.push(["channel", info.dc]);
1892
+ if (info?.signaling) rows.push(["signal", info.signaling]);
1893
+ const head = `<div class="ctip-h">${esc(CTYPE_TIPS[kind] || kind)}</div>`;
1894
+ const body = rows
1895
+ .map(([k, v]) => `<div><span class="ctip-k">${k}</span>${esc(String(v))}</div>`)
1896
+ .join("");
1897
+ return `<span class="ctip">${head}${body || '<div class="ctip-k">probing…</div>'}</span>`;
1898
+ }
1899
+ // Candidate pairs aren't known the instant a peer connects (ICE keeps probing)
1900
+ // and can change (relay fallback); a light poll keeps every pill honest.
1901
+ setInterval(refreshConnKinds, 2500);
1902
+
1903
+ // ---- live performance HUD (opt-in via the ⋯ menu, hidden by default) -----
1904
+ // Surfaces the metrics that actually move when the console feels laggy: render
1905
+ // fps, keystroke throughput (xterm onData), agent output rate (term.write),
1906
+ // and main-thread jank (long tasks). Counters accumulate over a 1s window;
1907
+ // the I/O hooks live in select() (perfNote on fwd / term.write).
1908
+ const perf = {
1909
+ frames: 0,
1910
+ inEvents: 0,
1911
+ inBytes: 0,
1912
+ outWrites: 0,
1913
+ outBytes: 0,
1914
+ jankMs: 0,
1915
+ echoMs: null,
1916
+ };
1917
+ let perfOn = false;
1918
+ let perfRaf = 0;
1919
+ // Keystroke→reflect latency: stamp the first un-echoed keystroke; the next
1920
+ // term.write that lands is its echo (the PTY round-trip). Accurate while
1921
+ // typing into an otherwise-quiet agent — the real UX number for a TUI.
1922
+ let pendingKeyTs = 0;
1923
+ function perfNote(kind, n) {
1924
+ if (!perfOn) return;
1925
+ if (kind === "in") {
1926
+ perf.inEvents++;
1927
+ perf.inBytes += n || 0;
1928
+ if (!pendingKeyTs) pendingKeyTs = performance.now();
1929
+ } else if (kind === "out") {
1930
+ perf.outWrites++;
1931
+ perf.outBytes += n || 0;
1932
+ if (pendingKeyTs) {
1933
+ perf.echoMs = Math.round(performance.now() - pendingKeyTs);
1934
+ pendingKeyTs = 0;
1935
+ }
1936
+ }
1937
+ }
1938
+ function perfReset() {
1939
+ perf.frames =
1940
+ perf.inEvents =
1941
+ perf.inBytes =
1942
+ perf.outWrites =
1943
+ perf.outBytes =
1944
+ perf.jankMs =
1945
+ 0;
1946
+ }
1947
+ function perfFrameLoop() {
1948
+ if (!perfOn) {
1949
+ perfRaf = 0;
1950
+ return;
1951
+ }
1952
+ perf.frames++;
1953
+ perfRaf = requestAnimationFrame(perfFrameLoop);
1954
+ }
1955
+ try {
1956
+ // Long tasks fire regardless of the HUD; we only sum them while it's on.
1957
+ new PerformanceObserver((l) => {
1958
+ if (perfOn) for (const e of l.getEntries()) perf.jankMs += e.duration;
1959
+ }).observe({ entryTypes: ["longtask"] });
1960
+ } catch {
1961
+ /* longtask API unavailable (Safari) — jank line just stays 0 */
1962
+ }
1963
+ const fmtBytes = (b) => (b >= 1024 ? (b / 1024).toFixed(1) + " KB" : Math.round(b) + " B");
1964
+ setInterval(() => {
1965
+ const el = $("perfhud");
1966
+ if (!perfOn || !el) {
1967
+ perfReset();
1968
+ return;
1969
+ }
1970
+ const fps = perf.frames; // window is ~1s
1971
+ el.innerHTML =
1972
+ `<b>fps</b> ${fps}\n` +
1973
+ `<b>echo</b> ${perf.echoMs == null ? "—" : perf.echoMs + " ms"}\n` +
1974
+ `<b>keys</b> ${perf.inEvents}/s ${fmtBytes(perf.inBytes)}/s\n` +
1975
+ `<b>out</b> ${perf.outWrites}/s ${fmtBytes(perf.outBytes)}/s\n` +
1976
+ `<b>jank</b> ${Math.round(perf.jankMs)} ms/s`;
1977
+ perfReset(); // note: echoMs is preserved (last keystroke round-trip stays shown)
1978
+ }, 1000);
1979
+ function setPerfHud(on) {
1980
+ perfOn = on;
1981
+ const el = $("perfhud");
1982
+ if (el) el.hidden = !on;
1983
+ const cb = $("perfToggle");
1984
+ if (cb) cb.checked = on;
1985
+ try {
1986
+ localStorage.setItem("ay.perfHud", on ? "1" : "0");
1987
+ } catch {}
1988
+ if (on) {
1989
+ perfReset();
1990
+ if (!perfRaf) perfRaf = requestAnimationFrame(perfFrameLoop);
1991
+ if (el) el.textContent = "measuring…";
1992
+ }
1993
+ }
1994
+ // Wire the ⋯ overflow menu and restore the saved HUD preference.
1995
+ // Recovery actions for the SELECTED agent, both confirmed first (destructive):
1996
+ // Stop — graceful: send the CLI's exit command (claude/codex /exit, gemini
1997
+ // /quit) + Enter over the existing /api/send stdin wire. (Ctrl+C / Ctrl+C×2
1998
+ // you can already just type — and some agents rely on that pattern — so
1999
+ // we don't reinvent it here.)
2000
+ // Force — escalation a stuck agent can't ignore: a real SIGKILL of its process
2001
+ // GROUP, server-side via POST /api/kill. Use when /exit does nothing.
2002
+ const GRACEFUL_EXIT = { claude: "/exit", codex: "/exit", gemini: "/quit" };
2003
+ async function stopAgent(force) {
2004
+ const e = sel ? entries.find((x) => x._key === sel) : null;
2005
+ if (!e) return;
2006
+ const who = `pid ${e.pid} ${cliLabel(e) || ident(e) || ""}`.trim();
2007
+ const tx = txFor(e),
2008
+ kw = String(e.pid);
2009
+ if (force) {
2010
+ if (!confirm(`Force-kill (SIGKILL) ${who}?\nThis terminates the process immediately.`))
2011
+ return;
2012
+ tx.post("/api/kill", { keyword: kw }).catch(() => {});
2013
+ return;
2014
+ }
2015
+ const graceful = GRACEFUL_EXIT[e.cli] || "/exit";
2016
+ if (!confirm(`Stop ${who} — send "${graceful}"?`)) return;
2017
+ const send = (msg) =>
2018
+ tx.post("/api/send", { keyword: kw, msg, code: "none" }).catch(() => {});
2019
+ await send(graceful);
2020
+ await new Promise((r) => setTimeout(r, 200));
2021
+ await send("\r");
2022
+ }
2023
+ (function perfMenuBoot() {
2024
+ const btn = $("rmenubtn"),
2025
+ panel = $("rmenupanel"),
2026
+ cb = $("perfToggle");
2027
+ const closePanel = () => panel && (panel.hidden = true);
2028
+ if (btn && panel) {
2029
+ btn.addEventListener("click", (ev) => {
2030
+ ev.stopPropagation();
2031
+ panel.hidden = !panel.hidden;
2032
+ });
2033
+ document.addEventListener("click", (ev) => {
2034
+ if (!panel.hidden && !panel.contains(ev.target) && ev.target !== btn) closePanel();
2035
+ });
2036
+ }
2037
+ if (cb) cb.addEventListener("change", () => setPerfHud(cb.checked));
2038
+ $("stopAgent")?.addEventListener("click", () => (closePanel(), stopAgent(false)));
2039
+ $("killAgent")?.addEventListener("click", () => (closePanel(), stopAgent(true)));
2040
+ let saved = false;
2041
+ try {
2042
+ saved = localStorage.getItem("ay.perfHud") === "1";
2043
+ } catch {}
2044
+ setPerfHud(saved);
2045
+ })();
2046
+
1418
2047
  // The local source is only worth polling when this page is actually backed
1419
2048
  // by an ay serve: localhost, or served by `ay serve --http` (which leaves a
1420
2049
  // token), or when there are no rooms to fall back on. On the public origin
@@ -1545,28 +2174,62 @@
1545
2174
  return `<span class="git${g.dirty ? " dirty" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
1546
2175
  }
1547
2176
 
2177
+ // Task-progress badge ("2/5") parsed from the agent's todo block, shown next
2178
+ // to the git chip. Amber-ish when all done; omitted entirely when no block.
2179
+ function taskChipHtml(e) {
2180
+ const label = taskLabel(e);
2181
+ if (!label) return "";
2182
+ const t = e.tasks || {};
2183
+ const allDone = t.total > 0 && t.done >= t.total;
2184
+ const tip = `tasks: ${t.done || 0} done / ${t.total || 0} total`;
2185
+ return `<span class="tasks${allDone ? " done" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
2186
+ }
2187
+
2188
+ // A room/peer group header row. Non-selectable; carries the same tree rails
2189
+ // as agent rows so the hierarchy reads as one tree. The room/peer layers
2190
+ // only appear here when there are ≥2 of them (single ones fold away in
2191
+ // layeredRows), so a header always disambiguates something real.
2192
+ function headerHtml(r) {
2193
+ const icon = r.kind === "room" ? "▤" : "▣";
2194
+ // Peer headers carry their host's connection type (lan/wan/relay), so a
2195
+ // mixed room shows at a glance which hosts are local vs reached over WAN.
2196
+ const ctag =
2197
+ r.kind === "peer"
2198
+ ? connTagHtml(hostKinds.get((r.room || "") + " " + (r.host || "")))
2199
+ : "";
2200
+ return `<div class="ghead g-${r.kind}">${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}<span class="gicon">${icon}</span><span class="glabel">${esc(r.label || "")}</span>${ctag}<span class="gkind">${r.kind}</span></div>`;
2201
+ }
2202
+
1548
2203
  function renderList() {
1549
2204
  const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
1550
- const shown = entries.filter((e) => matches(e, toks));
1551
- $("count").textContent = `${shown.length} / ${entries.length} agents`;
2205
+ // Build the full signalling-server > rooms > peers > agents > subagents
2206
+ // tree. Single-node room/peer layers fold away; ≥2 become ├ └ │ branches.
2207
+ const rows = layeredRows(entries.filter((e) => matches(e, toks)));
2208
+ const agentRows = rows.filter((r) => r.kind === "agent");
2209
+ $("count").textContent = `${agentRows.length} / ${entries.length} agents`;
1552
2210
  $("viewbtn").classList.toggle("on", compactList);
1553
2211
  // identContext blanks any field uniform across the shown list (so a
1554
2212
  // single-device fleet shows no device); multiDevice gates the detailed
1555
2213
  // host tag so it only appears when machines are actually mixed.
1556
- const ctx = identContext(shown);
1557
- const multiDevice = deviceCount(shown) > 1;
2214
+ const shownEntries = agentRows.map((r) => r.entry);
2215
+ const ctx = identContext(shownEntries);
2216
+ const multiDevice = deviceCount(shownEntries) > 1;
1558
2217
  if (compactList) {
1559
2218
  $("list").innerHTML =
1560
- shown
1561
- .map((e) => {
2219
+ rows
2220
+ .map((r) => {
2221
+ if (r.kind !== "agent") return headerHtml(r);
2222
+ const e = r.entry;
1562
2223
  const t = e.title || e.prompt || "";
1563
- const id = compactIdent(e, ctx);
2224
+ const id = compactIdent(e, ctx, 3, r.parentEntry);
1564
2225
  const cli = cliLabel(e);
1565
2226
  return `<div class="row crow ${e._key === sel ? "sel" : ""}" data-key="${esc(e._key)}">
2227
+ ${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}
1566
2228
  <span class="dot ${esc(e.status)}"></span>
1567
2229
  ${hasIdent(id) ? `<span class="cident" title="${esc(fullIdent(e))}">${esc(id)}</span>` : ""}
1568
2230
  ${cli ? `<span class="cname">${esc(cli)}</span>` : ""}
1569
2231
  <span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
2232
+ ${taskChipHtml(e)}
1570
2233
  ${gitChipHtml(e)}
1571
2234
  <span class="age">${age(e)}</span></div>`;
1572
2235
  })
@@ -1574,10 +2237,12 @@
1574
2237
  return;
1575
2238
  }
1576
2239
  $("list").innerHTML =
1577
- shown
1578
- .map((e) => {
2240
+ rows
2241
+ .map((r) => {
2242
+ if (r.kind !== "agent") return headerHtml(r);
2243
+ const e = r.entry;
1579
2244
  // The host tag only earns its place when several machines are in
1580
- // play; otherwise it's noise (every row would carry the same one).
2245
+ // play AND it isn't already shown by a peer header above this row.
1581
2246
  const tags = tagsFor(e)
1582
2247
  .filter(([k]) => k !== "host" || multiDevice)
1583
2248
  .map(
@@ -1586,9 +2251,10 @@
1586
2251
  )
1587
2252
  .join("");
1588
2253
  return `<div class="row ${e._key === sel ? "sel" : ""}" data-key="${esc(e._key)}">
1589
- <div class="r1"><span class="dot ${esc(e.status)}"></span>
2254
+ <div class="r1">${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}<span class="dot ${esc(e.status)}"></span>
1590
2255
  <span class="name">${esc(cliLabel(e) || ident(e) || "agent")}</span>
1591
2256
  <span class="badge">pid ${e.pid}</span>
2257
+ ${taskChipHtml(e)}
1592
2258
  ${gitChipHtml(e)}
1593
2259
  <span class="age">${age(e)}</span></div>
1594
2260
  ${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
@@ -1690,6 +2356,7 @@
1690
2356
  return;
1691
2357
  }
1692
2358
  sel = e._key;
2359
+ paintHeaderBadge();
1693
2360
  // Remember the selection so a refresh re-opens this agent (see boot/autoPid).
1694
2361
  try {
1695
2362
  localStorage.setItem("ay.sel", sel);
@@ -1796,8 +2463,10 @@
1796
2463
  // keyword with 400 (pid arrives as a number from /api/ls JSON).
1797
2464
  const kw = String(pid);
1798
2465
  const fwd = (d) => {
1799
- if (sel === e._key)
2466
+ if (sel === e._key) {
2467
+ perfNote("in", typeof d === "string" ? d.length : (d?.byteLength ?? 0));
1800
2468
  tx.post("/api/send", { keyword: kw, msg: d, code: "none" }).catch(() => {});
2469
+ }
1801
2470
  };
1802
2471
  term.onData(fwd);
1803
2472
  term.onBinary(fwd);
@@ -1837,7 +2506,10 @@
1837
2506
  const close = tx.subscribe(
1838
2507
  "/api/tail/" + encodeURIComponent(pid) + "?raw=1",
1839
2508
  (raw) => {
1840
- if (term) term.write(raw);
2509
+ if (term) {
2510
+ term.write(raw);
2511
+ perfNote("out", raw?.length ?? 0);
2512
+ }
1841
2513
  },
1842
2514
  () => {
1843
2515
  $("livedot").className = "dot active";
@@ -1882,7 +2554,10 @@
1882
2554
  // renders. Clamps at the ends, scrolls the row into view.
1883
2555
  function stepSelection(dir) {
1884
2556
  const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
1885
- const shown = entries.filter((e) => matches(e, toks));
2557
+ // Must match renderList's order; step only over agent rows (skip headers).
2558
+ const shown = layeredRows(entries.filter((e) => matches(e, toks)))
2559
+ .filter((r) => r.kind === "agent")
2560
+ .map((r) => r.entry);
1886
2561
  if (!shown.length) return;
1887
2562
  const cur = shown.findIndex((e) => e._key === sel);
1888
2563
  const next = shown[nextIndex(shown.length, cur, dir)];