agent-yes 1.97.0 → 1.99.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.
@@ -0,0 +1,1645 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>agent-yes · console</title>
7
+ <style>
8
+ /* Palette borrowed from codehost (GitHub-dark) so the two feel like one system. */
9
+ :root {
10
+ --bg: #0d1117;
11
+ --panel: #161b22;
12
+ --panel2: #1c2430;
13
+ --line: #2d3748;
14
+ --line2: #222b38;
15
+ --fg: #e6edf3;
16
+ --muted: #8b949e;
17
+ --accent: #58a6ff;
18
+ --green: #3fb950;
19
+ --amber: #d29922;
20
+ --purple: #bc8cff;
21
+ --pink: #f778ba;
22
+ --red: #f85149;
23
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
24
+ }
25
+ * {
26
+ box-sizing: border-box;
27
+ }
28
+ body {
29
+ margin: 0;
30
+ background: var(--bg);
31
+ color: var(--fg);
32
+ height: 100vh;
33
+ overflow: hidden;
34
+ font:
35
+ 14px/1.55 -apple-system,
36
+ BlinkMacSystemFont,
37
+ "Segoe UI",
38
+ system-ui,
39
+ sans-serif;
40
+ }
41
+ code {
42
+ font-family: var(--mono);
43
+ font-size: 12.5px;
44
+ background: var(--panel2);
45
+ padding: 1px 5px;
46
+ border-radius: 5px;
47
+ color: var(--purple);
48
+ }
49
+
50
+ /* grid-template-rows:minmax(0,1fr) caps the row at the viewport so the inner
51
+ panes scroll instead of growing the page (the min-height:0 below lets the
52
+ flex children actually shrink — without it overflow:auto never engages). */
53
+ .app {
54
+ display: grid;
55
+ grid-template-columns: minmax(360px, 42%) 1fr;
56
+ grid-template-rows: minmax(0, 1fr);
57
+ height: 100vh;
58
+ }
59
+
60
+ /* ---- left: list ---- */
61
+ .left {
62
+ border-right: 1px solid var(--line);
63
+ display: flex;
64
+ flex-direction: column;
65
+ min-width: 0;
66
+ min-height: 0;
67
+ }
68
+ .head {
69
+ padding: 16px 18px 10px;
70
+ border-bottom: 1px solid var(--line);
71
+ }
72
+ h1 {
73
+ font-size: 18px;
74
+ margin: 0 0 2px;
75
+ letter-spacing: -0.02em;
76
+ }
77
+ h1 .tag {
78
+ color: var(--accent);
79
+ }
80
+ .sub {
81
+ color: var(--muted);
82
+ font-size: 12.5px;
83
+ }
84
+ .ibox {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 8px;
88
+ background: var(--panel);
89
+ border: 1px solid var(--line);
90
+ border-radius: 9px;
91
+ padding: 7px 11px;
92
+ margin-top: 11px;
93
+ }
94
+ .ibox:focus-within {
95
+ border-color: var(--accent);
96
+ }
97
+ .ibox .mag {
98
+ color: var(--muted);
99
+ }
100
+ #q {
101
+ flex: 1;
102
+ background: transparent;
103
+ border: 0;
104
+ outline: 0;
105
+ color: var(--fg);
106
+ font: 13px var(--mono);
107
+ }
108
+ .meta {
109
+ display: flex;
110
+ justify-content: space-between;
111
+ color: var(--muted);
112
+ font-size: 11.5px;
113
+ margin-top: 8px;
114
+ }
115
+ .connbadge {
116
+ cursor: pointer;
117
+ user-select: none;
118
+ }
119
+ .connbadge:hover {
120
+ filter: brightness(1.3);
121
+ }
122
+ .rooms {
123
+ position: relative;
124
+ margin-top: 8px;
125
+ background: var(--panel);
126
+ border: 1px solid var(--line);
127
+ border-radius: 9px;
128
+ padding: 8px;
129
+ font-size: 12px;
130
+ }
131
+ .rooms .rtitle {
132
+ color: var(--muted);
133
+ font-size: 10.5px;
134
+ text-transform: uppercase;
135
+ letter-spacing: 0.05em;
136
+ padding: 2px 4px 6px;
137
+ }
138
+ .rooms .ritem {
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 8px;
142
+ padding: 6px 6px;
143
+ border-radius: 7px;
144
+ }
145
+ .rooms .ritem:hover {
146
+ background: var(--panel2);
147
+ }
148
+ .rooms .ritem.cur {
149
+ box-shadow: inset 2px 0 0 var(--green);
150
+ }
151
+ .rooms .rname {
152
+ font-family: var(--mono);
153
+ color: var(--accent);
154
+ cursor: pointer;
155
+ flex: 1;
156
+ }
157
+ .rooms .rhost {
158
+ color: var(--muted);
159
+ font-family: var(--mono);
160
+ font-size: 10.5px;
161
+ }
162
+ .rooms .rx {
163
+ color: var(--muted);
164
+ cursor: pointer;
165
+ padding: 0 4px;
166
+ }
167
+ .rooms .rx:hover {
168
+ color: var(--red);
169
+ }
170
+ .rooms .radd {
171
+ display: flex;
172
+ gap: 6px;
173
+ margin-top: 6px;
174
+ padding-top: 6px;
175
+ border-top: 1px solid var(--line2);
176
+ }
177
+ .rooms .radd input {
178
+ flex: 1;
179
+ background: var(--bg);
180
+ border: 1px solid var(--line);
181
+ border-radius: 7px;
182
+ color: var(--fg);
183
+ font: 11.5px var(--mono);
184
+ padding: 6px 8px;
185
+ outline: 0;
186
+ }
187
+ .rooms .radd button {
188
+ background: var(--accent);
189
+ color: var(--bg);
190
+ border: 0;
191
+ border-radius: 7px;
192
+ font-weight: 600;
193
+ padding: 0 12px;
194
+ cursor: pointer;
195
+ }
196
+ .rooms .empty2 {
197
+ color: var(--muted);
198
+ padding: 6px;
199
+ }
200
+ .rooms .rconnect {
201
+ margin-top: 6px;
202
+ padding: 8px 6px 2px;
203
+ border-top: 1px solid var(--line2);
204
+ color: var(--muted);
205
+ font-size: 11px;
206
+ line-height: 1.7;
207
+ }
208
+ .rooms .rconnect code {
209
+ display: block;
210
+ margin-top: 4px;
211
+ cursor: pointer;
212
+ }
213
+ .rooms .rconnect code:hover {
214
+ color: var(--accent);
215
+ }
216
+
217
+ /* launch overlay (command-only launch URLs: #launch=<json>) */
218
+ .launchoverlay {
219
+ position: fixed;
220
+ inset: 0;
221
+ z-index: 20;
222
+ background: rgba(2, 6, 12, 0.7);
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ }
227
+ .lcard {
228
+ background: var(--panel);
229
+ border: 1px solid var(--line);
230
+ border-radius: 14px;
231
+ padding: 22px 24px;
232
+ width: min(560px, 92vw);
233
+ }
234
+ .lcard .ltitle {
235
+ font-size: 16px;
236
+ font-weight: 600;
237
+ margin-bottom: 12px;
238
+ }
239
+ .lcard .lcmd {
240
+ font-family: var(--mono);
241
+ font-size: 13px;
242
+ background: var(--bg);
243
+ border: 1px solid var(--line);
244
+ border-radius: 8px;
245
+ padding: 10px 12px;
246
+ color: var(--green);
247
+ word-break: break-word;
248
+ }
249
+ .lcard .lcwd {
250
+ color: var(--muted);
251
+ font-family: var(--mono);
252
+ font-size: 11.5px;
253
+ margin-top: 6px;
254
+ }
255
+ .lcard .lfleets {
256
+ display: flex;
257
+ flex-wrap: wrap;
258
+ gap: 8px;
259
+ margin-top: 16px;
260
+ }
261
+ .lcard .lfleet {
262
+ background: var(--accent);
263
+ color: var(--bg);
264
+ border: 0;
265
+ border-radius: 8px;
266
+ font-weight: 600;
267
+ font-family: var(--mono);
268
+ padding: 8px 14px;
269
+ cursor: pointer;
270
+ }
271
+ .lcard .lwarn {
272
+ color: var(--amber);
273
+ font-size: 12.5px;
274
+ margin-top: 16px;
275
+ line-height: 1.6;
276
+ }
277
+ .lcard .lwarn code {
278
+ font-family: var(--mono);
279
+ background: var(--bg);
280
+ padding: 1px 6px;
281
+ border-radius: 5px;
282
+ color: var(--fg);
283
+ }
284
+ .lcard .lhint {
285
+ color: var(--muted);
286
+ font-size: 11.5px;
287
+ margin-top: 12px;
288
+ }
289
+ .lcard .lcancel {
290
+ background: transparent;
291
+ color: var(--muted);
292
+ border: 1px solid var(--line);
293
+ border-radius: 8px;
294
+ padding: 6px 14px;
295
+ cursor: pointer;
296
+ margin-top: 16px;
297
+ }
298
+
299
+ /* new-agent button (header) + spawn form (reuses the launch overlay shell) */
300
+ .metaright {
301
+ display: flex;
302
+ align-items: center;
303
+ gap: 10px;
304
+ }
305
+ .newbtn {
306
+ background: var(--accent);
307
+ color: var(--bg);
308
+ border: 0;
309
+ border-radius: 7px;
310
+ font-weight: 600;
311
+ font-size: 11.5px;
312
+ padding: 4px 11px;
313
+ cursor: pointer;
314
+ line-height: 1.6;
315
+ }
316
+ .newbtn:hover {
317
+ filter: brightness(1.12);
318
+ }
319
+ .viewbtn {
320
+ background: transparent;
321
+ border: 1px solid var(--line);
322
+ border-radius: 7px;
323
+ color: var(--muted);
324
+ font-size: 11.5px;
325
+ padding: 3px 9px;
326
+ cursor: pointer;
327
+ line-height: 1.6;
328
+ }
329
+ .viewbtn:hover {
330
+ color: var(--fg);
331
+ border-color: var(--accent);
332
+ }
333
+ .viewbtn.on {
334
+ color: var(--accent);
335
+ border-color: var(--accent);
336
+ }
337
+ .lcard .nfield {
338
+ margin-top: 12px;
339
+ }
340
+ .lcard .nfield label {
341
+ display: block;
342
+ color: var(--muted);
343
+ font-size: 10.5px;
344
+ text-transform: uppercase;
345
+ letter-spacing: 0.05em;
346
+ margin-bottom: 4px;
347
+ }
348
+ .lcard .nfield input,
349
+ .lcard .nfield textarea {
350
+ width: 100%;
351
+ background: var(--bg);
352
+ border: 1px solid var(--line);
353
+ border-radius: 8px;
354
+ color: var(--fg);
355
+ font: 13px var(--mono);
356
+ padding: 8px 10px;
357
+ outline: 0;
358
+ resize: vertical;
359
+ }
360
+ .lcard .nfield input:focus,
361
+ .lcard .nfield textarea:focus {
362
+ border-color: var(--accent);
363
+ }
364
+ .lcard .lrow {
365
+ display: flex;
366
+ gap: 10px;
367
+ align-items: center;
368
+ margin-top: 18px;
369
+ }
370
+ .lcard .lrow .lcancel {
371
+ margin-top: 0;
372
+ }
373
+ .lfleet:disabled {
374
+ opacity: 0.5;
375
+ cursor: progress;
376
+ }
377
+
378
+ .list {
379
+ overflow-y: auto;
380
+ flex: 1;
381
+ min-height: 0;
382
+ }
383
+ .row {
384
+ padding: 11px 18px;
385
+ border-bottom: 1px solid var(--line2);
386
+ cursor: pointer;
387
+ }
388
+ .row:hover {
389
+ background: var(--panel);
390
+ }
391
+ .row.sel {
392
+ background: var(--panel);
393
+ box-shadow: inset 3px 0 0 var(--accent);
394
+ }
395
+ .r1 {
396
+ display: flex;
397
+ align-items: center;
398
+ gap: 8px;
399
+ }
400
+ .dot {
401
+ width: 8px;
402
+ height: 8px;
403
+ border-radius: 50%;
404
+ flex: none;
405
+ }
406
+ .dot.active,
407
+ .dot.running {
408
+ background: var(--green);
409
+ }
410
+ .dot.idle {
411
+ background: var(--amber);
412
+ }
413
+ .dot.stopped,
414
+ .dot.exited {
415
+ background: var(--muted);
416
+ }
417
+ .name {
418
+ font-weight: 600;
419
+ }
420
+ .badge {
421
+ font-family: var(--mono);
422
+ font-size: 10.5px;
423
+ padding: 1px 6px;
424
+ border-radius: 5px;
425
+ border: 1px solid var(--line);
426
+ color: var(--muted);
427
+ }
428
+ .age {
429
+ margin-left: auto;
430
+ color: var(--muted);
431
+ font-size: 11.5px;
432
+ }
433
+ .detail {
434
+ color: var(--muted);
435
+ font-size: 12.5px;
436
+ margin-top: 4px;
437
+ overflow: hidden;
438
+ text-overflow: ellipsis;
439
+ white-space: nowrap;
440
+ }
441
+ .rowtitle {
442
+ color: var(--fg);
443
+ font-size: 12.5px;
444
+ margin-top: 4px;
445
+ overflow: hidden;
446
+ text-overflow: ellipsis;
447
+ white-space: nowrap;
448
+ }
449
+ /* compact view: one line per agent — dot + cli + live title (or prompt), age */
450
+ .row.crow {
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 8px;
454
+ padding: 6px 18px;
455
+ }
456
+ .crow .cname {
457
+ font-weight: 600;
458
+ flex: none;
459
+ }
460
+ .crow .ctitle {
461
+ flex: 1;
462
+ min-width: 0;
463
+ font-size: 12.5px;
464
+ overflow: hidden;
465
+ text-overflow: ellipsis;
466
+ white-space: nowrap;
467
+ }
468
+ .crow .ctitle.dim {
469
+ color: var(--muted);
470
+ }
471
+ .crow .age {
472
+ margin-left: 0;
473
+ flex: none;
474
+ }
475
+ .rowtags {
476
+ display: flex;
477
+ flex-wrap: wrap;
478
+ gap: 5px;
479
+ margin-top: 6px;
480
+ }
481
+ .rtag {
482
+ font-family: var(--mono);
483
+ font-size: 11px;
484
+ padding: 1px 7px;
485
+ border-radius: 999px;
486
+ border: 1px solid var(--line);
487
+ color: var(--muted);
488
+ white-space: nowrap;
489
+ }
490
+ .rtag[data-k="repo"],
491
+ .rtag[data-k="wt"] {
492
+ color: var(--green);
493
+ border-color: #1d3b25;
494
+ }
495
+ .rtag[data-k="cli"] {
496
+ color: var(--purple);
497
+ border-color: #3a2a4a;
498
+ }
499
+ .empty {
500
+ text-align: center;
501
+ color: var(--muted);
502
+ padding: 40px;
503
+ }
504
+
505
+ /* ---- right: live tail + send (this is what codehost structurally can't do) ---- */
506
+ .right {
507
+ display: flex;
508
+ flex-direction: column;
509
+ min-width: 0;
510
+ min-height: 0;
511
+ }
512
+ .rhead {
513
+ padding: 14px 20px;
514
+ border-bottom: 1px solid var(--line);
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 10px;
518
+ }
519
+ .rhead .name {
520
+ font-size: 15px;
521
+ }
522
+ .rhead .live {
523
+ margin-left: auto;
524
+ font-size: 11px;
525
+ font-family: var(--mono);
526
+ color: var(--muted);
527
+ display: flex;
528
+ align-items: center;
529
+ gap: 6px;
530
+ }
531
+ .rhead .live .dot {
532
+ width: 7px;
533
+ height: 7px;
534
+ }
535
+ .log {
536
+ flex: 1;
537
+ min-height: 0;
538
+ overflow: hidden;
539
+ padding: 8px 10px;
540
+ background: #0d1117;
541
+ }
542
+ .log .xterm {
543
+ height: 100%;
544
+ }
545
+ .log .xterm-viewport {
546
+ background: transparent !important;
547
+ }
548
+ .placeholder {
549
+ display: flex;
550
+ align-items: center;
551
+ justify-content: center;
552
+ height: 100%;
553
+ color: var(--muted);
554
+ font-size: 14px;
555
+ }
556
+ .composer {
557
+ border-top: 1px solid var(--line);
558
+ padding: 12px 16px;
559
+ display: flex;
560
+ gap: 8px;
561
+ align-items: flex-end;
562
+ }
563
+ .composer textarea {
564
+ flex: 1;
565
+ resize: none;
566
+ background: var(--panel);
567
+ border: 1px solid var(--line);
568
+ border-radius: 9px;
569
+ color: var(--fg);
570
+ font: 13px var(--mono);
571
+ padding: 9px 12px;
572
+ outline: 0;
573
+ min-height: 40px;
574
+ max-height: 160px;
575
+ }
576
+ .composer textarea:focus {
577
+ border-color: var(--accent);
578
+ }
579
+ .send {
580
+ background: var(--accent);
581
+ color: var(--bg);
582
+ border: 0;
583
+ border-radius: 9px;
584
+ font-weight: 600;
585
+ padding: 10px 18px;
586
+ cursor: pointer;
587
+ font-size: 13px;
588
+ }
589
+ .send:disabled {
590
+ opacity: 0.45;
591
+ cursor: not-allowed;
592
+ }
593
+ .hint {
594
+ color: var(--muted);
595
+ font-size: 11px;
596
+ font-family: var(--mono);
597
+ margin-top: 6px;
598
+ padding: 0 16px 10px;
599
+ }
600
+ </style>
601
+ <link
602
+ rel="stylesheet"
603
+ href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
604
+ />
605
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
606
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
607
+ <script type="module">
608
+ // codehost room transport — vendored from codehost's `bun run build:lib`.
609
+ // Loaded as a module (deferred), so the classic script below awaits the
610
+ // "codehost-ready" event before first use.
611
+ import * as codehost from "./room-client.js";
612
+ window.__codehost = codehost;
613
+ window.dispatchEvent(new Event("codehost-ready"));
614
+ </script>
615
+ </head>
616
+ <body>
617
+ <div class="app">
618
+ <div class="left">
619
+ <div class="head">
620
+ <h1><span class="tag">agent-yes</span> · console</h1>
621
+ <div class="sub">
622
+ Live <code>ay ls</code> + per-agent tail &amp; send. Backed by <code>ay serve</code>.
623
+ </div>
624
+ <div class="ibox">
625
+ <span class="mag">⌕</span>
626
+ <input
627
+ id="q"
628
+ placeholder="filter… repo:agent-yes claude symval (space = AND)"
629
+ autocomplete="off"
630
+ autofocus
631
+ />
632
+ </div>
633
+ <div class="meta">
634
+ <span id="count"></span>
635
+ <span class="metaright">
636
+ <button id="viewbtn" class="viewbtn" title="toggle compact list">☰</button>
637
+ <button id="newbtn" class="newbtn" title="spawn a new agent on this fleet">
638
+ + New agent
639
+ </button>
640
+ <span id="conn" class="connbadge" title="rooms — click to manage">● local</span>
641
+ </span>
642
+ </div>
643
+ <div class="rooms" id="rooms" style="display: none"></div>
644
+ </div>
645
+ <div class="list" id="list"></div>
646
+ </div>
647
+
648
+ <div class="right">
649
+ <div class="rhead" id="rhead" style="display: none">
650
+ <span class="dot" id="rdot"></span>
651
+ <span class="name" id="rname"></span>
652
+ <span class="badge" id="rpid"></span>
653
+ <span class="live"
654
+ ><span class="dot" id="livedot"></span><span id="livetxt">connecting…</span></span
655
+ >
656
+ </div>
657
+ <div class="log" id="log">
658
+ <div class="placeholder">← pick an agent to tail its log and send it a message</div>
659
+ </div>
660
+ <div class="composer" id="composer" style="display: none">
661
+ <textarea
662
+ id="msg"
663
+ rows="1"
664
+ placeholder="message to send to the agent… (⌘/Ctrl+Enter)"
665
+ ></textarea>
666
+ <button class="send" id="send">Send ⏎</button>
667
+ </div>
668
+ <div class="hint" id="hint" style="display: none">
669
+ POST /api/send → writes to the agent's stdin fifo, then Enter.
670
+ </div>
671
+ </div>
672
+ </div>
673
+
674
+ <div class="launchoverlay" id="launch" style="display: none"></div>
675
+ <div class="launchoverlay" id="newform" style="display: none"></div>
676
+
677
+ <script>
678
+ let entries = [];
679
+ let sel = null; // selected keyword (pid as string)
680
+ let es = null; // live-tail subscription closer
681
+ let term = null; // xterm.js Terminal rendering the raw PTY stream
682
+ let fit = null;
683
+
684
+ const $ = (id) => document.getElementById(id);
685
+ const esc = (s) =>
686
+ String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
687
+
688
+ // ---- transport ---------------------------------------------------------
689
+ // Local: same-origin fetch + EventSource (via the ay-serve proxy).
690
+ // Remote: an /api/* call tunnelled over a WebRTC DataChannel to a peer running
691
+ // `ay serve --share` — established through the signaling server. Same call
692
+ // sites, two wires. Remote is selected by a URL hash: #room:token[@sighost].
693
+ const SIG_DEFAULT = "s.agent-yes.com"; // signaling host (override in the hash with @host)
694
+ const SUB = "ay-signal-1";
695
+
696
+ // Tunnels request/response + streaming over one DataChannel. Mirrors the
697
+ // envelope in lab/ui/share-host.ts: {t:"req"|"abort"} out, {t:"res"|"data"|"end"} in.
698
+ class RTCClient {
699
+ constructor(host, room, token) {
700
+ Object.assign(this, {
701
+ host,
702
+ room,
703
+ token,
704
+ dc: null,
705
+ nextId: 1,
706
+ calls: new Map(),
707
+ streams: new Map(),
708
+ onstate: () => {},
709
+ });
710
+ }
711
+ connect() {
712
+ return new Promise((resolve, reject) => {
713
+ const ws = new WebSocket(`wss://${this.host}/${this.room}`, [SUB]);
714
+ let pc,
715
+ settled = false;
716
+ const fail = (e) => {
717
+ if (!settled) {
718
+ settled = true;
719
+ reject(e);
720
+ }
721
+ };
722
+ ws.onopen = () =>
723
+ ws.send(JSON.stringify({ type: "hello", role: "client", token: this.token }));
724
+ ws.onmessage = async (ev) => {
725
+ const m = JSON.parse(ev.data);
726
+ if (m.type === "welcome") {
727
+ pc = new RTCPeerConnection({
728
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
729
+ });
730
+ this.pc = pc;
731
+ pc.onicecandidate = (e) => {
732
+ if (e.candidate)
733
+ ws.send(JSON.stringify({ type: "candidate", candidate: e.candidate }));
734
+ };
735
+ pc.onconnectionstatechange = () => this.onstate(pc.connectionState);
736
+ pc.ondatachannel = (e) => {
737
+ this.dc = e.channel;
738
+ this.dc.onopen = () => {
739
+ settled = true;
740
+ this.onstate("open");
741
+ resolve();
742
+ };
743
+ this.dc.onmessage = (ev2) => this._recv(JSON.parse(ev2.data));
744
+ this.dc.onclose = () => this.onstate("closed");
745
+ };
746
+ } else if (m.type === "offer") {
747
+ await pc.setRemoteDescription({ type: "offer", sdp: m.sdp });
748
+ await pc.setLocalDescription(await pc.createAnswer());
749
+ ws.send(JSON.stringify({ type: "answer", sdp: pc.localDescription.sdp }));
750
+ } else if (m.type === "candidate") {
751
+ await pc.addIceCandidate(m.candidate).catch(() => {});
752
+ }
753
+ };
754
+ ws.onerror = () => fail(new Error("signaling error"));
755
+ ws.onclose = () => fail(new Error("signaling closed"));
756
+ setTimeout(() => fail(new Error("connect timeout")), 8000);
757
+ });
758
+ }
759
+ _recv(r) {
760
+ const call = this.calls.get(r.id),
761
+ stream = this.streams.get(r.id);
762
+ if (r.t === "res") {
763
+ if (call) {
764
+ call.status = r.status;
765
+ }
766
+ } else if (r.t === "data") {
767
+ if (call) call.body += r.chunk;
768
+ if (stream) stream(r.chunk);
769
+ } else if (r.t === "end") {
770
+ if (call) {
771
+ this.calls.delete(r.id);
772
+ r.error
773
+ ? call.reject(new Error(r.error))
774
+ : call.resolve({ status: call.status, text: call.body });
775
+ }
776
+ }
777
+ }
778
+ req(method, path, body) {
779
+ const id = this.nextId++;
780
+ return new Promise((resolve, reject) => {
781
+ this.calls.set(id, { status: 0, body: "", resolve, reject });
782
+ this.dc.send(JSON.stringify({ t: "req", id, method, path, body }));
783
+ });
784
+ }
785
+ subscribe(path, onRaw) {
786
+ const id = this.nextId++;
787
+ this.streams.set(id, onRaw);
788
+ this.dc.send(JSON.stringify({ t: "req", id, method: "GET", path }));
789
+ return () => {
790
+ this.streams.delete(id);
791
+ try {
792
+ this.dc.send(JSON.stringify({ t: "abort", id }));
793
+ } catch {}
794
+ };
795
+ }
796
+ }
797
+
798
+ // ---- codehost rooms: a THIRD wire -----------------------------------
799
+ // A codehost room can hold several machines: every `codehost serve`
800
+ // daemon advertises its live agents (PeerMeta.agents) and proxies the
801
+ // local ay-serve API over its WebRTC tunnel at /__codehost/agent-yes/*.
802
+ // This wire aggregates all hosts' agents into one list, then routes each
803
+ // per-agent call to the host that owns the pid. Known v1 limitation:
804
+ // pids may collide across hosts (last listed wins); the host: tag keeps
805
+ // them tellable-apart visually.
806
+ const CH_API = "/__codehost/agent-yes";
807
+ class CodehostClient {
808
+ constructor(token) {
809
+ Object.assign(this, { token, room: null, byPid: new Map(), onstate: () => {} });
810
+ }
811
+ async connect() {
812
+ if (!window.__codehost)
813
+ await new Promise((r) => window.addEventListener("codehost-ready", r, { once: true }));
814
+ let firstPeers;
815
+ const ready = new Promise((resolve) => (firstPeers = resolve));
816
+ this.room = window.__codehost.joinRoom({
817
+ token: this.token,
818
+ onStatus: (open) => {
819
+ if (!open) this.onstate("closed");
820
+ },
821
+ onPeers: () => {
822
+ this.onstate("open");
823
+ firstPeers();
824
+ },
825
+ });
826
+ // First peer broadcast usually lands instantly after the WS opens;
827
+ // don't hang the UI on an empty/unreachable room.
828
+ await Promise.race([ready, new Promise((r) => setTimeout(r, 6000))]);
829
+ }
830
+ close() {
831
+ this.room?.close();
832
+ }
833
+ hosts() {
834
+ // Machines that can answer for agents: anything advertising agents,
835
+ // plus root daemons (their ay serve may know agents the broadcast
836
+ // hasn't picked up yet).
837
+ return (this.room?.peers || []).filter(
838
+ (p) => p.meta && ((p.meta.agents || []).length || p.meta.kind === "root"),
839
+ );
840
+ }
841
+ anyHost() {
842
+ const h = this.hosts();
843
+ return h.length ? h[0].peerId : null;
844
+ }
845
+ peerFor(path) {
846
+ const m = /^\/api\/(?:tail|size|resize|read|status)\/([^/?]+)/.exec(path);
847
+ const kw = m ? decodeURIComponent(m[1]) : null;
848
+ return (kw && this.byPid.get(kw)) || null;
849
+ }
850
+ async ls(path) {
851
+ const hosts = this.hosts();
852
+ const lists = await Promise.all(
853
+ hosts.map(async (p) => {
854
+ try {
855
+ const res = await this.room.fetch(p.peerId, "GET", CH_API + path);
856
+ if (!res.ok) throw new Error("HTTP " + res.status);
857
+ return (await res.json()).map((e) => ({ ...e, _host: p.meta?.host }));
858
+ } catch {
859
+ // ay serve down on that host — fall back to the daemon-advertised list.
860
+ return (p.meta?.agents || []).map((a) => ({
861
+ pid: a.pid,
862
+ cli: a.tool,
863
+ title: a.title || null,
864
+ prompt: a.title || null,
865
+ cwd: a.cwd,
866
+ status: a.state,
867
+ started_at: a.startedAt,
868
+ _host: p.meta?.host,
869
+ }));
870
+ }
871
+ }),
872
+ );
873
+ const byPid = new Map();
874
+ const merged = [];
875
+ lists.forEach((arr, i) => {
876
+ for (const e of arr) {
877
+ byPid.set(String(e.pid), hosts[i].peerId);
878
+ merged.push(e);
879
+ }
880
+ });
881
+ this.byPid = byPid;
882
+ return merged;
883
+ }
884
+ async fetchJSON(path) {
885
+ if (path.startsWith("/api/ls")) return this.ls(path);
886
+ const peer = this.peerFor(path) || this.anyHost();
887
+ if (!peer) throw new Error("no codehost peer");
888
+ const res = await this.room.fetch(peer, "GET", CH_API + path);
889
+ if (!res.ok) throw new Error("HTTP " + res.status);
890
+ return res.json();
891
+ }
892
+ async post(path, bodyObj) {
893
+ const kw = bodyObj && bodyObj.keyword != null ? String(bodyObj.keyword) : null;
894
+ const peer = (kw && this.byPid.get(kw)) || this.peerFor(path) || this.anyHost();
895
+ if (!peer) return { ok: false, text: "no codehost peer in the room" };
896
+ const res = await this.room.fetch(peer, "POST", CH_API + path, {
897
+ headers: { "content-type": "application/json" },
898
+ body: JSON.stringify(bodyObj),
899
+ });
900
+ return { ok: res.status >= 200 && res.status < 300, text: await res.text() };
901
+ }
902
+ subscribe(path, onText, onOpen, onError) {
903
+ let cancelled = false;
904
+ let reader = null;
905
+ (async () => {
906
+ const peer = this.peerFor(path) || this.anyHost();
907
+ if (!peer) {
908
+ onError && onError();
909
+ return;
910
+ }
911
+ try {
912
+ const res = await this.room.fetch(peer, "GET", CH_API + path, {
913
+ headers: { accept: "text/event-stream" },
914
+ });
915
+ if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
916
+ onOpen && onOpen();
917
+ reader = res.body.getReader();
918
+ const dec = new TextDecoder();
919
+ let buf = "";
920
+ for (;;) {
921
+ const { done, value } = await reader.read();
922
+ if (done || cancelled) break;
923
+ buf += dec.decode(value, { stream: true });
924
+ let i;
925
+ while ((i = buf.indexOf("\n\n")) >= 0) {
926
+ const evt = buf.slice(0, i);
927
+ buf = buf.slice(i + 2);
928
+ for (const line of evt.split("\n"))
929
+ if (line.startsWith("data:")) {
930
+ try {
931
+ onText(JSON.parse(line.slice(5).trim()));
932
+ } catch {}
933
+ }
934
+ }
935
+ }
936
+ if (!cancelled) onError && onError();
937
+ } catch {
938
+ if (!cancelled) onError && onError();
939
+ }
940
+ })();
941
+ return () => {
942
+ cancelled = true;
943
+ try {
944
+ reader?.cancel();
945
+ } catch {}
946
+ };
947
+ }
948
+ }
949
+
950
+ // `ay serve --http` serves this page itself and prints a #k=<token> link;
951
+ // boot() stores the token and local /api calls carry it as ?token= (query,
952
+ // not header — EventSource can't set headers). When served via the lab
953
+ // proxy instead, no token is stored and paths stay bare (proxy injects).
954
+ const withTok = (path) => {
955
+ const t = localStorage.getItem("ay.localToken");
956
+ return t
957
+ ? path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(t)
958
+ : path;
959
+ };
960
+
961
+ const Conn = {
962
+ rtc: null, // RTCClient when remote (ay share), null when local
963
+ ch: null, // CodehostClient when viewing a codehost room
964
+ async fetchJSON(path) {
965
+ if (this.ch) return this.ch.fetchJSON(path);
966
+ if (this.rtc) return JSON.parse((await this.rtc.req("GET", path)).text);
967
+ return (await fetch(withTok(path))).json();
968
+ },
969
+ async post(path, bodyObj) {
970
+ if (this.ch) return this.ch.post(path, bodyObj);
971
+ if (this.rtc) {
972
+ const r = await this.rtc.req("POST", path, JSON.stringify(bodyObj));
973
+ return { ok: r.status >= 200 && r.status < 300, text: r.text };
974
+ }
975
+ const r = await fetch(withTok(path), {
976
+ method: "POST",
977
+ headers: { "Content-Type": "application/json" },
978
+ body: JSON.stringify(bodyObj),
979
+ });
980
+ return { ok: r.ok, text: await r.text() };
981
+ },
982
+ // onText gets each parsed SSE data payload (same shape as the local EventSource path).
983
+ subscribe(path, onText, onOpen, onError) {
984
+ if (this.ch) return this.ch.subscribe(path, onText, onOpen, onError);
985
+ if (this.rtc) {
986
+ onOpen && onOpen();
987
+ let buf = "";
988
+ return this.rtc.subscribe(path, (raw) => {
989
+ buf += raw;
990
+ let i;
991
+ while ((i = buf.indexOf("\n\n")) >= 0) {
992
+ const evt = buf.slice(0, i);
993
+ buf = buf.slice(i + 2);
994
+ for (const line of evt.split("\n"))
995
+ if (line.startsWith("data:")) {
996
+ try {
997
+ onText(JSON.parse(line.slice(5).trim()));
998
+ } catch {}
999
+ }
1000
+ }
1001
+ });
1002
+ }
1003
+ const ev = new EventSource(withTok(path));
1004
+ ev.onopen = () => onOpen && onOpen();
1005
+ ev.onmessage = (e) => onText(JSON.parse(e.data));
1006
+ ev.onerror = () => onError && onError();
1007
+ return () => ev.close();
1008
+ },
1009
+ };
1010
+
1011
+ // Derive codehost-style mnemonic tags from a cwd like .../ws/<owner>/<repo>/tree/<wt>
1012
+ function tagsFor(e) {
1013
+ const t = [];
1014
+ const m = /\/([^/]+)\/([^/]+)\/tree\/([^/]+)/.exec(e.cwd || "");
1015
+ if (m) {
1016
+ t.push(["repo", `${m[1]}/${m[2]}`], ["wt", m[3]]);
1017
+ }
1018
+ if (e.cli) t.push(["cli", e.cli]);
1019
+ if (e._host) t.push(["host", e._host]); // codehost rooms: which machine
1020
+ return t;
1021
+ }
1022
+ function age(e) {
1023
+ if (!e.started_at) return "";
1024
+ const s = Math.max(0, (Date.now() - e.started_at) / 1000);
1025
+ if (s < 60) return Math.floor(s) + "s";
1026
+ if (s < 3600) return Math.floor(s / 60) + "m";
1027
+ return Math.floor(s / 3600) + "h";
1028
+ }
1029
+
1030
+ function matches(e, toks) {
1031
+ const hay =
1032
+ (e.title || "") +
1033
+ " " +
1034
+ (e.prompt || "") +
1035
+ " " +
1036
+ e.cli +
1037
+ " " +
1038
+ (e.cwd || "") +
1039
+ " " +
1040
+ e.status;
1041
+ return toks.every((tok) => {
1042
+ tok = tok.toLowerCase();
1043
+ const ci = tok.indexOf(":");
1044
+ if (ci > 0) {
1045
+ const k = tok.slice(0, ci),
1046
+ v = tok.slice(ci + 1);
1047
+ return tagsFor(e).some(([tk, tv]) => tk === k && tv.toLowerCase().includes(v));
1048
+ }
1049
+ return hay.toLowerCase().includes(tok);
1050
+ });
1051
+ }
1052
+
1053
+ // Compact list: one line per agent (dot + cli + title), persisted per device.
1054
+ let compactList = localStorage.getItem("ay.compactList") === "1";
1055
+
1056
+ function renderList() {
1057
+ const toks = $("q").value.trim().split(/\s+/).filter(Boolean);
1058
+ const shown = entries.filter((e) => matches(e, toks));
1059
+ $("count").textContent = `${shown.length} / ${entries.length} agents`;
1060
+ $("viewbtn").classList.toggle("on", compactList);
1061
+ if (compactList) {
1062
+ $("list").innerHTML =
1063
+ shown
1064
+ .map((e) => {
1065
+ const t = e.title || e.prompt || "";
1066
+ return `<div class="row crow ${String(e.pid) === sel ? "sel" : ""}" data-pid="${e.pid}">
1067
+ <span class="dot ${esc(e.status)}"></span>
1068
+ <span class="cname">${esc(e.cli)}</span>
1069
+ <span class="ctitle ${e.title ? "" : "dim"}" title="${esc(t)}">${esc(t)}</span>
1070
+ <span class="age">${age(e)}</span></div>`;
1071
+ })
1072
+ .join("") || `<div class="empty">no match</div>`;
1073
+ return;
1074
+ }
1075
+ $("list").innerHTML =
1076
+ shown
1077
+ .map((e) => {
1078
+ const tags = tagsFor(e)
1079
+ .map(
1080
+ ([k, v]) =>
1081
+ `<span class="rtag" data-k="${k}"><span style="opacity:.55">${k}:</span>${esc(v)}</span>`,
1082
+ )
1083
+ .join("");
1084
+ return `<div class="row ${String(e.pid) === sel ? "sel" : ""}" data-pid="${e.pid}">
1085
+ <div class="r1"><span class="dot ${esc(e.status)}"></span>
1086
+ <span class="name">${esc(e.cli)}</span>
1087
+ <span class="badge">pid ${e.pid}</span>
1088
+ <span class="age">${age(e)}</span></div>
1089
+ ${e.title ? `<div class="rowtitle" title="${esc(e.title)}">${esc(e.title)}</div>` : ""}
1090
+ ${e.prompt ? `<div class="detail" title="${esc(e.prompt)}">${esc(e.prompt)}</div>` : ""}
1091
+ <div class="rowtags">${tags}</div>
1092
+ </div>`;
1093
+ })
1094
+ .join("") || `<div class="empty">no match</div>`;
1095
+ }
1096
+
1097
+ // A ?pid=<pid> in the URL (codehost.dev's agent chips link here) selects
1098
+ // that agent as soon as it shows up in the list, then forgets itself.
1099
+ let autoPid = new URLSearchParams(location.search).get("pid");
1100
+ if (autoPid) {
1101
+ history.replaceState(null, document.title, location.pathname + location.hash);
1102
+ }
1103
+
1104
+ async function loadList() {
1105
+ const remote = Conn.ch || Conn.rtc;
1106
+ try {
1107
+ entries = await Conn.fetchJSON("/api/ls?all=1");
1108
+ setConn(remote ? "● " + (curRoom || "remote") : "● local", "var(--green)");
1109
+ } catch (e) {
1110
+ setConn(remote ? "● peer down" : "● ay serve down", "var(--red)");
1111
+ }
1112
+ renderList();
1113
+ if (autoPid && entries.some((x) => String(x.pid) === String(autoPid))) {
1114
+ const pid = autoPid;
1115
+ autoPid = null;
1116
+ select(pid);
1117
+ }
1118
+ }
1119
+
1120
+ function select(pid) {
1121
+ sel = String(pid);
1122
+ const e = entries.find((x) => String(x.pid) === sel);
1123
+ if (!e) return;
1124
+ renderList();
1125
+ $("rhead").style.display = "flex";
1126
+ $("composer").style.display = "flex";
1127
+ $("hint").style.display = "block";
1128
+ $("rdot").className = "dot " + e.status;
1129
+ $("rname").textContent = e.title || e.cli;
1130
+ $("rpid").textContent = "pid " + e.pid;
1131
+ $("msg").focus();
1132
+
1133
+ // Render the agent's native TUI with xterm.js by feeding it the raw PTY
1134
+ // stream (ANSI/cursor control intact) — see /api/tail?raw=1.
1135
+ if (es) es.close();
1136
+ if (term) {
1137
+ term.dispose();
1138
+ term = null;
1139
+ }
1140
+ const logEl = $("log");
1141
+ logEl.innerHTML = "";
1142
+ term = new Terminal({
1143
+ convertEol: false,
1144
+ disableStdin: false,
1145
+ cursorBlink: true,
1146
+ scrollback: 5000,
1147
+ fontSize: 12,
1148
+ fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
1149
+ theme: { background: "#0d1117", foreground: "#c9d1d9", cursor: "#0d1117" },
1150
+ });
1151
+ fit = new FitAddon.FitAddon();
1152
+ term.loadAddon(fit);
1153
+ term.open(logEl);
1154
+ // An agent can rename itself by emitting an OSC 0/2 title sequence
1155
+ // (\x1b]2;my-name\x07); xterm parses it out of the raw PTY stream we already
1156
+ // feed it, so we just surface the latest title as the header name. Falls
1157
+ // back to the cli name when the agent never sets one.
1158
+ term.onTitleChange((t) => {
1159
+ if (sel === String(e.pid) && t && t.trim()) $("rname").textContent = t.trim();
1160
+ });
1161
+ // Adapt: drive the agent's PTY to the browser terminal size (POST
1162
+ // /api/resize → winsize + SIGWINCH) so its TUI reflows to match what we
1163
+ // render. Suppressed while we're merely adopting the agent's OWN size.
1164
+ let adoptingAgentSize = false;
1165
+ const pushSize = () => {
1166
+ if (term && sel && !adoptingAgentSize)
1167
+ Conn.post("/api/resize/" + encodeURIComponent(sel), {
1168
+ cols: term.cols,
1169
+ rows: term.rows,
1170
+ }).catch(() => {});
1171
+ };
1172
+ term.onResize(pushSize);
1173
+ // Forward everything the terminal emits to the agent stdin (raw, no
1174
+ // trailing Enter): keystrokes, paste, and — when a TUI enables mouse
1175
+ // tracking — click / drag-motion / wheel as SGR mouse reports. onBinary
1176
+ // covers the UTF-8 mouse encoding (DECSET 1005). Verified end-to-end:
1177
+ // a drag emits \x1b[<0;..M / \x1b[<32;..M / \x1b[<0;..m, wheel \x1b[<64/65..M.
1178
+ const fwd = (d) => {
1179
+ if (sel) Conn.post("/api/send", { keyword: sel, msg: d, code: "none" }).catch(() => {});
1180
+ };
1181
+ term.onData(fwd);
1182
+ term.onBinary(fwd);
1183
+ // Render the existing buffer at the AGENT's current width first so its
1184
+ // wrapping is correct, instead of forcing our viewport width onto stale
1185
+ // content. The user adapts to the window by resizing it (fit → push).
1186
+ const selPid = sel;
1187
+ Conn.fetchJSON("/api/size/" + encodeURIComponent(selPid))
1188
+ .then((sz) => {
1189
+ if (sel !== selPid || !term) return;
1190
+ if (sz && sz.cols && sz.rows) {
1191
+ adoptingAgentSize = true;
1192
+ term.resize(sz.cols, sz.rows);
1193
+ adoptingAgentSize = false;
1194
+ } else {
1195
+ try {
1196
+ fit.fit();
1197
+ } catch {}
1198
+ }
1199
+ })
1200
+ .catch(() => {
1201
+ try {
1202
+ fit.fit();
1203
+ } catch {}
1204
+ });
1205
+
1206
+ // True live tail via ay serve's SSE stream. First event is an xterm-rendered
1207
+ // tail snapshot; later events are incremental deltas. We normalise terminal
1208
+ // carriage returns so the stream reads cleanly as HTML, autoscroll while the
1209
+ // viewer is pinned to the bottom, and cap the buffer so it can't grow forever.
1210
+ $("livedot").className = "dot idle";
1211
+ $("livetxt").textContent = "connecting…";
1212
+ const close = Conn.subscribe(
1213
+ "/api/tail/" + encodeURIComponent(sel) + "?raw=1",
1214
+ (raw) => {
1215
+ if (term) term.write(raw);
1216
+ },
1217
+ () => {
1218
+ $("livedot").className = "dot active";
1219
+ $("livetxt").textContent = "live";
1220
+ },
1221
+ () => {
1222
+ $("livedot").className = "dot stopped";
1223
+ $("livetxt").textContent = "disconnected";
1224
+ },
1225
+ );
1226
+ es = { close };
1227
+ }
1228
+
1229
+ async function send() {
1230
+ if (!sel) return;
1231
+ const msg = $("msg").value;
1232
+ if (!msg.trim()) return;
1233
+ $("send").disabled = true;
1234
+ try {
1235
+ const r = await Conn.post("/api/send", { keyword: sel, msg, code: "enter" });
1236
+ if (r.ok) {
1237
+ $("msg").value = "";
1238
+ } else {
1239
+ alert("send failed: " + r.text);
1240
+ }
1241
+ } finally {
1242
+ $("send").disabled = false;
1243
+ $("msg").focus();
1244
+ }
1245
+ }
1246
+
1247
+ $("list").addEventListener("click", (ev) => {
1248
+ const row = ev.target.closest(".row");
1249
+ if (row) select(row.dataset.pid);
1250
+ });
1251
+ $("q").addEventListener("input", renderList);
1252
+ $("viewbtn").addEventListener("click", () => {
1253
+ compactList = !compactList;
1254
+ localStorage.setItem("ay.compactList", compactList ? "1" : "0");
1255
+ renderList();
1256
+ });
1257
+ window.addEventListener("resize", () => {
1258
+ if (fit)
1259
+ try {
1260
+ fit.fit();
1261
+ } catch {}
1262
+ });
1263
+ $("send").addEventListener("click", send);
1264
+ $("msg").addEventListener("keydown", (ev) => {
1265
+ if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) {
1266
+ ev.preventDefault();
1267
+ send();
1268
+ }
1269
+ });
1270
+
1271
+ // ---- rooms: localStorage cache + a manager you open by clicking the badge ----
1272
+ const ROOMS_KEY = "ay.rooms";
1273
+ let curRoom = null;
1274
+ const loadRooms = () => {
1275
+ try {
1276
+ return JSON.parse(localStorage.getItem(ROOMS_KEY) || "{}");
1277
+ } catch {
1278
+ return {};
1279
+ }
1280
+ };
1281
+ const saveRoom = (room, token, host) => {
1282
+ const r = loadRooms();
1283
+ r[room] = { token, host: host || SIG_DEFAULT, ts: Date.now() };
1284
+ localStorage.setItem(ROOMS_KEY, JSON.stringify(r));
1285
+ };
1286
+ const dropRoom = (room) => {
1287
+ const r = loadRooms();
1288
+ delete r[room];
1289
+ localStorage.setItem(ROOMS_KEY, JSON.stringify(r));
1290
+ };
1291
+ function setConn(text, color) {
1292
+ $("conn").textContent = text;
1293
+ $("conn").style.color = color;
1294
+ }
1295
+
1296
+ // codehost rooms ride the same saved-rooms cache, discriminated by the
1297
+ // sentinel host "codehost". The token is a bearer secret — never render
1298
+ // it; display a short FNV-1a mnemonic instead (mirrors codehost.dev).
1299
+ const CH_HOST = "codehost";
1300
+ function chName(token) {
1301
+ let h = 0x811c9dc5;
1302
+ for (let i = 0; i < token.length; i++) {
1303
+ h ^= token.charCodeAt(i);
1304
+ h = Math.imul(h, 0x01000193);
1305
+ }
1306
+ return "ch-" + (h >>> 0).toString(36).slice(0, 4).padStart(4, "0");
1307
+ }
1308
+
1309
+ function dropConn() {
1310
+ if (Conn.rtc) {
1311
+ try {
1312
+ Conn.rtc.pc?.close();
1313
+ } catch {}
1314
+ Conn.rtc = null;
1315
+ }
1316
+ if (Conn.ch) {
1317
+ try {
1318
+ Conn.ch.close();
1319
+ } catch {}
1320
+ Conn.ch = null;
1321
+ }
1322
+ }
1323
+
1324
+ async function connectRoom(room, token, host) {
1325
+ host = host || SIG_DEFAULT;
1326
+ saveRoom(room, token, host); // cache so the badge can list & reconnect later
1327
+ try {
1328
+ localStorage.setItem("ay.lastRoom", room); // reconnect here on a bare open
1329
+ } catch {}
1330
+ curRoom = room;
1331
+ dropConn();
1332
+ setConn("● connecting " + room + "…", "var(--amber)");
1333
+ if (host === CH_HOST) {
1334
+ const c = new CodehostClient(token);
1335
+ c.onstate = (s) => {
1336
+ if (s === "closed") setConn("● room lost", "var(--red)");
1337
+ };
1338
+ try {
1339
+ await c.connect();
1340
+ Conn.ch = c;
1341
+ } catch (e) {
1342
+ setConn("● connect failed", "var(--red)");
1343
+ }
1344
+ loadList();
1345
+ return;
1346
+ }
1347
+ const c = new RTCClient(host, room, token);
1348
+ c.onstate = (s) => {
1349
+ if (s === "failed" || s === "closed") setConn("● peer lost", "var(--red)");
1350
+ };
1351
+ try {
1352
+ await c.connect();
1353
+ Conn.rtc = c;
1354
+ } catch (e) {
1355
+ setConn("● connect failed", "var(--red)");
1356
+ }
1357
+ loadList();
1358
+ }
1359
+
1360
+ function parseRoomInput(s) {
1361
+ s = s.trim();
1362
+ const hash = s.indexOf("#");
1363
+ if (hash >= 0) s = s.slice(hash + 1);
1364
+ s = decodeURIComponent(s);
1365
+ // codehost rooms: "ch:<room-token>" or a codehost.dev share link ("…#t=<token>").
1366
+ if (s.startsWith("ch:") || s.startsWith("t=")) {
1367
+ const token = (s.startsWith("ch:") ? s.slice(3) : s.slice(2)).trim();
1368
+ return token ? { room: chName(token), token, host: CH_HOST } : null;
1369
+ }
1370
+ const m = /^([A-Za-z0-9_-]+):([^@\s]+)(?:@(.+))?$/.exec(s);
1371
+ return m ? { room: m[1], token: m[2], host: m[3] } : null;
1372
+ }
1373
+
1374
+ function renderRooms() {
1375
+ const r = loadRooms();
1376
+ const names = Object.keys(r).sort((a, b) => r[b].ts - r[a].ts);
1377
+ const items = names.length
1378
+ ? names
1379
+ .map(
1380
+ (n) => `<div class="ritem ${n === curRoom ? "cur" : ""}">
1381
+ <span class="rname" data-room="${esc(n)}">${esc(n)}</span>
1382
+ <span class="rhost">${esc(r[n].host)}</span>
1383
+ <span class="rx" data-del="${esc(n)}" title="forget">✕</span></div>`,
1384
+ )
1385
+ .join("")
1386
+ : `<div class="empty2">no saved rooms — paste a share link below</div>`;
1387
+ $("rooms").innerHTML = `<div class="rtitle">rooms · stored on this device</div>${items}
1388
+ <div class="radd"><input id="roomin" placeholder="room:token or https://…/#room:token" /><button id="roomadd">add</button></div>
1389
+ <div class="rconnect">share your own fleet — run this, then open the printed link:
1390
+ <code id="cmd" title="click to copy">bunx agent-yes serve --share</code></div>
1391
+ <div class="rconnect">or view a <b>codehost</b> room — paste <code>ch:&lt;room-token&gt;</code> (or a
1392
+ codehost.dev share link) above; every machine in the room shows its agents here.</div>`;
1393
+ }
1394
+
1395
+ $("conn").addEventListener("click", () => {
1396
+ const el = $("rooms");
1397
+ if (el.style.display === "none") {
1398
+ renderRooms();
1399
+ el.style.display = "block";
1400
+ $("roomin")?.focus();
1401
+ } else el.style.display = "none";
1402
+ });
1403
+ $("rooms").addEventListener("click", (ev) => {
1404
+ const name = ev.target.closest(".rname");
1405
+ if (name) {
1406
+ const r = loadRooms()[name.dataset.room];
1407
+ if (r) {
1408
+ connectRoom(name.dataset.room, r.token, r.host);
1409
+ $("rooms").style.display = "none";
1410
+ }
1411
+ return;
1412
+ }
1413
+ const del = ev.target.closest(".rx");
1414
+ if (del) {
1415
+ dropRoom(del.dataset.del);
1416
+ renderRooms();
1417
+ return;
1418
+ }
1419
+ if (ev.target.id === "roomadd") {
1420
+ const p = parseRoomInput($("roomin").value);
1421
+ if (p) {
1422
+ connectRoom(p.room, p.token, p.host);
1423
+ $("rooms").style.display = "none";
1424
+ } else alert("expected room:token or a share link");
1425
+ }
1426
+ if (ev.target.id === "cmd") {
1427
+ navigator.clipboard?.writeText(ev.target.textContent).then(() => {
1428
+ const o = ev.target.textContent;
1429
+ ev.target.textContent = "copied ✓";
1430
+ setTimeout(() => {
1431
+ ev.target.textContent = o;
1432
+ }, 1000);
1433
+ });
1434
+ }
1435
+ });
1436
+
1437
+ // ---- launch: command-only URLs (#launch=<json>, NO token) ----
1438
+ // The link carries only WHAT to run; the capability to run it is the viewer's
1439
+ // own connected fleet (cached room) + a y/N confirm on that host. So a public
1440
+ // launch link can never spawn on a machine the clicker doesn't already control.
1441
+ function showLaunch(spec) {
1442
+ const rooms = loadRooms();
1443
+ const names = Object.keys(rooms).sort((a, b) => rooms[b].ts - rooms[a].ts);
1444
+ const cmd = "ay " + (spec.cli || "claude") + (spec.prompt ? ` -- "${spec.prompt}"` : "");
1445
+ const fleets = names.length
1446
+ ? `<div class="lfleets">${names.map((n) => `<button class="lfleet" data-room="${esc(n)}">▷ ${esc(n)}</button>`).join("")}</div>
1447
+ <div class="lhint">pick a fleet to run on</div>`
1448
+ : `<div class="lwarn">No connected fleet on this device. Run <code>bunx agent-yes serve --share</code>, open its link once, then reopen this launch link.</div>`;
1449
+ $("launch").innerHTML = `<div class="lcard">
1450
+ <div class="ltitle">Launch agent</div>
1451
+ <div class="lcmd">${esc(cmd)}</div>
1452
+ <div class="lcwd">cwd: ${esc(spec.cwd || "(host default)")}</div>
1453
+ ${fleets}
1454
+ <button class="lcancel">cancel</button></div>`;
1455
+ $("launch").dataset.spec = JSON.stringify(spec);
1456
+ $("launch").style.display = "flex";
1457
+ }
1458
+
1459
+ // Spawn a new agent on the CURRENT connection (local same-origin, or the
1460
+ // connected remote fleet) and select it once it registers. Shared by the
1461
+ // launch-URL flow and the "+ New agent" button. Returns false on a spawn
1462
+ // error (the alert is already shown), true otherwise.
1463
+ async function spawnAndSelect(spec) {
1464
+ await loadList();
1465
+ // Match by "newest agent that wasn't here before" — the spawn returns the
1466
+ // wrapper pid, but the agent registers under the runtime's own pid.
1467
+ const before = new Set(entries.map((e) => e.pid));
1468
+ const res = await Conn.post("/api/spawn", {
1469
+ cli: spec.cli || "claude",
1470
+ cwd: spec.cwd || undefined,
1471
+ prompt: spec.prompt || undefined,
1472
+ });
1473
+ if (!res.ok) {
1474
+ alert("launch failed: " + res.text);
1475
+ return false;
1476
+ }
1477
+ for (let i = 0; i < 14; i++) {
1478
+ await loadList();
1479
+ const fresh = entries
1480
+ .filter((e) => !before.has(e.pid))
1481
+ .sort((a, b) => (b.started_at || 0) - (a.started_at || 0));
1482
+ if (fresh.length) {
1483
+ select(fresh[0].pid);
1484
+ return true;
1485
+ }
1486
+ await new Promise((r) => setTimeout(r, 800));
1487
+ }
1488
+ return true;
1489
+ }
1490
+
1491
+ async function launchOn(room, spec) {
1492
+ const r = loadRooms()[room];
1493
+ if (!r) return;
1494
+ $("launch").style.display = "none";
1495
+ await connectRoom(room, r.token, r.host);
1496
+ await spawnAndSelect(spec);
1497
+ }
1498
+
1499
+ $("launch").addEventListener("click", (ev) => {
1500
+ const f = ev.target.closest(".lfleet");
1501
+ if (f) {
1502
+ launchOn(f.dataset.room, JSON.parse($("launch").dataset.spec));
1503
+ return;
1504
+ }
1505
+ if (ev.target.closest(".lcancel")) $("launch").style.display = "none";
1506
+ });
1507
+
1508
+ // ---- "+ New agent": a spawn form for the CURRENTLY connected fleet ----
1509
+ // Click → fill (cli defaults to claude, cwd prefilled from the selected agent
1510
+ // when there is one, prompt optional) → POST /api/spawn on this connection.
1511
+ // Always allowed: the console already controls every running agent's stdin.
1512
+ function showNew() {
1513
+ const here = entries.find((x) => String(x.pid) === sel);
1514
+ const cwd = here?.cwd || "";
1515
+ const where = Conn.rtc ? curRoom || "remote fleet" : "local";
1516
+ $("newform").innerHTML = `<div class="lcard">
1517
+ <div class="ltitle">New agent · ${esc(where)}</div>
1518
+ <div class="nfield"><label>CLI</label><input id="nf-cli" value="claude" spellcheck="false" autocapitalize="off" /></div>
1519
+ <div class="nfield"><label>Working dir</label><input id="nf-cwd" value="${esc(cwd)}" placeholder="(host default)" spellcheck="false" autocapitalize="off" /></div>
1520
+ <div class="nfield"><label>Prompt — optional</label><textarea id="nf-prompt" rows="3" placeholder="initial message to send the agent…"></textarea></div>
1521
+ <div class="lrow">
1522
+ <button class="lfleet" id="nf-go">▷ Launch</button>
1523
+ <button class="lcancel" id="nf-cancel">cancel</button>
1524
+ </div>
1525
+ <div class="lhint">POST /api/spawn on this fleet. ⌘/Ctrl+Enter to launch.</div></div>`;
1526
+ $("newform").style.display = "flex";
1527
+ setTimeout(() => $("nf-prompt")?.focus(), 0);
1528
+ }
1529
+
1530
+ async function submitNew() {
1531
+ const go = $("nf-go");
1532
+ if (!go || go.disabled) return;
1533
+ const spec = {
1534
+ cli: ($("nf-cli").value || "claude").trim(),
1535
+ cwd: $("nf-cwd").value.trim(),
1536
+ prompt: $("nf-prompt").value,
1537
+ };
1538
+ go.disabled = true;
1539
+ go.textContent = "launching…";
1540
+ const ok = await spawnAndSelect(spec);
1541
+ if (ok) {
1542
+ $("newform").style.display = "none";
1543
+ } else {
1544
+ go.disabled = false;
1545
+ go.textContent = "▷ Launch";
1546
+ }
1547
+ }
1548
+
1549
+ $("newbtn").addEventListener("click", showNew);
1550
+ $("newform").addEventListener("click", (ev) => {
1551
+ if (ev.target.id === "nf-go") submitNew();
1552
+ else if (ev.target.id === "nf-cancel" || ev.target === $("newform"))
1553
+ $("newform").style.display = "none";
1554
+ });
1555
+ $("newform").addEventListener("keydown", (ev) => {
1556
+ if (ev.key === "Escape") $("newform").style.display = "none";
1557
+ else if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) {
1558
+ ev.preventDefault();
1559
+ submitNew();
1560
+ }
1561
+ });
1562
+
1563
+ // boot: a launch URL opens the launcher; otherwise connect from the hash (then
1564
+ // eat the token); a bare #room reconnects from the cached token; else local.
1565
+ async function boot() {
1566
+ const raw = location.hash.replace(/^#/, "");
1567
+ if (raw.startsWith("launch=")) {
1568
+ let spec = null;
1569
+ try {
1570
+ spec = JSON.parse(decodeURIComponent(raw.slice(7)));
1571
+ } catch {}
1572
+ history.replaceState(null, document.title, location.pathname + location.search); // eat launch params
1573
+ if (spec) showLaunch(spec);
1574
+ setConn("● local", "var(--muted)");
1575
+ loadList();
1576
+ setInterval(loadList, 3000);
1577
+ return;
1578
+ }
1579
+ // #k=<token> — local-mode auth from `ay serve --http`'s printed link.
1580
+ // Store it and stay local (don't fall through to the saved-room reconnect).
1581
+ if (raw.startsWith("k=")) {
1582
+ try {
1583
+ localStorage.setItem("ay.localToken", decodeURIComponent(raw.slice(2)));
1584
+ } catch {}
1585
+ // SECURITY: strip the token from the URL immediately.
1586
+ history.replaceState(null, document.title, location.pathname + location.search);
1587
+ setConn("● local", "var(--muted)");
1588
+ loadList();
1589
+ setInterval(loadList, 3000);
1590
+ return;
1591
+ }
1592
+ const h = decodeURIComponent(raw);
1593
+ let pending = null;
1594
+ // codehost rooms first — the generic room:token regex below would
1595
+ // otherwise eat "ch:<token>" as room "ch".
1596
+ if (h.startsWith("ch:") || h.startsWith("t=")) {
1597
+ const p = parseRoomInput(h);
1598
+ if (p) {
1599
+ // SECURITY: strip the bearer token from the URL immediately.
1600
+ history.replaceState(
1601
+ null,
1602
+ document.title,
1603
+ location.pathname + location.search + "#" + p.room,
1604
+ );
1605
+ pending = p;
1606
+ }
1607
+ }
1608
+ const full = pending ? null : /^([A-Za-z0-9_-]+):([^@]+)(?:@(.+))?$/.exec(h);
1609
+ const bare = /^([A-Za-z0-9_-]+)$/.exec(h);
1610
+ if (full) {
1611
+ const [, room, token, host] = full;
1612
+ // SECURITY: strip the token from the URL immediately so it never lingers in
1613
+ // the omnibox, history, or a screenshot. Keep only the room mnemonic.
1614
+ history.replaceState(
1615
+ null,
1616
+ document.title,
1617
+ location.pathname + location.search + "#" + room,
1618
+ );
1619
+ pending = { room, token, host };
1620
+ } else if (bare && loadRooms()[bare[1]]) {
1621
+ const r = loadRooms()[bare[1]];
1622
+ pending = { room: bare[1], token: r.token, host: r.host };
1623
+ } else if (!raw) {
1624
+ // No hash → reconnect to the last-used room (or the most recent saved
1625
+ // one), so opening agent-yes.com brings back your list automatically.
1626
+ const rooms = loadRooms();
1627
+ const names = Object.keys(rooms);
1628
+ if (names.length) {
1629
+ const last = localStorage.getItem("ay.lastRoom");
1630
+ const pick =
1631
+ last && rooms[last] ? last : names.sort((a, b) => rooms[b].ts - rooms[a].ts)[0];
1632
+ pending = { room: pick, token: rooms[pick].token, host: rooms[pick].host };
1633
+ }
1634
+ }
1635
+ // Render the UI immediately and refresh on a timer; connect to a room (if
1636
+ // any) in the BACKGROUND so a dead/slow cached room never blanks the page.
1637
+ if (!pending) setConn("● local", "var(--muted)");
1638
+ loadList();
1639
+ setInterval(loadList, 3000); // refresh statuses / new agents
1640
+ if (pending) connectRoom(pending.room, pending.token, pending.host);
1641
+ }
1642
+ boot();
1643
+ </script>
1644
+ </body>
1645
+ </html>