agent-yes 1.122.3 → 1.124.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-Cvm7yo5d.js +8 -0
  3. package/dist/{SUPPORTED_CLIS-BleNYXA2.js → SUPPORTED_CLIS-D_-bIOlW.js} +2 -2
  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-e4f7NlA2.js → schedule-D2cn8N7o.js} +7 -7
  16. package/dist/{serve-CzztmZ_N.js → serve-Bo3bDXQG.js} +202 -58
  17. package/dist/{setup-CPyRNiIA.js → setup-CvOr258q.js} +3 -3
  18. package/dist/{share-CS9XVrLF.js → share-YuM6-Q6A.js} +71 -13
  19. package/dist/{subcommands-CQowpr1t.js → subcommands-ClVHy-xI.js} +647 -32
  20. package/dist/subcommands-Llf9o8nh.js +7 -0
  21. package/dist/{tray-DjCIyakK.js → tray-BVnJLThD.js} +1 -1
  22. package/dist/{ts-9GThuc3w.js → ts-DGIglR4L.js} +10 -7
  23. package/dist/{versionChecker-Bv9XKddN.js → versionChecker-gaQkM2Hy.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 +1152 -28
  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 +144 -27
  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-ClaOErso.js +0 -8
  54. package/dist/pidStore-7y1cTcAE.js +0 -5
  55. package/dist/reaper-HqcUms2d.js +0 -3
  56. package/dist/subcommands-KAbIcd8_.js +0 -6
package/lab/ui/index.html CHANGED
@@ -2,8 +2,23 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
5
+ <meta
6
+ name="viewport"
7
+ content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
8
+ />
6
9
  <title>agent-yes · console</title>
10
+ <!-- PWA: installable console under /w/ (manifest scope is /w/). The service
11
+ worker is network-first so it never serves a stale wire protocol — the
12
+ cache is only an offline-launch fallback. -->
13
+ <meta name="theme-color" content="#0d1117" />
14
+ <link rel="manifest" href="./manifest.webmanifest" />
15
+ <link rel="icon" href="./icon.svg" />
16
+ <link rel="apple-touch-icon" href="./icon.svg" />
17
+ <script>
18
+ if ("serviceWorker" in navigator) {
19
+ addEventListener("load", () => navigator.serviceWorker.register("./sw.js").catch(() => {}));
20
+ }
21
+ </script>
7
22
  <style>
8
23
  /* Palette borrowed from codehost (GitHub-dark) so the two feel like one system. */
9
24
  :root {
@@ -23,6 +38,17 @@
23
38
  --pink: #f778ba;
24
39
  --red: #f85149;
25
40
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
41
+ /* Safe-area insets — non-zero only in a standalone PWA on a notched /
42
+ gesture-bar device (else 0, so these are harmless on desktop). Consumed
43
+ via max() so the surface keeps its normal padding when the inset is 0.
44
+ Needs <meta viewport ... viewport-fit=cover> to be reported at all. */
45
+ --sat: env(safe-area-inset-top);
46
+ --sar: env(safe-area-inset-right);
47
+ --sab: env(safe-area-inset-bottom);
48
+ --sal: env(safe-area-inset-left);
49
+ /* height the iOS soft keyboard overlays the viewport with (set from JS
50
+ via visualViewport; 0 on desktop / Android, which resizes instead). */
51
+ --kb: 0px;
26
52
  }
27
53
  /* Light theme — GitHub-light, the daylight counterpart of the dark palette
28
54
  above. prefers-color-scheme is re-evaluated by the browser the moment the
@@ -81,7 +107,8 @@
81
107
  grid-template-columns: var(--leftw, 42%) 1px 1fr;
82
108
  grid-template-rows: minmax(0, 1fr);
83
109
  height: 100vh;
84
- height: 100dvh;
110
+ /* subtract the iOS keyboard overlay so the composer stays above it */
111
+ height: calc(100dvh - var(--kb, 0px));
85
112
  }
86
113
 
87
114
  /* VSCode-style splitter: the visible divider stays 1px, but a ::before
@@ -113,7 +140,7 @@
113
140
  min-height: 0;
114
141
  }
115
142
  .head {
116
- padding: 16px 18px 10px;
143
+ padding: max(16px, var(--sat)) max(18px, var(--sar)) 10px max(18px, var(--sal));
117
144
  border-bottom: 1px solid var(--line);
118
145
  }
119
146
  h1 {
@@ -124,6 +151,201 @@
124
151
  h1 .tag {
125
152
  color: var(--accent);
126
153
  }
154
+ /* Connection-path pill in the header: at a glance, is the terminal you're
155
+ watching on the fast local/lan path or the slow relayed path? */
156
+ /* Per-peer connection-type pill in the terminal header — describes how this
157
+ viewer reaches the SELECTED agent's host (local/lan/wan/relay). */
158
+ .rhead .ctype {
159
+ position: relative;
160
+ font-size: 10px;
161
+ font-weight: 600;
162
+ letter-spacing: 0.02em;
163
+ align-self: center;
164
+ padding: 1px 7px;
165
+ border-radius: 999px;
166
+ border: 1px solid currentColor;
167
+ text-transform: uppercase;
168
+ opacity: 0.9;
169
+ cursor: help;
170
+ }
171
+ .ctype[hidden] {
172
+ display: none;
173
+ }
174
+ /* Hover popover with the connection debug readout. */
175
+ .ctype .ctip {
176
+ display: none;
177
+ position: absolute;
178
+ top: 150%;
179
+ right: 0;
180
+ z-index: 30;
181
+ min-width: 240px;
182
+ padding: 8px 10px;
183
+ background: var(--panel);
184
+ border: 1px solid var(--line);
185
+ border-radius: 8px;
186
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
187
+ color: var(--fg);
188
+ font-family: var(--mono);
189
+ font-size: 10.5px;
190
+ font-weight: 400;
191
+ line-height: 1.6;
192
+ letter-spacing: 0;
193
+ text-transform: none;
194
+ text-align: left;
195
+ white-space: nowrap;
196
+ }
197
+ .ctype:hover .ctip {
198
+ display: block;
199
+ }
200
+ .ctip-h {
201
+ color: var(--muted);
202
+ margin-bottom: 5px;
203
+ white-space: normal;
204
+ }
205
+ .ctip-k {
206
+ display: inline-block;
207
+ width: 56px;
208
+ color: var(--muted);
209
+ }
210
+ /* ⋯ overflow menu in the terminal header + its dropdown. */
211
+ .rmenu {
212
+ position: relative;
213
+ margin-left: auto;
214
+ }
215
+ .rmenubtn {
216
+ background: none;
217
+ border: none;
218
+ color: var(--muted);
219
+ font-size: 16px;
220
+ line-height: 1;
221
+ cursor: pointer;
222
+ padding: 2px 6px;
223
+ border-radius: 6px;
224
+ }
225
+ .rmenubtn:hover {
226
+ background: var(--panel);
227
+ color: var(--fg);
228
+ }
229
+ .rmenupanel {
230
+ position: absolute;
231
+ top: 26px;
232
+ right: 0;
233
+ z-index: 15;
234
+ background: var(--panel);
235
+ border: 1px solid var(--line);
236
+ border-radius: 8px;
237
+ padding: 4px;
238
+ min-width: 140px;
239
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
240
+ }
241
+ .rmenupanel[hidden] {
242
+ display: none;
243
+ }
244
+ .rmenuitem {
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 7px;
248
+ padding: 5px 8px;
249
+ font-size: 12.5px;
250
+ border-radius: 6px;
251
+ cursor: pointer;
252
+ user-select: none;
253
+ }
254
+ .rmenuitem:hover {
255
+ background: var(--bg);
256
+ }
257
+ .rmenuitem input {
258
+ margin: 0;
259
+ cursor: pointer;
260
+ }
261
+ /* Action items (Stop / Force-kill) reuse the menu-item look as buttons. */
262
+ .rmenuact {
263
+ width: 100%;
264
+ background: none;
265
+ border: none;
266
+ text-align: left;
267
+ font: inherit;
268
+ color: var(--fg);
269
+ }
270
+ .rmenuact.danger {
271
+ color: var(--red);
272
+ }
273
+ .rmenusep {
274
+ height: 1px;
275
+ background: var(--line);
276
+ margin: 4px 2px;
277
+ }
278
+ /* Terminal font-size stepper (a static row, not a hover/click target). */
279
+ .rmenufont {
280
+ justify-content: space-between;
281
+ cursor: default;
282
+ }
283
+ .rmenufont:hover {
284
+ background: none;
285
+ }
286
+ .fontstep {
287
+ display: inline-flex;
288
+ align-items: center;
289
+ gap: 8px;
290
+ }
291
+ .fontstep button {
292
+ width: 26px;
293
+ height: 26px;
294
+ background: var(--panel2);
295
+ border: 1px solid var(--line);
296
+ border-radius: 6px;
297
+ color: var(--fg);
298
+ font-size: 13px;
299
+ line-height: 1;
300
+ cursor: pointer;
301
+ }
302
+ .fontstep button:hover {
303
+ border-color: var(--accent);
304
+ color: var(--accent);
305
+ }
306
+ .fontstep #fontVal {
307
+ min-width: 18px;
308
+ text-align: center;
309
+ font-variant-numeric: tabular-nums;
310
+ color: var(--muted);
311
+ }
312
+ /* Live performance HUD — opt-in via the ⋯ menu, hidden by default. */
313
+ .perfhud {
314
+ position: absolute;
315
+ top: 44px;
316
+ right: 12px;
317
+ z-index: 14;
318
+ background: rgba(13, 17, 23, 0.82);
319
+ color: var(--fg);
320
+ border: 1px solid var(--line);
321
+ border-radius: 8px;
322
+ padding: 7px 10px;
323
+ font-family: var(--mono);
324
+ font-size: 11px;
325
+ line-height: 1.5;
326
+ pointer-events: none;
327
+ white-space: pre;
328
+ backdrop-filter: blur(3px);
329
+ }
330
+ .perfhud[hidden] {
331
+ display: none;
332
+ }
333
+ .perfhud b {
334
+ color: var(--accent);
335
+ font-weight: 600;
336
+ }
337
+ /* Per-peer connection-type tag on peer-group headers in the tree. */
338
+ .ghead .ctag {
339
+ font-size: 9.5px;
340
+ font-weight: 600;
341
+ text-transform: uppercase;
342
+ letter-spacing: 0.02em;
343
+ margin-left: 6px;
344
+ padding: 0 5px;
345
+ border-radius: 999px;
346
+ border: 1px solid currentColor;
347
+ opacity: 0.85;
348
+ }
127
349
  .sub {
128
350
  color: var(--muted);
129
351
  font-size: 12.5px;
@@ -454,6 +676,8 @@
454
676
  overflow-y: auto;
455
677
  flex: 1;
456
678
  min-height: 0;
679
+ /* clear the home-indicator / gesture bar so the last row stays tappable */
680
+ padding-bottom: var(--sab);
457
681
  }
