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.
- package/dist/SUPPORTED_CLIS-BGUPuqya.js +8 -0
- package/dist/{SUPPORTED_CLIS-eD-UlqO_.js → SUPPORTED_CLIS-eIjVu8HF.js} +2 -2
- package/dist/cli.js +3 -3
- package/dist/index.js +2 -2
- package/dist/serve-SQYFRbm3.js +554 -0
- package/dist/{share-DUhUA1Pi.js → share-BsCeIfQM.js} +21 -4
- package/dist/{subcommands-B4gXEu5I.js → subcommands-D3Z9cD9u.js} +1 -1
- package/dist/{subcommands-K242usI5.js → subcommands-z8Y8gcD_.js} +8 -3
- package/dist/{ts-BAc4Jcrw.js → ts-BECoCPV1.js} +2 -2
- package/dist/{versionChecker-MNvA73o9.js → versionChecker-pct2j3wR.js} +2 -2
- package/lab/ui/index.html +1645 -0
- package/lab/ui/room-client.js +520 -0
- package/package.json +4 -2
- package/ts/serve.ts +463 -311
- package/ts/share.ts +49 -17
- package/ts/subcommands.ts +6 -0
- package/dist/SUPPORTED_CLIS-CNO_pj9f.js +0 -8
- package/dist/serve-CKcbVPy6.js +0 -451
|
@@ -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 & 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) => ({ "&": "&", "<": "<", ">": ">" })[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:<room-token></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>
|