458
682
  .row {
459
683
  padding: 11px 18px;
@@ -516,6 +740,23 @@
516
740
  .git.dirty {
517
741
  color: var(--amber);
518
742
  }
743
+ /* Task-progress badge ("2/5") parsed from the agent's todo block, sits next
744
+ to the git chip. Pill so it reads as a discrete counter; green when all
745
+ tasks are done. */
746
+ .tasks {
747
+ font-family: var(--mono);
748
+ font-size: 10.5px;
749
+ color: var(--muted);
750
+ white-space: nowrap;
751
+ flex: none;
752
+ padding: 0 5px;
753
+ border: 1px solid var(--line);
754
+ border-radius: 8px;
755
+ }
756
+ .tasks.done {
757
+ color: var(--green);
758
+ border-color: #2a3a2a;
759
+ }
519
760
  .detail {
520
761
  color: var(--muted);
521
762
  font-size: 12.5px;
@@ -539,6 +780,46 @@
539
780
  gap: 8px;
540
781
  padding: 6px 18px;
541
782
  }
783
+ /* Tree branch prefix for nested (sub)agents — monospace so the box-drawing
784
+ glyphs line up into continuous │ ├ └ rails across rows. */
785
+ .tbranch {
786
+ font-family: var(--mono);
787
+ color: var(--muted);
788
+ white-space: pre;
789
+ flex: none;
790
+ opacity: 0.7;
791
+ margin-right: -2px;
792
+ }
793
+ /* Room/peer group header rows (the upper layers of the tree). Non-selectable,
794
+ dimmer and smaller than agent rows so they read as containers, not agents. */
795
+ .ghead {
796
+ display: flex;
797
+ align-items: center;
798
+ gap: 6px;
799
+ padding: 5px 18px 2px;
800
+ font-size: 11.5px;
801
+ color: var(--muted);
802
+ user-select: none;
803
+ }
804
+ .ghead .gicon {
805
+ opacity: 0.6;
806
+ flex: none;
807
+ }
808
+ .ghead .glabel {
809
+ font-weight: 600;
810
+ color: var(--fg);
811
+ overflow: hidden;
812
+ text-overflow: ellipsis;
813
+ white-space: nowrap;
814
+ }
815
+ .ghead.g-room .glabel {
816
+ color: var(--accent);
817
+ }
818
+ .ghead .gkind {
819
+ font-size: 10px;
820
+ opacity: 0.45;
821
+ flex: none;
822
+ }
542
823
  .crow .cident {
543
824
  font-family: var(--mono);
544
825
  font-size: 11.5px;
@@ -601,9 +882,10 @@
601
882
  flex-direction: column;
602
883
  min-width: 0;
603
884
  min-height: 0;
885
+ position: relative; /* anchor for the absolutely-positioned perf HUD */
604
886
  }
605
887
  .rhead {
606
- padding: 14px 20px;
888
+ padding: max(14px, var(--sat)) max(20px, var(--sar)) 14px max(20px, var(--sal));
607
889
  border-bottom: 1px solid var(--line);
608
890
  display: flex;
609
891
  align-items: center;
@@ -611,6 +893,13 @@
611
893
  }
612
894
  .rhead .name {
613
895
  font-size: 15px;
896
+ /* shrink + ellipsis so a long agent title can't shove the live/⋯
897
+ controls off the edge on a narrow phone header */
898
+ flex: 0 1 auto;
899
+ min-width: 0;
900
+ overflow: hidden;
901
+ text-overflow: ellipsis;
902
+ white-space: nowrap;
614
903
  }
615
904
  /* back-to-list affordance — only surfaces in the single-column mobile view */
616
905
  .rback {
@@ -651,7 +940,7 @@
651
940
  flex: 1;
652
941
  min-height: 0;
653
942
  overflow: hidden;
654
- padding: 8px 10px;
943
+ padding: 8px max(10px, var(--sar)) 8px max(10px, var(--sal));
655
944
  background: var(--bg);
656
945
  }
657
946
  .log .xterm {
@@ -668,6 +957,96 @@
668
957
  color: var(--muted);
669
958
  font-size: 14px;
670
959
  }
960
+ /* On-screen key bar — hidden on desktop, flex on mobile (720px block).
961
+ Scrolls horizontally so it never wraps or steals height from the log. */
962
+ .keybar {
963
+ display: none;
964
+ flex: none;
965
+ gap: 6px;
966
+ align-items: center;
967
+ padding: 6px max(8px, var(--sar)) 6px max(8px, var(--sal));
968
+ border-top: 1px solid var(--line);
969
+ background: var(--panel);
970
+ overflow-x: auto;
971
+ -webkit-overflow-scrolling: touch;
972
+ scrollbar-width: none;
973
+ }
974
+ .keybar::-webkit-scrollbar {
975
+ display: none;
976
+ }
977
+ .keybar .kb {
978
+ flex: none;
979
+ min-width: 36px;
980
+ height: 36px;
981
+ padding: 0 11px;
982
+ background: var(--panel2);
983
+ border: 1px solid var(--line);
984
+ border-radius: 8px;
985
+ color: var(--fg);
986
+ font: 13px/1 var(--mono);
987
+ cursor: pointer;
988
+ -webkit-tap-highlight-color: transparent;
989
+ user-select: none;
990
+ }
991
+ .keybar .kb:active {
992
+ background: var(--line);
993
+ }
994
+ /* a sticky Ctrl/Alt that's armed for the next keystroke */
995
+ .keybar .kb.on {
996
+ background: var(--accent);
997
+ border-color: var(--accent);
998
+ color: #fff;
999
+ }
1000
+ /* Line composer — mobile-only. Carries the bottom safe-area inset so it
1001
+ clears the home indicator (completes the terminal pane's inset cover). */
1002
+ .composer {
1003
+ display: none;
1004
+ flex: none;
1005
+ gap: 8px;
1006
+ align-items: flex-end;
1007
+ padding: 8px max(10px, var(--sar)) max(8px, var(--sab)) max(10px, var(--sal));
1008
+ border-top: 1px solid var(--line);
1009
+ background: var(--panel);
1010
+ }
1011
+ .composer textarea {
1012
+ flex: 1;
1013
+ min-width: 0;
1014
+ resize: none;
1015
+ max-height: 120px;
1016
+ padding: 9px 11px;
1017
+ background: var(--bg);
1018
+ border: 1px solid var(--line);
1019
+ border-radius: 10px;
1020
+ color: var(--fg);
1021
+ /* ≥16px so iOS Safari doesn't auto-zoom the page when it gains focus */
1022
+ font:
1023
+ 16px/1.4 -apple-system,
1024
+ BlinkMacSystemFont,
1025
+ system-ui,
1026
+ sans-serif;
1027
+ }
1028
+ .composer textarea:focus {
1029
+ outline: none;
1030
+ border-color: var(--accent);
1031
+ }
1032
+ .composer .cmpsend {
1033
+ flex: none;
1034
+ height: 38px;
1035
+ padding: 0 14px;
1036
+ background: var(--accent);
1037
+ border: 1px solid var(--accent);
1038
+ border-radius: 10px;
1039
+ color: #fff;
1040
+ font:
1041
+ 600 13px/1 -apple-system,
1042
+ system-ui,
1043
+ sans-serif;
1044
+ cursor: pointer;
1045
+ -webkit-tap-highlight-color: transparent;
1046
+ }
1047
+ .composer .cmpsend:active {
1048
+ opacity: 0.85;
1049
+ }
671
1050
 
672
1051
  /* ---- mobile: collapse the two-pane desktop layout into a single column ----
673
1052
  The list and the terminal stack into the same cell; only one is visible at
@@ -706,7 +1085,7 @@
706
1085
  }
707
1086
 
708
1087
  .head {
709
- padding: 12px 14px 8px;
1088
+ padding: max(12px, var(--sat)) max(14px, var(--sar)) 8px max(14px, var(--sal));
710
1089
  }
711
1090
  h1 {
712
1091
  font-size: 17px;
@@ -730,7 +1109,8 @@
730
1109
  padding: 10px 14px;
731
1110
  }
732
1111
  .rhead {
733
- padding: 12px 14px;
1112
+ padding: max(12px, var(--sat)) max(14px, var(--sar)) 12px max(14px, var(--sal));
1113
+ gap: 8px;
734
1114
  }
735
1115
  .list .row,
736
1116
  .rooms .ritem,
@@ -741,6 +1121,15 @@
741
1121
  -webkit-tap-highlight-color: transparent;
742
1122
  }
743
1123
  }
1124
+ /* Touch input aids (key bar + composer) show on ANY touch device, not just
1125
+ narrow ones — so a tablet in the two-pane layout (typically no hardware
1126
+ keyboard) still gets Esc/Ctrl/arrows and a reliable line composer. */
1127
+ @media (pointer: coarse) {
1128
+ .keybar,
1129
+ .composer {
1130
+ display: flex;
1131
+ }
1132
+ }
744
1133
  </style>
745
1134
  <link
746
1135
  rel="stylesheet"
@@ -811,12 +1200,75 @@
811
1200
  <span class="live"
812
1201
  ><span class="dot" id="livedot"></span><span id="livetxt">connecting…</span></span
813
1202
  >
1203
+ <span
1204
+ id="ctype"
1205
+ class="ctype"
1206
+ title="how your browser reaches this agent's host"
1207
+ hidden
1208
+ ></span>
1209
+ <span class="rmenu">
1210
+ <button class="rmenubtn" id="rmenubtn" title="more" aria-label="more">⋯</button>
1211
+ <div class="rmenupanel" id="rmenupanel" hidden>
1212
+ <div class="rmenuitem rmenufont">
1213
+ <span>Font size</span>
1214
+ <span class="fontstep">
1215
+ <button type="button" id="fontDown" aria-label="smaller font">A−</button>
1216
+ <span id="fontVal">12</span>
1217
+ <button type="button" id="fontUp" aria-label="larger font">A+</button>
1218
+ </span>
1219
+ </div>
1220
+ <div class="rmenusep"></div>
1221
+ <label class="rmenuitem"
1222
+ ><input type="checkbox" id="perfToggle" /> <span>Perf HUD</span></label
1223
+ >
1224
+ <div class="rmenusep"></div>
1225
+ <button class="rmenuitem rmenuact" id="stopAgent" type="button">⏹ Stop agent</button>
1226
+ <button class="rmenuitem rmenuact danger" id="killAgent" type="button">
1227
+ ✕ Force-kill (SIGKILL)
1228
+ </button>
1229
+ </div>
1230
+ </span>
814
1231
  </div>
815
1232
  <div class="log" id="log">
816
1233
  <div class="placeholder">
817
1234
  ← pick an agent to tail its log; type directly into the terminal
818
1235
  </div>
819
1236
  </div>
1237
+ <!-- On-screen key bar — phones have no Esc/Tab/Ctrl/arrows, so agent TUIs
1238
+ are otherwise undriveable. Mobile-only (shown in the 720px block);
1239
+ posts raw sequences to the agent over the same /api/send wire. -->
1240
+ <div class="keybar" id="keybar" role="group" aria-label="terminal keys">
1241
+ <button class="kb" data-key="esc" title="Escape">Esc</button>
1242
+ <button class="kb kbmod" data-mod="ctrl" title="Ctrl — applies to the next key">
1243
+ Ctrl
1244
+ </button>
1245
+ <button class="kb kbmod" data-mod="alt" title="Alt — applies to the next key">Alt</button>
1246
+ <button class="kb" data-key="tab" title="Tab">Tab</button>
1247
+ <button class="kb" data-key="cc" title="Ctrl-C — interrupt">^C</button>
1248
+ <button class="kb" data-arrow="up" title="Up" aria-label="Up">↑</button>
1249
+ <button class="kb" data-arrow="down" title="Down" aria-label="Down">↓</button>
1250
+ <button class="kb" data-arrow="left" title="Left" aria-label="Left">←</button>
1251
+ <button class="kb" data-arrow="right" title="Right" aria-label="Right">→</button>
1252
+ <button class="kb" data-key="enter" title="Enter">⏎</button>
1253
+ </div>
1254
+ <!-- Line composer — the reliable path for typing a prompt on a phone:
1255
+ a normal textarea (no IME/autocorrect surprises that plague xterm's
1256
+ hidden input). Enter sends, Shift+Enter inserts a newline. -->
1257
+ <form class="composer" id="composer">
1258
+ <textarea
1259
+ id="cmpin"
1260
+ rows="1"
1261
+ placeholder="Message agent… ↵ to send"
1262
+ enterkeyhint="send"
1263
+ inputmode="text"
1264
+ autocapitalize="off"
1265
+ autocorrect="off"
1266
+ autocomplete="off"
1267
+ spellcheck="false"
1268
+ ></textarea>
1269
+ <button type="submit" class="cmpsend" title="Send to agent">Send</button>
1270
+ </form>
1271
+ <div class="perfhud" id="perfhud" hidden></div>
820
1272
  </div>
821
1273
  </div>
822
1274
 
@@ -842,6 +1294,8 @@
842
1294
  fullIdent,
843
1295
  hasIdent,
844
1296
  deviceCount,
1297
+ layeredRows,
1298
+ taskLabel,
845
1299
  } from "./console-logic.js";
846
1300
  import {
847
1301
  MARKER as E2E_MARKER,
@@ -864,6 +1318,28 @@
864
1318
  let es = null; // live-tail subscription closer
865
1319
  let term = null; // xterm.js Terminal rendering the raw PTY stream
866
1320
  let fit = null;
1321
+ // Terminal font size (px) — adjustable from the ⋯ menu, persisted across
1322
+ // reloads, applied live to the open terminal and used by every new one.
1323
+ let termFontSize = 12;
1324
+ try {
1325
+ const n = parseInt(localStorage.getItem("ay.fontSize") || "", 10);
1326
+ if (n >= 8 && n <= 32) termFontSize = n;
1327
+ } catch {}
1328
+ function setTermFontSize(px) {
1329
+ termFontSize = Math.max(8, Math.min(32, px | 0));
1330
+ const v = $("fontVal");
1331
+ if (v) v.textContent = String(termFontSize);
1332
+ try {
1333
+ localStorage.setItem("ay.fontSize", String(termFontSize));
1334
+ } catch {}
1335
+ if (term) {
1336
+ term.options.fontSize = termFontSize;
1337
+ if (fit)
1338
+ try {
1339
+ fit.fit();
1340
+ } catch {}
1341
+ }
1342
+ }
867
1343
 
868
1344
  // xterm paints to a <canvas>, so unlike the CSS var() consumers it can't
869
1345
  // ride prefers-color-scheme on its own — its theme is a JS object. Mirror
@@ -1415,6 +1891,539 @@
1415
1891
  const srcFor = (e) => (e && sources.get(e._room)) || sources.get(LOCAL) || null;
1416
1892
  const txFor = (e) => srcFor(e)?.tx || localTx;
1417
1893
 
1894
+ // ---- connection-path classification (header [local/lan/wan/relay] pill) ----
1895
+ // Tells the user, at a glance, whether the terminal they're watching rides the
1896
+ // fast direct path or a relayed one — the difference between snappy and laggy.
1897
+ const isLoopbackIp = (ip) => ip === "::1" || /^127\./.test(ip);
1898
+ const isPrivateIp = (ip) =>
1899
+ /^10\./.test(ip) ||
1900
+ /^192\.168\./.test(ip) ||
1901
+ /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
1902
+ /^169\.254\./.test(ip) || // IPv4 link-local
1903
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip) || // CGNAT / Tailscale (100.64.0.0/10)
1904
+ /^fe80:/i.test(ip) || // IPv6 link-local
1905
+ /^f[cd][0-9a-f]{2}:/i.test(ip) || // IPv6 ULA (fc00::/7, incl. Tailscale fd7a:…)
1906
+ /\.local$/i.test(ip); // mDNS host candidate (browser-obfuscated LAN IP)
1907
+ // Inspect the selected ICE candidate pair and return the full picture for the
1908
+ // hover tooltip: kind, RTT, both candidates, a NAT-traversal hint, and the open
1909
+ // data channel. `kind` is classified by how we reach the REMOTE host: relay on
1910
+ // either end = TURN-proxied; otherwise the remote candidate's type/address
1911
+ // decides local/lan/wan. (The LOCAL end is often prflx/srflx even on a fast
1912
+ // private path — e.g. a Tailscale/CGNAT mesh shows local=prflx, remote=host
1913
+ // 100.x at ~1ms — so we key on the remote, not on both ends being `host`.)
1914
+ async function inspectConn(pc) {
1915
+ if (!pc || typeof pc.getStats !== "function") return null;
1916
+ try {
1917
+ const stats = await pc.getStats();
1918
+ let pairId = null;
1919
+ stats.forEach((s) => {
1920
+ if (s.type === "transport" && s.selectedCandidatePairId)
1921
+ pairId = s.selectedCandidatePairId;
1922
+ });
1923
+ let pair = null;
1924
+ stats.forEach((s) => {
1925
+ if (
1926
+ pairId
1927
+ ? s.id === pairId
1928
+ : s.type === "candidate-pair" && s.state === "succeeded" && s.nominated
1929
+ )
1930
+ pair = s;
1931
+ });
1932
+ if (!pair) return null;
1933
+ let local = null,
1934
+ remote = null;
1935
+ stats.forEach((s) => {
1936
+ if (s.id === pair.localCandidateId) local = s;
1937
+ if (s.id === pair.remoteCandidateId) remote = s;
1938
+ });
1939
+ if (!local || !remote) return null;
1940
+ const lt = local.candidateType,
1941
+ rt = remote.candidateType,
1942
+ rip = remote.address || remote.ip || "";
1943
+ let kind, nat;
1944
+ if (lt === "relay" || rt === "relay") {
1945
+ kind = "relay";
1946
+ nat = "TURN-relayed — symmetric NAT or UDP blocked";
1947
+ } else if (rt === "host") {
1948
+ kind = isLoopbackIp(rip) ? "local" : !rip || isPrivateIp(rip) ? "lan" : "wan";
1949
+ nat =
1950
+ lt === "host"
1951
+ ? "direct — both ends on-net"
1952
+ : "direct to host" + (lt === "srflx" || lt === "prflx" ? " (you behind NAT)" : "");
1953
+ } else {
1954
+ kind = "wan";
1955
+ nat = "STUN-reflexive P2P (NAT-traversed)";
1956
+ }
1957
+ const fmt = (c) => c.candidateType + "/" + (c.protocol || "?");
1958
+ return {
1959
+ kind,
1960
+ nat,
1961
+ rttMs:
1962
+ pair.currentRoundTripTime != null
1963
+ ? Math.round(pair.currentRoundTripTime * 1000)
1964
+ : null,
1965
+ local:
1966
+ fmt(local) + (local.address || local.ip ? " " + (local.address || local.ip) : ""),
1967
+ remote: fmt(remote) + (rip ? " " + rip : ""),
1968
+ dc:
1969
+ [...stats.values()]
1970
+ .filter((v) => v.type === "data-channel" && v.state === "open")
1971
+ .map((v) => v.label)
1972
+ .join(",") || null,
1973
+ };
1974
+ } catch {
1975
+ return null;
1976
+ }
1977
+ }
1978
+ // Resolve the live RTCPeerConnection that carries a SPECIFIC agent. A codehost
1979
+ // room holds one peer per host (room.rtcs keyed by peerId); byPid maps the
1980
+ // agent's pid to its peerId, so each agent resolves to ITS host's connection —
1981
+ // a VPS agent reads wan while a tak.local agent in the same room reads lan.
1982
+ function pcForAgent(s, e) {
1983
+ if (!s) return null;
1984
+ if (s.kind === "rtc") return s.client?.pc || null; // share room: one host
1985
+ if (s.kind === "ch") {
1986
+ const peerId = s.client?.byPid?.get(String(e?.pid));
1987
+ return (peerId && s.client?.room?.rtcs?.get(peerId)?.pc) || null;
1988
+ }
1989
+ return null; // local ay-serve has no peer connection
1990
+ }
1991
+ // Two cache views, both filled from one classification pass:
1992
+ // - connKinds keyed by the true PEER (room + peerId) drives the header badge,
1993
+ // so two machines that happen to share a hostname never collide.
1994
+ // - hostKinds keyed by (room, host) drives the peer-group header tags, which
1995
+ // only ever render when hosts are already distinct.
1996
+ const hostKeyOf = (e) => (e?._room || "") + " " + (e?._host || "");
1997
+ function peerKeyOf(s, e) {
1998
+ if (!s || s.id === LOCAL || s.kind == null) return LOCAL;
1999
+ if (s.kind === "ch") {
2000
+ const peerId = s.client?.byPid?.get(String(e?.pid));
2001
+ return s.id + " " + (peerId || e?._host || "");
2002
+ }
2003
+ return s.id; // rtc share: one peer per room
2004
+ }
2005
+ const connKinds = new Map(); // peerKey -> "local" | "lan" | "wan" | "relay"
2006
+ const hostKinds = new Map(); // hostKey -> same (peer-header tags)
2007
+ const connInfo = new Map(); // peerKey -> full detail for the hover tooltip
2008
+ let connSig = "";
2009
+ // The signaling server backing a source: codehost rooms use the codehost
2010
+ // signal DO; agent-yes share rooms use their own host; local has none.
2011
+ const signalingOf = (s) =>
2012
+ !s || s.id === LOCAL || s.kind == null
2013
+ ? null
2014
+ : s.kind === "ch"
2015
+ ? "wss://signal.codehost.dev"
2016
+ : "wss://" + (s.host || SIG_DEFAULT);
2017
+ const CTYPE_COLORS = {
2018
+ local: "var(--green)",
2019
+ lan: "var(--green)",
2020
+ wan: "var(--amber)",
2021
+ relay: "var(--red)",
2022
+ };
2023
+ const CTYPE_TIPS = {
2024
+ local: "this machine (ay serve, local)",
2025
+ lan: "direct over the local network (fast)",
2026
+ wan: "direct over the internet, peer-to-peer (STUN — RTT-bound)",
2027
+ relay: "TURN-relayed — proxied through a server (slowest path)",
2028
+ };
2029
+ // A small colored pill for a connection kind — reused by the header badge and
2030
+ // every peer-group header in the tree.
2031
+ function connTagHtml(kind) {
2032
+ if (!kind) return "";
2033
+ return `<span class="ctag" style="color:${CTYPE_COLORS[kind]}" title="${esc(CTYPE_TIPS[kind])}">${kind}</span>`;
2034
+ }
2035
+ // Classify every distinct peer in the fleet, caching by (room,host). Because
2036
+ // each agent reads its own host's path, a mixed room (VPS + laptop) shows both
2037
+ // wan and lan at once — exactly what tells you a "local" agent is wrongly remote.
2038
+ async function refreshConnKinds() {
2039
+ // Probe each distinct peer once (not once per agent), keyed by peerKey.
2040
+ const reps = new Map(); // peerKey -> a representative entry for that peer
2041
+ for (const e of entries) {
2042
+ const k = peerKeyOf(srcFor(e), e);
2043
+ if (!reps.has(k)) reps.set(k, e);
2044
+ }
2045
+ connKinds.clear();
2046
+ connInfo.clear();
2047
+ await Promise.all(
2048
+ [...reps].map(async ([key, e]) => {
2049
+ const s = srcFor(e);
2050
+ if (!s || s.id === LOCAL || s.kind == null) {
2051
+ if (s && s.live) {
2052
+ connKinds.set(key, "local");
2053
+ connInfo.set(key, {
2054
+ kind: "local",
2055
+ nat: "this machine (ay serve)",
2056
+ signaling: null,
2057
+ });
2058
+ }
2059
+ return;
2060
+ }
2061
+ const info = await inspectConn(pcForAgent(s, e));
2062
+ if (info) {
2063
+ connKinds.set(key, info.kind);
2064
+ connInfo.set(key, { ...info, signaling: signalingOf(s) });
2065
+ }
2066
+ }),
2067
+ );
2068
+ // Project peer kinds onto (room,host) keys for the tree's peer headers.
2069
+ hostKinds.clear();
2070
+ for (const e of entries) {
2071
+ const kind = connKinds.get(peerKeyOf(srcFor(e), e));
2072
+ if (kind) hostKinds.set(hostKeyOf(e), kind);
2073
+ }
2074
+ paintHeaderBadge();
2075
+ // Repaint the list only when the peer-header tags actually changed — the poll
2076
+ // already re-renders on data deltas, so this avoids churning the DOM every tick.
2077
+ const sig = [...hostKinds]
2078
+ .sort()
2079
+ .map(([k, v]) => k + "=" + v)
2080
+ .join(";");
2081
+ if (sig !== connSig) {
2082
+ connSig = sig;
2083
+ renderList();
2084
+ }
2085
+ }
2086
+ // The terminal-header pill shows how this viewer reaches the SELECTED agent's
2087
+ // host peer (local/lan/wan/relay) — per-peer, contextual to what's open. Reads
2088
+ // the cache only (kept sync so renders never await); hidden when no agent is
2089
+ // open (the whole rhead is hidden then anyway).
2090
+ function paintHeaderBadge() {
2091
+ const badge = $("ctype");
2092
+ if (!badge) return;
2093
+ const e = sel ? entries.find((x) => x._key === sel) : null;
2094
+ const key = e ? peerKeyOf(srcFor(e), e) : null;
2095
+ const kind = key ? connKinds.get(key) : null;
2096
+ if (!kind) {
2097
+ badge.hidden = true;
2098
+ return;
2099
+ }
2100
+ badge.hidden = false;
2101
+ badge.style.color = CTYPE_COLORS[kind] || "var(--muted)";
2102
+ badge.removeAttribute("title"); // the hover popover replaces the native title
2103
+ badge.innerHTML = esc(kind) + connTipHtml(kind, connInfo.get(key));
2104
+ }
2105
+ // The on-hover debug popover for the connection pill: latency, both ICE
2106
+ // candidates, NAT-traversal hint, the open data channel, and the signaling
2107
+ // server. Pure detail readout — the same getStats the classifier already ran.
2108
+ function connTipHtml(kind, info) {
2109
+ const rows = [];
2110
+ if (info?.rttMs != null) rows.push(["latency", info.rttMs + " ms RTT"]);
2111
+ if (info?.remote) rows.push(["remote", info.remote]);
2112
+ if (info?.local) rows.push(["local", info.local]);
2113
+ if (info?.nat) rows.push(["nat", info.nat]);
2114
+ if (info?.dc) rows.push(["channel", info.dc]);
2115
+ if (info?.signaling) rows.push(["signal", info.signaling]);
2116
+ const head = `<div class="ctip-h">${esc(CTYPE_TIPS[kind] || kind)}</div>`;
2117
+ const body = rows
2118
+ .map(([k, v]) => `<div><span class="ctip-k">${k}</span>${esc(String(v))}</div>`)
2119
+ .join("");
2120
+ return `<span class="ctip">${head}${body || '<div class="ctip-k">probing…</div>'}</span>`;
2121
+ }
2122
+ // Candidate pairs aren't known the instant a peer connects (ICE keeps probing)
2123
+ // and can change (relay fallback); a light poll keeps every pill honest.
2124
+ setInterval(refreshConnKinds, 2500);
2125
+
2126
+ // ---- live performance HUD (opt-in via the ⋯ menu, hidden by default) -----
2127
+ // Surfaces the metrics that actually move when the console feels laggy: render
2128
+ // fps, keystroke throughput (xterm onData), agent output rate (term.write),
2129
+ // and main-thread jank (long tasks). Counters accumulate over a 1s window;
2130
+ // the I/O hooks live in select() (perfNote on fwd / term.write).
2131
+ const perf = {
2132
+ frames: 0,
2133
+ inEvents: 0,
2134
+ inBytes: 0,
2135
+ outWrites: 0,
2136
+ outBytes: 0,
2137
+ jankMs: 0,
2138
+ echoMs: null,
2139
+ };
2140
+ let perfOn = false;
2141
+ let perfRaf = 0;
2142
+ // Keystroke→reflect latency: stamp the first un-echoed keystroke; the next
2143
+ // term.write that lands is its echo (the PTY round-trip). Accurate while
2144
+ // typing into an otherwise-quiet agent — the real UX number for a TUI.
2145
+ let pendingKeyTs = 0;
2146
+ function perfNote(kind, n) {
2147
+ if (!perfOn) return;
2148
+ if (kind === "in") {
2149
+ perf.inEvents++;
2150
+ perf.inBytes += n || 0;
2151
+ if (!pendingKeyTs) pendingKeyTs = performance.now();
2152
+ } else if (kind === "out") {
2153
+ perf.outWrites++;
2154
+ perf.outBytes += n || 0;
2155
+ if (pendingKeyTs) {
2156
+ perf.echoMs = Math.round(performance.now() - pendingKeyTs);
2157
+ pendingKeyTs = 0;
2158
+ }
2159
+ }
2160
+ }
2161
+ function perfReset() {
2162
+ perf.frames =
2163
+ perf.inEvents =
2164
+ perf.inBytes =
2165
+ perf.outWrites =
2166
+ perf.outBytes =
2167
+ perf.jankMs =
2168
+ 0;
2169
+ }
2170
+ function perfFrameLoop() {
2171
+ if (!perfOn) {
2172
+ perfRaf = 0;
2173
+ return;
2174
+ }
2175
+ perf.frames++;
2176
+ perfRaf = requestAnimationFrame(perfFrameLoop);
2177
+ }
2178
+ try {
2179
+ // Long tasks fire regardless of the HUD; we only sum them while it's on.
2180
+ new PerformanceObserver((l) => {
2181
+ if (perfOn) for (const e of l.getEntries()) perf.jankMs += e.duration;
2182
+ }).observe({ entryTypes: ["longtask"] });
2183
+ } catch {
2184
+ /* longtask API unavailable (Safari) — jank line just stays 0 */
2185
+ }
2186
+ const fmtBytes = (b) => (b >= 1024 ? (b / 1024).toFixed(1) + " KB" : Math.round(b) + " B");
2187
+ setInterval(() => {
2188
+ const el = $("perfhud");
2189
+ if (!perfOn || !el) {
2190
+ perfReset();
2191
+ return;
2192
+ }
2193
+ const fps = perf.frames; // window is ~1s
2194
+ el.innerHTML =
2195
+ `<b>fps</b> ${fps}\n` +
2196
+ `<b>echo</b> ${perf.echoMs == null ? "—" : perf.echoMs + " ms"}\n` +
2197
+ `<b>keys</b> ${perf.inEvents}/s ${fmtBytes(perf.inBytes)}/s\n` +
2198
+ `<b>out</b> ${perf.outWrites}/s ${fmtBytes(perf.outBytes)}/s\n` +
2199
+ `<b>jank</b> ${Math.round(perf.jankMs)} ms/s`;
2200
+ perfReset(); // note: echoMs is preserved (last keystroke round-trip stays shown)
2201
+ }, 1000);
2202
+ function setPerfHud(on) {
2203
+ perfOn = on;
2204
+ const el = $("perfhud");
2205
+ if (el) el.hidden = !on;
2206
+ const cb = $("perfToggle");
2207
+ if (cb) cb.checked = on;
2208
+ try {
2209
+ localStorage.setItem("ay.perfHud", on ? "1" : "0");
2210
+ } catch {}
2211
+ if (on) {
2212
+ perfReset();
2213
+ if (!perfRaf) perfRaf = requestAnimationFrame(perfFrameLoop);
2214
+ if (el) el.textContent = "measuring…";
2215
+ }
2216
+ }
2217
+ // Wire the ⋯ overflow menu and restore the saved HUD preference.
2218
+ // Recovery actions for the SELECTED agent, both confirmed first (destructive):
2219
+ // Stop — graceful: send the CLI's exit command (claude/codex /exit, gemini
2220
+ // /quit) + Enter over the existing /api/send stdin wire. (Ctrl+C / Ctrl+C×2
2221
+ // you can already just type — and some agents rely on that pattern — so
2222
+ // we don't reinvent it here.)
2223
+ // Force — escalation a stuck agent can't ignore: a real SIGKILL of its process
2224
+ // GROUP, server-side via POST /api/kill. Use when /exit does nothing.
2225
+ const GRACEFUL_EXIT = { claude: "/exit", codex: "/exit", gemini: "/quit" };
2226
+ async function stopAgent(force) {
2227
+ const e = sel ? entries.find((x) => x._key === sel) : null;
2228
+ if (!e) return;
2229
+ const who = `pid ${e.pid} ${cliLabel(e) || ident(e) || ""}`.trim();
2230
+ const tx = txFor(e),
2231
+ kw = String(e.pid);
2232
+ if (force) {
2233
+ if (!confirm(`Force-kill (SIGKILL) ${who}?\nThis terminates the process immediately.`))
2234
+ return;
2235
+ tx.post("/api/kill", { keyword: kw }).catch(() => {});
2236
+ return;
2237
+ }
2238
+ const graceful = GRACEFUL_EXIT[e.cli] || "/exit";
2239
+ if (!confirm(`Stop ${who} — send "${graceful}"?`)) return;
2240
+ const send = (msg) =>
2241
+ tx.post("/api/send", { keyword: kw, msg, code: "none" }).catch(() => {});
2242
+ await send(graceful);
2243
+ await new Promise((r) => setTimeout(r, 200));
2244
+ await send("\r");
2245
+ }
2246
+
2247
+ // ---- On-screen key bar (mobile) -------------------------------------
2248
+ // A phone keyboard has no Esc/Tab/Ctrl/arrows, so agent TUIs (claude,
2249
+ // codex, vim…) are otherwise undriveable. The bar posts raw byte
2250
+ // sequences straight to the selected agent's stdin, and a sticky Ctrl/Alt
2251
+ // rewrites the NEXT character typed on the soft keyboard.
2252
+ let modCtrl = false,
2253
+ modAlt = false;
2254
+ // Send raw bytes to the currently-selected agent. Uses the live `sel`, so
2255
+ // it always hits the open agent even while an old terminal tears down mid
2256
+ // switch — the same /api/send wire the terminal's own input uses.
2257
+ function sendToSelected(data) {
2258
+ const e = sel ? entries.find((x) => x._key === sel) : null;
2259
+ if (!e || data == null) return;
2260
+ perfNote("in", typeof data === "string" ? data.length : (data?.byteLength ?? 0));
2261
+ txFor(e)
2262
+ .post("/api/send", { keyword: String(e.pid), msg: data, code: "none" })
2263
+ .catch(() => {});
2264
+ }
2265
+ // Apply a pending sticky Ctrl/Alt to ONE typed character (soft-keyboard
2266
+ // path only). Multi-char input — paste, an IME commit, an escape sequence —
2267
+ // passes through untouched. Ctrl folds @A–Z[\]^_ (case-insensitive) to
2268
+ // 0x00–0x1f; Alt/Meta is an ESC prefix. One-shot: clears after one char.
2269
+ function applyStickyMods(d) {
2270
+ if ((!modCtrl && !modAlt) || typeof d !== "string" || d.length !== 1) return d;
2271
+ let c = d;
2272
+ if (modCtrl) {
2273
+ const u = d.toUpperCase().charCodeAt(0);
2274
+ if (u >= 64 && u <= 95) c = String.fromCharCode(u - 64);
2275
+ else if (u === 32) c = "\x00"; // Ctrl-Space → NUL
2276
+ }
2277
+ if (modAlt) c = "\x1b" + c;
2278
+ modCtrl = modAlt = false;
2279
+ syncModButtons();
2280
+ return c;
2281
+ }
2282
+ function syncModButtons() {
2283
+ const kb = $("keybar");
2284
+ if (!kb) return;
2285
+ kb.querySelector('[data-mod="ctrl"]')?.classList.toggle("on", modCtrl);
2286
+ kb.querySelector('[data-mod="alt"]')?.classList.toggle("on", modAlt);
2287
+ }
2288
+ (function keybarBoot() {
2289
+ const kb = $("keybar");
2290
+ if (!kb) return;
2291
+ const SEQ = { esc: "\x1b", tab: "\t", enter: "\r", cc: "\x03" };
2292
+ const ARROW = { up: "A", down: "B", right: "C", left: "D" };
2293
+ // Don't let a button steal focus — that would drop the soft keyboard and
2294
+ // blur whatever the user is typing into (terminal or composer).
2295
+ kb.addEventListener("pointerdown", (ev) => {
2296
+ if (ev.target.closest("button")) ev.preventDefault();
2297
+ });
2298
+ kb.addEventListener("click", (ev) => {
2299
+ const b = ev.target.closest("button.kb");
2300
+ if (!b) return;
2301
+ if (b.dataset.mod) {
2302
+ // sticky toggle — armed for the next soft-keyboard character
2303
+ if (b.dataset.mod === "ctrl") modCtrl = !modCtrl;
2304
+ else modAlt = !modAlt;
2305
+ syncModButtons();
2306
+ return;
2307
+ }
2308
+ let seq = null;
2309
+ if (b.dataset.arrow) {
2310
+ const L = ARROW[b.dataset.arrow];
2311
+ if (modCtrl || modAlt) {
2312
+ // Ctrl/Alt + arrow → CSI modifier form ESC [ 1 ; <m> <L>, where
2313
+ // m = 1 + Alt(2) + Ctrl(4) (e.g. Ctrl-Left = ESC [ 1 ; 5 D for
2314
+ // word-wise motion). Modified cursor keys are always CSI, never
2315
+ // SS3, so DECCKM doesn't apply when a modifier is held.
2316
+ seq = "\x1b[1;" + (1 + (modAlt ? 2 : 0) + (modCtrl ? 4 : 0)) + L;
2317
+ } else {
2318
+ // Honour application-cursor-key mode (DECCKM): TUIs that enable it
2319
+ // expect SS3 (ESC O x) arrows, not CSI (ESC [ x). xterm tracks it.
2320
+ const app = term && term.modes && term.modes.applicationCursorKeysMode;
2321
+ seq = (app ? "\x1bO" : "\x1b[") + L;
2322
+ }
2323
+ } else if (b.dataset.key) {
2324
+ seq = SEQ[b.dataset.key];
2325
+ // Alt/Meta on a fixed key = ESC prefix (e.g. Alt+Enter). Ctrl on
2326
+ // Esc/Tab/Enter has no standard sequence, so it's left as the bare key.
2327
+ if (seq != null && modAlt) seq = "\x1b" + seq;
2328
+ }
2329
+ if (seq == null) return;
2330
+ // the armed modifier (if any) has now been folded into this key — clear it
2331
+ modCtrl = modAlt = false;
2332
+ syncModButtons();
2333
+ sendToSelected(seq);
2334
+ });
2335
+ })();
2336
+ // ---- Line composer (mobile) ----------------------------------------
2337
+ // The dependable way to type a prompt on a phone: a real textarea, immune
2338
+ // to the IME/autocorrect glitches that dog xterm's hidden input. Enter
2339
+ // sends text + a carriage return in one write (identical to typing then ↵);
2340
+ // Shift+Enter inserts a literal newline.
2341
+ (function composerBoot() {
2342
+ const form = $("composer"),
2343
+ ta = $("cmpin");
2344
+ if (!form || !ta) return;
2345
+ const grow = () => {
2346
+ ta.style.height = "auto";
2347
+ ta.style.height = Math.min(ta.scrollHeight, 120) + "px";
2348
+ };
2349
+ const sendLine = () => {
2350
+ let v = ta.value;
2351
+ if (!v) return;
2352
+ // An armed sticky Ctrl/Alt decorates a single-char line (e.g. Ctrl-D);
2353
+ // longer text can't carry it, but the modifier must still be cleared so
2354
+ // it doesn't silently leak into the next terminal keystroke.
2355
+ v = applyStickyMods(v);
2356
+ if (modCtrl || modAlt) {
2357
+ modCtrl = modAlt = false;
2358
+ syncModButtons();
2359
+ }
2360
+ sendToSelected(v + "\r");
2361
+ ta.value = "";
2362
+ grow();
2363
+ ta.focus(); // keep the soft keyboard up for the next line
2364
+ };
2365
+ ta.addEventListener("input", grow);
2366
+ form.addEventListener("submit", (ev) => {
2367
+ ev.preventDefault();
2368
+ sendLine();
2369
+ });
2370
+ // Enter sends; Shift+Enter inserts a newline; never fire mid-IME-commit
2371
+ // (the confirming Enter reports keyCode 229 / isComposing). `enterSeen`
2372
+ // lets the beforeinput fallback below know keydown already decided this
2373
+ // Enter (so it doesn't double-send, and Shift+Enter still makes a newline).
2374
+ let enterSeen = false;
2375
+ ta.addEventListener("keydown", (ev) => {
2376
+ enterSeen = false;
2377
+ if (ev.key === "Enter" && !ev.isComposing && ev.keyCode !== 229) {
2378
+ enterSeen = true;
2379
+ if (!ev.shiftKey) {
2380
+ ev.preventDefault();
2381
+ sendLine();
2382
+ }
2383
+ }
2384
+ });
2385
+ // Android (Gboard) commonly delivers Enter as keyCode 229 / key
2386
+ // "Unidentified", so the keydown above never matches it. beforeinput's
2387
+ // insertLineBreak fires reliably there; skip it when keydown already
2388
+ // saw the Enter (desktop/iOS, including the Shift+Enter newline case).
2389
+ ta.addEventListener("beforeinput", (ev) => {
2390
+ if (ev.inputType !== "insertLineBreak") return;
2391
+ if (enterSeen) {
2392
+ enterSeen = false;
2393
+ return;
2394
+ }
2395
+ ev.preventDefault();
2396
+ sendLine();
2397
+ });
2398
+ })();
2399
+ (function perfMenuBoot() {
2400
+ const btn = $("rmenubtn"),
2401
+ panel = $("rmenupanel"),
2402
+ cb = $("perfToggle");
2403
+ const closePanel = () => panel && (panel.hidden = true);
2404
+ if (btn && panel) {
2405
+ btn.addEventListener("click", (ev) => {
2406
+ ev.stopPropagation();
2407
+ panel.hidden = !panel.hidden;
2408
+ });
2409
+ document.addEventListener("click", (ev) => {
2410
+ if (!panel.hidden && !panel.contains(ev.target) && ev.target !== btn) closePanel();
2411
+ });
2412
+ }
2413
+ if (cb) cb.addEventListener("change", () => setPerfHud(cb.checked));
2414
+ $("stopAgent")?.addEventListener("click", () => (closePanel(), stopAgent(false)));
2415
+ $("killAgent")?.addEventListener("click", () => (closePanel(), stopAgent(true)));
2416
+ // Font stepper — stays open so you can tap repeatedly; persists + refits.
2417
+ $("fontDown")?.addEventListener("click", () => setTermFontSize(termFontSize - 1));
2418
+ $("fontUp")?.addEventListener("click", () => setTermFontSize(termFontSize + 1));
2419
+ setTermFontSize(termFontSize); // sync the label with the restored value
2420
+ let saved = false;
2421
+ try {
2422
+ saved = localStorage.getItem("ay.perfHud") === "1";
2423
+ } catch {}
2424
+ setPerfHud(saved);
2425
+ })();
2426
+
1418
2427
  // The local source is only worth polling when this page is actually backed
1419
2428
  // by an ay serve: localhost, or served by `ay serve --http` (which leaves a
1420
2429
  // token), or when there are no rooms to fall back on. On the public origin
@@ -1545,28 +2554,62 @@
1545
2554
  return `<span class="git${g.dirty ? " dirty" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
1546
2555
  }
1547
2556
 
2557
+ // Task-progress badge ("2/5") parsed from the agent's todo block, shown next
2558
+ // to the git chip. Amber-ish when all done; omitted entirely when no block.
2559
+ function taskChipHtml(e) {
2560
+ const label = taskLabel(e);
2561
+ if (!label) return "";
2562
+ const t = e.tasks || {};
2563
+ const allDone = t.total > 0 && t.done >= t.total;
2564
+ const tip = `tasks: ${t.done || 0} done / ${t.total || 0} total`;
2565
+ return `<span class="tasks${allDone ? " done" : ""}" title="${esc(tip)}">${esc(label)}</span>`;
2566
+ }
2567
+
2568
+ // A room/peer group header row. Non-selectable; carries the same tree rails
2569
+ // as agent rows so the hierarchy reads as one tree. The room/peer layers
2570
+ // only appear here when there are ≥2 of them (single ones fold away in
2571
+ // layeredRows), so a header always disambiguates something real.
2572
+ function headerHtml(r) {
2573
+ const icon = r.kind === "room" ? "▤" : "▣";
2574
+ // Peer headers carry their host's connection type (lan/wan/relay), so a
2575
+ // mixed room shows at a glance which hosts are local vs reached over WAN.
2576
+ const ctag =
2577
+ r.kind === "peer"
2578
+ ? connTagHtml(hostKinds.get((r.room || "") + " " + (r.host || "")))
2579
+ : "";
2580
+ 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>`;
2581
+ }
2582
+
1548
2583
  function renderList() {
1549
2584
  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`;
2585
+ // Build the full signalling-server > rooms > peers > agents > subagents
2586
+ // tree. Single-node room/peer layers fold away; ≥2 become ├ └ │ branches.
2587
+ const rows = layeredRows(entries.filter((e) => matches(e, toks)));
2588
+ const agentRows = rows.filter((r) => r.kind === "agent");
2589
+ $("count").textContent = `${agentRows.length} / ${entries.length} agents`;
1552
2590
  $("viewbtn").classList.toggle("on", compactList);
1553
2591
  // identContext blanks any field uniform across the shown list (so a
1554
2592
  // single-device fleet shows no device); multiDevice gates the detailed
1555
2593
  // host tag so it only appears when machines are actually mixed.
1556
- const ctx = identContext(shown);
1557
- const multiDevice = deviceCount(shown) > 1;
2594
+ const shownEntries = agentRows.map((r) => r.entry);
2595
+ const ctx = identContext(shownEntries);
2596
+ const multiDevice = deviceCount(shownEntries) > 1;
1558
2597
  if (compactList) {
1559
2598
  $("list").innerHTML =
1560
- shown
1561
- .map((e) => {
2599
+ rows
2600
+ .map((r) => {
2601
+ if (r.kind !== "agent") return headerHtml(r);
2602
+ const e = r.entry;
1562
2603
  const t = e.title || e.prompt || "";
1563
- const id = compactIdent(e, ctx);
2604
+ const id = compactIdent(e, ctx, 3, r.parentEntry);
1564
2605
  const cli = cliLabel(e);
1565
2606
  return `<div class="row crow ${e._key === sel ? "sel" : ""}" data-key="${esc(e._key)}">
2607
+ ${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}
1566
2608
  <span class="dot ${esc(e.status)}"></span>
1567
2609
  ${hasIdent(id) ? `<span class="cident" title="${esc(fullIdent(e))}">${esc(id)}</span>` : ""}
1568
2610
  ${cli ? `<span class="cname">${esc(cli)}</span>` : ""}
1569
2611
  <span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
2612
+ ${taskChipHtml(e)}
1570
2613
  ${gitChipHtml(e)}
1571
2614
  <span class="age">${age(e)}</span></div>`;
1572
2615
  })
@@ -1574,10 +2617,12 @@
1574
2617
  return;
1575
2618
  }
1576
2619
  $("list").innerHTML =
1577
- shown
1578
- .map((e) => {
2620
+ rows
2621
+ .map((r) => {
2622
+ if (r.kind !== "agent") return headerHtml(r);
2623
+ const e = r.entry;
1579
2624
  // 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).
2625
+ // play AND it isn't already shown by a peer header above this row.
1581
2626
  const tags = tagsFor(e)
1582
2627
  .filter(([k]) => k !== "host" || multiDevice)
1583
2628
  .map(
@@ -1586,9 +2631,10 @@
1586
2631
  )
1587
2632
  .join("");
1588
2633
  return `<div class="row ${e._key === sel ? "sel" : ""}" data-key="${esc(e._key)}">
1589
- <div class="r1"><span class="dot ${esc(e.status)}"></span>
2634
+ <div class="r1">${r.branch ? `<span class="tbranch">${esc(r.branch)}</span>` : ""}<span class="dot ${esc(e.status)}"></span>
1590
2635
  <span class="name">${esc(cliLabel(e) || ident(e) || "agent")}</span>
1591
2636
  <span class="badge">pid ${e.pid}</span>
2637
+ ${taskChipHtml(e)}
1592
2638
  ${gitChipHtml(e)}
1593
2639
  <span class="age">${age(e)}</span></div>
1594
2640
  ${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
@@ -1690,6 +2736,7 @@
1690
2736
  return;
1691
2737
  }
1692
2738
  sel = e._key;
2739
+ paintHeaderBadge();
1693
2740
  // Remember the selection so a refresh re-opens this agent (see boot/autoPid).
1694
2741
  try {
1695
2742
  localStorage.setItem("ay.sel", sel);
@@ -1730,7 +2777,7 @@
1730
2777
  disableStdin: false,
1731
2778
  cursorBlink: true,
1732
2779
  scrollback: 5000,
1733
- fontSize: 12,
2780
+ fontSize: termFontSize,
1734
2781
  fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
1735
2782
  theme: termTheme(),
1736
2783
  });
@@ -1796,8 +2843,11 @@
1796
2843
  // keyword with 400 (pid arrives as a number from /api/ls JSON).
1797
2844
  const kw = String(pid);
1798
2845
  const fwd = (d) => {
1799
- if (sel === e._key)
1800
- tx.post("/api/send", { keyword: kw, msg: d, code: "none" }).catch(() => {});
2846
+ if (sel !== e._key) return; // ignore a stale terminal mid-switch
2847
+ // a soft-keyboard character may be decorated by an armed sticky Ctrl/Alt
2848
+ if (typeof d === "string") d = applyStickyMods(d);
2849
+ perfNote("in", typeof d === "string" ? d.length : (d?.byteLength ?? 0));
2850
+ tx.post("/api/send", { keyword: kw, msg: d, code: "none" }).catch(() => {});
1801
2851
  };
1802
2852
  term.onData(fwd);
1803
2853
  term.onBinary(fwd);
@@ -1837,7 +2887,10 @@
1837
2887
  const close = tx.subscribe(
1838
2888
  "/api/tail/" + encodeURIComponent(pid) + "?raw=1",
1839
2889
  (raw) => {
1840
- if (term) term.write(raw);
2890
+ if (term) {
2891
+ term.write(raw);
2892
+ perfNote("out", raw?.length ?? 0);
2893
+ }
1841
2894
  },
1842
2895
  () => {
1843
2896
  $("livedot").className = "dot active";
@@ -1855,11 +2908,51 @@
1855
2908
  const row = ev.target.closest(".row");
1856
2909
  if (row) select(row.dataset.key);
1857
2910
  });
1858
- // Mobile back button: return to the list pane. The tail keeps streaming in the
2911
+ // Mobile back: return to the list pane. The tail keeps streaming in the
1859
2912
  // background (selection unchanged), so reopening the agent is instant.
1860
- $("rback").addEventListener("click", () => {
1861
- document.querySelector(".app").classList.remove("show-detail");
1862
- });
2913
+ const goBack = () => document.querySelector(".app").classList.remove("show-detail");
2914
+ $("rback").addEventListener("click", goBack);
2915
+ // Swipe-right-from-the-left-edge → back, an alternative to the ‹ button.
2916
+ // Passive/read-only so it never fights xterm's own scroll or text
2917
+ // selection; armed only from the left edge, in the single-column view, and
2918
+ // never when a TUI has mouse tracking on (that touch belongs to the agent).
2919
+ (function swipeBackBoot() {
2920
+ const pane = document.querySelector(".right");
2921
+ if (!pane) return;
2922
+ let sx = 0,
2923
+ sy = 0,
2924
+ st = 0,
2925
+ armed = false;
2926
+ pane.addEventListener(
2927
+ "touchstart",
2928
+ (ev) => {
2929
+ armed = false;
2930
+ if (window.innerWidth > 720 || ev.touches.length !== 1) return;
2931
+ const t = ev.touches[0];
2932
+ armed = t.clientX <= 28; // left-edge start only
2933
+ sx = t.clientX;
2934
+ sy = t.clientY;
2935
+ st = ev.timeStamp;
2936
+ },
2937
+ { passive: true },
2938
+ );
2939
+ pane.addEventListener(
2940
+ "touchend",
2941
+ (ev) => {
2942
+ if (!armed) return;
2943
+ armed = false;
2944
+ const mt = term && term.modes && term.modes.mouseTrackingMode;
2945
+ if (mt && mt !== "none") return; // the TUI wants this touch
2946
+ const t = ev.changedTouches[0];
2947
+ if (!t) return;
2948
+ const dx = t.clientX - sx,
2949
+ dy = t.clientY - sy;
2950
+ // a quick, clearly-horizontal rightward flick (not a scroll/tap)
2951
+ if (dx > 45 && Math.abs(dx) > 2 * Math.abs(dy) && ev.timeStamp - st < 500) goBack();
2952
+ },
2953
+ { passive: true },
2954
+ );
2955
+ })();
1863
2956
  $("q").addEventListener("input", () => {
1864
2957
  try {
1865
2958
  localStorage.setItem("ay.filter", $("q").value);
@@ -1877,12 +2970,35 @@
1877
2970
  fit.fit();
1878
2971
  } catch {}
1879
2972
  });
2973
+ // iOS overlays the soft keyboard ON TOP of the layout viewport rather than
2974
+ // shrinking it, so a bottom-anchored composer would hide behind it. Track
2975
+ // the visual viewport and reserve the covered height as --kb (which .app
2976
+ // subtracts from its height), then refit the terminal. Android Chrome
2977
+ // resizes the layout viewport itself (interactive-widget=resizes-content),
2978
+ // so --kb stays ~0 there — this is harmless and idempotent on desktop too.
2979
+ if (window.visualViewport) {
2980
+ const vv = window.visualViewport;
2981
+ const onVV = () => {
2982
+ const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
2983
+ document.documentElement.style.setProperty("--kb", kb + "px");
2984
+ if (fit)
2985
+ try {
2986
+ fit.fit();
2987
+ } catch {}
2988
+ };
2989
+ vv.addEventListener("resize", onVV);
2990
+ vv.addEventListener("scroll", onVV);
2991
+ onVV();
2992
+ }
1880
2993
 
1881
2994
  // Step the selection up/down the (filtered) list — same order the left panel
1882
2995
  // renders. Clamps at the ends, scrolls the row into view.
1883
2996
  function stepSelection(dir) {
1884
2997
  const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
1885
- const shown = entries.filter((e) => matches(e, toks));
2998
+ // Must match renderList's order; step only over agent rows (skip headers).
2999
+ const shown = layeredRows(entries.filter((e) => matches(e, toks)))
3000
+ .filter((r) => r.kind === "agent")
3001
+ .map((r) => r.entry);
1886
3002
  if (!shown.length) return;
1887
3003
  const cur = shown.findIndex((e) => e._key === sel);
1888
3004
  const next = shown[nextIndex(shown.length, cur, dir)];
@@ -2483,7 +3599,15 @@
2483
3599
  // composite key so it picks the right host when pids collide across rooms.
2484
3600
  const [, room, aid] = full;
2485
3601
  autoPid = room + "#" + aid;
2486
- autoPidExplicit = true;
3602
+ // A #room:agentId hash is BOTH our own persisted selection (select()
3603
+ // writes it so a refresh reopens) AND a shareable deep link — same
3604
+ // format, two intents. Disambiguate by navigation type: a reload is a
3605
+ // restore (on a phone, just re-highlight the row and stay on the list,
3606
+ // per mergeRender), while a fresh navigation is a deliberate "take me
3607
+ // to this agent" that opens even on mobile. (No nav entry → treat as
3608
+ // explicit, preserving the old always-open behaviour.)
3609
+ const nav = performance.getEntriesByType("navigation")[0];
3610
+ autoPidExplicit = nav ? nav.type !== "reload" : true;
2487
3611
  const r = loadRooms()[room];
2488
3612
  if (r) pending = { room, token: r.token, host: r.host };
2489
3613
  } else if (bare && loadRooms()[bare[1]]) {