agent-yes 1.97.0 → 1.98.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/ts/serve.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdir, open, readFile, writeFile } from "fs/promises";
2
2
  import { watch } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
4
5
  import { homedir } from "os";
5
6
  import path from "path";
@@ -38,10 +39,7 @@ async function loadOrCreateToken(tokenFlag?: string): Promise<string> {
38
39
  }
39
40
  }
40
41
 
41
- function checkAuth(req: Request, expectedToken: string): boolean {
42
- const auth = req.headers.get("authorization") ?? "";
43
- if (!auth.startsWith("Bearer ")) return false;
44
- const provided = auth.slice(7);
42
+ function tokenEqual(provided: string, expectedToken: string): boolean {
45
43
  // Constant-time compare; pad both to the same length first
46
44
  const maxLen = Math.max(provided.length, expectedToken.length);
47
45
  const a = Buffer.from(provided.padEnd(maxLen, "\0"));
@@ -49,6 +47,14 @@ function checkAuth(req: Request, expectedToken: string): boolean {
49
47
  return timingSafeEqual(a, b) && provided.length === expectedToken.length;
50
48
  }
51
49
 
50
+ function checkAuth(req: Request, expectedToken: string): boolean {
51
+ const auth = req.headers.get("authorization") ?? "";
52
+ if (auth.startsWith("Bearer ")) return tokenEqual(auth.slice(7), expectedToken);
53
+ // Fallback: ?token= query param — the web UI's EventSource cannot set headers.
54
+ const q = new URL(req.url).searchParams.get("token");
55
+ return q ? tokenEqual(q, expectedToken) : false;
56
+ }
57
+
52
58
  const defaultOpts = (overrides: Partial<CommonOpts> = {}): CommonOpts => ({
53
59
  all: false,
54
60
  active: false,
@@ -77,7 +83,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
77
83
 
78
84
  if (sub === "install") {
79
85
  const token = await loadOrCreateToken(undefined);
80
- // Build the ay serve command with forwarded args (port, host, etc.)
86
+ // Build the ay serve command with forwarded args (port, host, --webrtc, etc.)
81
87
  const serveCmd = ["ay", "serve", ...args].join(" ");
82
88
  const proc = Bun.spawn(
83
89
  [oxmgrBin, "start", serveCmd, "--name", DAEMON_NAME, "--restart", "always"],
@@ -85,12 +91,19 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
85
91
  );
86
92
  const code = await proc.exited;
87
93
  if (code === 0) {
94
+ const portM = /--port[=\s](\d+)/.exec(args.join(" "));
95
+ const port = portM ? Number(portM[1]) : DEFAULT_PORT;
88
96
  process.stdout.write(`\ninstalled '${DAEMON_NAME}' as a daemon via oxmgr\n`);
89
97
  process.stdout.write(`token: ${token}\n\n`);
90
- process.stdout.write(` ay ls ${token}@<host>:${DEFAULT_PORT}\n`);
91
- process.stdout.write(` ay remote add <alias> http://${token}@<host>:${DEFAULT_PORT}\n`);
98
+ process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
99
+ process.stdout.write(` ay remote add <alias> http://${token}@<host>:${port}\n`);
92
100
  process.stdout.write(` ay serve logs # view server logs\n`);
93
101
  process.stdout.write(` ay serve uninstall # remove daemon\n`);
102
+ if (args.some((a) => a.startsWith("--webrtc") || a.startsWith("--share"))) {
103
+ process.stdout.write(
104
+ `\nthe WebRTC share link is printed by the daemon — see: ay serve logs\n`,
105
+ );
106
+ }
94
107
  }
95
108
  return code ?? 1;
96
109
  }
@@ -120,12 +133,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
120
133
  if (rest.includes("-h") || rest.includes("--help")) {
121
134
  process.stdout.write(
122
135
  `Usage: ay serve [options]\n\n` +
123
- `Start an HTTP API server so remote machines can list/tail/send agents.\n\n` +
136
+ `Start an API server (HTTP and/or WebRTC) so browsers and remote machines\n` +
137
+ `can list/tail/send agents.\n\n` +
138
+ `Modes (default: --http):\n` +
139
+ ` --http HTTP API + web console on --port; no WebRTC\n` +
140
+ ` --webrtc [URL] Share over WebRTC (bare flag mints a room+link on\n` +
141
+ ` agent-yes.com, or pass webrtc://room:token@host).\n` +
142
+ ` Alone it needs NO port — combine with --http for both.\n` +
143
+ ` --share [URL] Legacy alias for --http --webrtc\n\n` +
124
144
  `Options:\n` +
125
145
  ` --port N Port to listen on (default: ${DEFAULT_PORT})\n` +
126
146
  ` --host HOST Interface to bind (default: 127.0.0.1; use 0.0.0.0 to expose)\n` +
127
147
  ` --token TOKEN Auth token (auto-generated and saved if omitted)\n` +
128
- ` --share [URL] Share over WebRTC to agent-yes.com (bare flag mints a room+link)\n` +
129
148
  ` --allow-spawn Deprecated no-op — the console can always spawn agents\n` +
130
149
  ` --tls-cert FILE TLS certificate PEM\n` +
131
150
  ` --tls-key FILE TLS private key PEM\n\n` +
@@ -157,10 +176,24 @@ export async function cmdServe(rest: string[]): Promise<number> {
157
176
  .option("token", { type: "string", description: "Auth token (auto-generated if omitted)" })
158
177
  .option("tls-cert", { type: "string", description: "TLS certificate file (PEM)" })
159
178
  .option("tls-key", { type: "string", description: "TLS private key file (PEM)" })
160
- .option("share", {
179
+ .option("http", {
180
+ type: "boolean",
181
+ description: "Serve the HTTP API + web console on --port (default mode)",
182
+ })
183
+ .option("webrtc", {
161
184
  type: "string",
162
185
  description:
163
- "Share over WebRTC: bare flag mints a room+link, or pass webrtc://room:token@host",
186
+ "Share over WebRTC: bare flag mints a room+link, or pass webrtc://room:token@host. Needs no port unless combined with --http",
187
+ })
188
+ .option("share", {
189
+ type: "string",
190
+ description: "Legacy alias for --http --webrtc",
191
+ })
192
+ .option("daemon", {
193
+ alias: "d",
194
+ type: "boolean",
195
+ default: false,
196
+ description: "Install as a background daemon via oxmgr (same as: ay serve install <flags>)",
164
197
  })
165
198
  .option("allow-spawn", {
166
199
  type: "boolean",
@@ -185,7 +218,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
185
218
  const useHttps = !!(certPath && keyPath);
186
219
  const scheme = useHttps ? "https" : "http";
187
220
 
188
- if (host !== "127.0.0.1" && host !== "localhost") {
221
+ // Modes: --http (HTTP listener + web console), --webrtc (port-free WebRTC
222
+ // share), or both. Bare `ay serve` stays HTTP-only; --share keeps its old
223
+ // meaning (HTTP + WebRTC) for existing invocations.
224
+ const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
225
+ const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
226
+
227
+ if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
189
228
  process.stderr.write(
190
229
  "ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
191
230
  );
@@ -198,350 +237,435 @@ export async function cmdServe(rest: string[]): Promise<number> {
198
237
  // y/N prompt bought no real safety. We just log each spawn so the host sees it.
199
238
  // (--allow-spawn is still accepted as a no-op for older invocations.)
200
239
 
201
- const serverOpts: any = {
202
- hostname: host,
203
- port,
204
- async fetch(req: Request): Promise<Response> {
205
- if (!checkAuth(req, token)) {
206
- return new Response("Unauthorized", { status: 401 });
240
+ // Agents retitle their terminal by writing OSC 0/2 (\x1b]2;name\x07) into the
241
+ // PTY stream we log; surfacing the most recent one lets the console label list
242
+ // rows without streaming every log. Cached per (size, mtime) — the UI polls
243
+ // /api/ls every few seconds and exited agents' logs never change again.
244
+ const titleCache = new Map<string, { size: number; mtimeMs: number; title: string | null }>();
245
+ const logTitle = async (logFile: string | null | undefined): Promise<string | null> => {
246
+ if (!logFile) return null;
247
+ try {
248
+ const fh = await open(logFile, "r");
249
+ try {
250
+ const { size, mtimeMs } = await fh.stat();
251
+ const hit = titleCache.get(logFile);
252
+ if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.title;
253
+ const len = Math.min(size, 65536);
254
+ const buf = Buffer.allocUnsafe(len);
255
+ const { bytesRead } = await fh.read(buf, 0, len, size - len);
256
+ const text = buf.toString("utf-8", 0, bytesRead);
257
+ // eslint-disable-next-line no-control-regex
258
+ const oscTitleRe = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
259
+ let title: string | null = null;
260
+ for (let m; (m = oscTitleRe.exec(text)); ) if (m[1]!.trim()) title = m[1]!.trim();
261
+ titleCache.set(logFile, { size, mtimeMs, title });
262
+ return title;
263
+ } finally {
264
+ await fh.close();
207
265
  }
266
+ } catch {
267
+ return null;
268
+ }
269
+ };
208
270
 
209
- const url = new URL(req.url);
210
- const p = url.pathname;
271
+ // The whole API as a plain handler: served over HTTP by Bun.serve (--http)
272
+ // and called in-process by the WebRTC bridge (--webrtc) — the latter needs
273
+ // no TCP port at all.
274
+ const apiFetch = async (req: Request): Promise<Response> => {
275
+ if (!checkAuth(req, token)) {
276
+ return new Response("Unauthorized", { status: 401 });
277
+ }
211
278
 
212
- // GET /api/ls
213
- if (req.method === "GET" && p === "/api/ls") {
214
- const keyword = url.searchParams.get("keyword") ?? undefined;
215
- const opts = defaultOpts({
216
- all: url.searchParams.get("all") === "1",
217
- active: url.searchParams.get("active") === "1",
218
- });
219
- try {
220
- const records = await listRecords(keyword, opts);
221
- return Response.json(records);
222
- } catch (e) {
223
- return new Response((e as Error).message, { status: 500 });
224
- }
225
- }
279
+ const url = new URL(req.url);
280
+ const p = url.pathname;
226
281
 
227
- // GET /api/notes
228
- if (req.method === "GET" && p === "/api/notes") {
229
- const notes = await readNotes();
230
- return Response.json(Object.fromEntries(notes));
282
+ // GET /api/ls
283
+ if (req.method === "GET" && p === "/api/ls") {
284
+ const keyword = url.searchParams.get("keyword") ?? undefined;
285
+ const opts = defaultOpts({
286
+ all: url.searchParams.get("all") === "1",
287
+ active: url.searchParams.get("active") === "1",
288
+ });
289
+ try {
290
+ const records = await listRecords(keyword, opts);
291
+ const withTitles = await Promise.all(
292
+ records.map(async (r) => ({ ...r, title: await logTitle(r.log_file) })),
293
+ );
294
+ return Response.json(withTitles);
295
+ } catch (e) {
296
+ return new Response((e as Error).message, { status: 500 });
231
297
  }
298
+ }
232
299
 
233
- // GET /api/status/:keyword
234
- const statusM = /^\/api\/status\/(.+)$/.exec(p);
235
- if (req.method === "GET" && statusM) {
236
- const keyword = decodeURIComponent(statusM[1]!);
237
- try {
238
- const record = await resolveOne(keyword, defaultOpts({ all: true }));
239
- const snap = await snapshotStatus(record);
240
- return Response.json(snap);
241
- } catch (e) {
242
- return new Response((e as Error).message, { status: 404 });
243
- }
300
+ // GET /api/notes
301
+ if (req.method === "GET" && p === "/api/notes") {
302
+ const notes = await readNotes();
303
+ return Response.json(Object.fromEntries(notes));
304
+ }
305
+
306
+ // GET /api/status/:keyword
307
+ const statusM = /^\/api\/status\/(.+)$/.exec(p);
308
+ if (req.method === "GET" && statusM) {
309
+ const keyword = decodeURIComponent(statusM[1]!);
310
+ try {
311
+ const record = await resolveOne(keyword, defaultOpts({ all: true }));
312
+ const snap = await snapshotStatus(record);
313
+ return Response.json(snap);
314
+ } catch (e) {
315
+ return new Response((e as Error).message, { status: 404 });
244
316
  }
317
+ }
245
318
 
246
- // GET /api/read/:keyword?mode=cat|tail|head&n=N — static log read
247
- const readM = /^\/api\/read\/(.+)$/.exec(p);
248
- if (req.method === "GET" && readM) {
249
- const keyword = decodeURIComponent(readM[1]!);
250
- const mode = (url.searchParams.get("mode") ?? "tail") as "cat" | "tail" | "head";
251
- const n = parseInt(url.searchParams.get("n") ?? "96", 10) || 96;
252
- try {
253
- const record = await resolveOne(keyword, defaultOpts());
254
- if (!record.log_file)
255
- return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
256
- const buf = await readFile(record.log_file);
257
- const text = await renderRawLog(buf, { mode, n });
258
- return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
259
- } catch (e) {
260
- return new Response((e as Error).message, { status: 404 });
261
- }
319
+ // GET /api/read/:keyword?mode=cat|tail|head&n=N — static log read
320
+ const readM = /^\/api\/read\/(.+)$/.exec(p);
321
+ if (req.method === "GET" && readM) {
322
+ const keyword = decodeURIComponent(readM[1]!);
323
+ const mode = (url.searchParams.get("mode") ?? "tail") as "cat" | "tail" | "head";
324
+ const n = parseInt(url.searchParams.get("n") ?? "96", 10) || 96;
325
+ try {
326
+ const record = await resolveOne(keyword, defaultOpts());
327
+ if (!record.log_file)
328
+ return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
329
+ const buf = await readFile(record.log_file);
330
+ const text = await renderRawLog(buf, { mode, n });
331
+ return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
332
+ } catch (e) {
333
+ return new Response((e as Error).message, { status: 404 });
262
334
  }
335
+ }
263
336
 
264
- // GET /api/size/:keyword — the agent's current PTY size, so the console can
265
- // render the existing buffer at the agent's real width before adapting.
266
- const sizeM = /^\/api\/size\/(.+)$/.exec(p);
267
- if (req.method === "GET" && sizeM) {
268
- const keyword = decodeURIComponent(sizeM[1]!);
337
+ // GET /api/size/:keyword — the agent's current PTY size, so the console can
338
+ // render the existing buffer at the agent's real width before adapting.
339
+ const sizeM = /^\/api\/size\/(.+)$/.exec(p);
340
+ if (req.method === "GET" && sizeM) {
341
+ const keyword = decodeURIComponent(sizeM[1]!);
342
+ try {
343
+ const record = await resolveOne(keyword, defaultOpts());
344
+ const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
345
+ let cols: number | null = null;
346
+ let rows: number | null = null;
269
347
  try {
270
- const record = await resolveOne(keyword, defaultOpts());
271
- const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
272
- let cols: number | null = null;
273
- let rows: number | null = null;
274
- try {
275
- const txt = await readFile(path.join(ayHome, "ptysize", String(record.pid)), "utf-8");
276
- const [c, r] = txt.trim().split(/\s+/).map(Number);
277
- if (c > 0 && r > 0) {
278
- cols = c;
279
- rows = r;
280
- }
281
- } catch {
282
- /* no ptysize sidecar (older agent or not yet written) */
348
+ const txt = await readFile(path.join(ayHome, "ptysize", String(record.pid)), "utf-8");
349
+ const [c = 0, r = 0] = txt.trim().split(/\s+/).map(Number);
350
+ if (c > 0 && r > 0) {
351
+ cols = c;
352
+ rows = r;
283
353
  }
284
- return Response.json({ pid: record.pid, cols, rows });
285
- } catch (e) {
286
- return new Response((e as Error).message, { status: 404 });
354
+ } catch {
355
+ /* no ptysize sidecar (older agent or not yet written) */
287
356
  }
357
+ return Response.json({ pid: record.pid, cols, rows });
358
+ } catch (e) {
359
+ return new Response((e as Error).message, { status: 404 });
288
360
  }
361
+ }
289
362
 
290
- // GET /api/tail/:keyword — SSE streaming
291
- const tailM = /^\/api\/tail\/(.+)$/.exec(p);
292
- if (req.method === "GET" && tailM) {
293
- const keyword = decodeURIComponent(tailM[1]!);
294
- // raw=1 streams the unmodified PTY bytes (ANSI/cursor control intact) so a
295
- // browser xterm.js can render the real terminal; default stays ANSI-stripped.
296
- const raw = url.searchParams.get("raw") === "1";
297
- try {
298
- const record = await resolveOne(keyword, defaultOpts());
299
- if (!record.log_file)
300
- return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
301
- const logPath = record.log_file;
302
-
303
- const stream = new ReadableStream({
304
- async start(ctrl) {
305
- const enc = new TextEncoder();
306
- const send = (text: string) =>
307
- ctrl.enqueue(enc.encode(`data: ${JSON.stringify(text)}\n\n`));
308
- const ping = () => ctrl.enqueue(enc.encode(": ping\n\n"));
309
-
310
- // Initial tail. Raw: replay the last ~64 KB of PTY bytes (enough to
311
- // contain a recent full-screen redraw so xterm converges fast).
312
- const initBuf = await readFile(logPath).catch(() => Buffer.alloc(0));
313
- if (raw)
314
- send(new TextDecoder().decode(initBuf.slice(Math.max(0, initBuf.length - 65536))));
315
- else send(await renderRawLog(initBuf, { mode: "tail", n: 96 }));
316
-
317
- let offset = initBuf.length;
318
- let closed = false;
319
-
320
- const heartbeat = setInterval(() => {
321
- if (closed) {
322
- clearInterval(heartbeat);
323
- return;
324
- }
325
- ping();
326
- }, 15_000);
327
-
328
- // eslint-disable-next-line no-control-regex
329
- const ansiRe =
330
- /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
331
- // eslint-disable-next-line no-control-regex
332
- const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
333
-
334
- // Stream only the bytes appended since `offset` (incremental read,
335
- // not a full re-read), driven by fs.watch for near-instant echo with
336
- // a short fallback poll in case the watcher misses an event. The old
337
- // 300 ms full-file poll was the dominant typing-echo latency.
338
- const fh = await open(logPath, "r").catch(() => null);
339
- let reading = false;
340
- const flush = async () => {
341
- if (closed || reading || !fh) return;
342
- reading = true;
343
- try {
344
- const { size } = await fh.stat();
345
- if (size < offset) offset = size; // truncated/rotated
346
- if (size > offset) {
347
- const len = size - offset;
348
- const buf = Buffer.allocUnsafe(len);
349
- const { bytesRead } = await fh.read(buf, 0, len, offset);
350
- offset += bytesRead;
351
- const chunk = buf.subarray(0, bytesRead);
352
- if (raw) {
353
- send(new TextDecoder().decode(chunk));
354
- } else {
355
- const text = new TextDecoder()
356
- .decode(chunk)
357
- .replace(ansiRe, "")
358
- .replace(ctrlRe, "");
359
- if (text.trim()) send(text.trimStart());
360
- }
363
+ // GET /api/tail/:keyword — SSE streaming
364
+ const tailM = /^\/api\/tail\/(.+)$/.exec(p);
365
+ if (req.method === "GET" && tailM) {
366
+ const keyword = decodeURIComponent(tailM[1]!);
367
+ // raw=1 streams the unmodified PTY bytes (ANSI/cursor control intact) so a
368
+ // browser xterm.js can render the real terminal; default stays ANSI-stripped.
369
+ const raw = url.searchParams.get("raw") === "1";
370
+ try {
371
+ const record = await resolveOne(keyword, defaultOpts());
372
+ if (!record.log_file)
373
+ return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
374
+ const logPath = record.log_file;
375
+
376
+ const stream = new ReadableStream({
377
+ async start(ctrl) {
378
+ const enc = new TextEncoder();
379
+ const send = (text: string) =>
380
+ ctrl.enqueue(enc.encode(`data: ${JSON.stringify(text)}\n\n`));
381
+ const ping = () => ctrl.enqueue(enc.encode(": ping\n\n"));
382
+
383
+ // Initial tail. Raw: replay the last ~64 KB of PTY bytes (enough to
384
+ // contain a recent full-screen redraw so xterm converges fast).
385
+ const initBuf = await readFile(logPath).catch(() => Buffer.alloc(0));
386
+ if (raw)
387
+ send(new TextDecoder().decode(initBuf.slice(Math.max(0, initBuf.length - 65536))));
388
+ else send(await renderRawLog(initBuf, { mode: "tail", n: 96 }));
389
+
390
+ let offset = initBuf.length;
391
+ let closed = false;
392
+
393
+ const heartbeat = setInterval(() => {
394
+ if (closed) {
395
+ clearInterval(heartbeat);
396
+ return;
397
+ }
398
+ ping();
399
+ }, 15_000);
400
+
401
+ // eslint-disable-next-line no-control-regex
402
+ const ansiRe =
403
+ /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
404
+ // eslint-disable-next-line no-control-regex
405
+ const ctrlRe = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
406
+
407
+ // Stream only the bytes appended since `offset` (incremental read,
408
+ // not a full re-read), driven by fs.watch for near-instant echo with
409
+ // a short fallback poll in case the watcher misses an event. The old
410
+ // 300 ms full-file poll was the dominant typing-echo latency.
411
+ const fh = await open(logPath, "r").catch(() => null);
412
+ let reading = false;
413
+ const flush = async () => {
414
+ if (closed || reading || !fh) return;
415
+ reading = true;
416
+ try {
417
+ const { size } = await fh.stat();
418
+ if (size < offset) offset = size; // truncated/rotated
419
+ if (size > offset) {
420
+ const len = size - offset;
421
+ const buf = Buffer.allocUnsafe(len);
422
+ const { bytesRead } = await fh.read(buf, 0, len, offset);
423
+ offset += bytesRead;
424
+ const chunk = buf.subarray(0, bytesRead);
425
+ if (raw) {
426
+ send(new TextDecoder().decode(chunk));
427
+ } else {
428
+ const text = new TextDecoder()
429
+ .decode(chunk)
430
+ .replace(ansiRe, "")
431
+ .replace(ctrlRe, "");
432
+ if (text.trim()) send(text.trimStart());
361
433
  }
362
- } catch {
363
- /* log gone */
364
- } finally {
365
- reading = false;
366
434
  }
367
- };
435
+ } catch {
436
+ /* log gone */
437
+ } finally {
438
+ reading = false;
439
+ }
440
+ };
441
+
442
+ let watcher: ReturnType<typeof watch> | null = null;
443
+ try {
444
+ watcher = watch(logPath, () => void flush());
445
+ } catch {
446
+ /* fs.watch unsupported — the fallback poll below still works */
447
+ }
448
+ const poller = setInterval(() => void flush(), 60);
368
449
 
369
- let watcher: ReturnType<typeof watch> | null = null;
450
+ req.signal.addEventListener("abort", () => {
451
+ closed = true;
452
+ clearInterval(heartbeat);
453
+ clearInterval(poller);
370
454
  try {
371
- watcher = watch(logPath, () => void flush());
455
+ watcher?.close();
372
456
  } catch {
373
- /* fs.watch unsupported — the fallback poll below still works */
457
+ /* already closed */
374
458
  }
375
- const poller = setInterval(() => void flush(), 60);
459
+ void fh?.close().catch(() => {});
460
+ try {
461
+ ctrl.close();
462
+ } catch {
463
+ /* already closed */
464
+ }
465
+ });
466
+ },
467
+ });
376
468
 
377
- req.signal.addEventListener("abort", () => {
378
- closed = true;
379
- clearInterval(heartbeat);
380
- clearInterval(poller);
381
- try {
382
- watcher?.close();
383
- } catch {
384
- /* already closed */
385
- }
386
- void fh?.close().catch(() => {});
387
- try {
388
- ctrl.close();
389
- } catch {
390
- /* already closed */
391
- }
392
- });
393
- },
394
- });
395
-
396
- return new Response(stream, {
397
- headers: {
398
- "Content-Type": "text/event-stream",
399
- "Cache-Control": "no-cache",
400
- Connection: "keep-alive",
401
- },
402
- });
403
- } catch (e) {
404
- return new Response((e as Error).message, { status: 404 });
405
- }
469
+ return new Response(stream, {
470
+ headers: {
471
+ "Content-Type": "text/event-stream",
472
+ "Cache-Control": "no-cache",
473
+ Connection: "keep-alive",
474
+ },
475
+ });
476
+ } catch (e) {
477
+ return new Response((e as Error).message, { status: 404 });
406
478
  }
479
+ }
407
480
 
408
- // POST /api/send body: {keyword, msg, code?}
409
- if (req.method === "POST" && p === "/api/send") {
410
- let body: { keyword: string; msg: string; code?: string };
411
- try {
412
- body = await req.json();
413
- } catch {
414
- return new Response("invalid JSON body", { status: 400 });
415
- }
416
- const { keyword, msg = "", code = "enter" } = body;
417
- if (!keyword || typeof keyword !== "string") {
418
- return new Response("missing keyword", { status: 400 });
419
- }
420
- try {
421
- const record = await resolveOne(keyword, defaultOpts());
422
- if (!record.fifo_file)
423
- return new Response(`pid ${record.pid}: no fifo_file`, { status: 409 });
424
- const trailing = controlCodeFromName(code.toLowerCase());
425
- if (msg && trailing) {
426
- await writeToIpc(record.fifo_file, msg);
427
- await new Promise((r) => setTimeout(r, 200));
428
- await writeToIpc(record.fifo_file, trailing);
429
- } else {
430
- await writeToIpc(record.fifo_file, msg + trailing);
431
- }
432
- return Response.json({ ok: true, pid: record.pid });
433
- } catch (e) {
434
- return new Response((e as Error).message, { status: 404 });
481
+ // POST /api/send body: {keyword, msg, code?}
482
+ if (req.method === "POST" && p === "/api/send") {
483
+ let body: { keyword: string; msg: string; code?: string };
484
+ try {
485
+ body = (await req.json()) as typeof body;
486
+ } catch {
487
+ return new Response("invalid JSON body", { status: 400 });
488
+ }
489
+ const { keyword, msg = "", code = "enter" } = body;
490
+ if (!keyword || typeof keyword !== "string") {
491
+ return new Response("missing keyword", { status: 400 });
492
+ }
493
+ try {
494
+ const record = await resolveOne(keyword, defaultOpts());
495
+ if (!record.fifo_file)
496
+ return new Response(`pid ${record.pid}: no fifo_file`, { status: 409 });
497
+ const trailing = controlCodeFromName(code.toLowerCase());
498
+ if (msg && trailing) {
499
+ await writeToIpc(record.fifo_file, msg);
500
+ await new Promise((r) => setTimeout(r, 200));
501
+ await writeToIpc(record.fifo_file, trailing);
502
+ } else {
503
+ await writeToIpc(record.fifo_file, msg + trailing);
435
504
  }
505
+ return Response.json({ ok: true, pid: record.pid });
506
+ } catch (e) {
507
+ return new Response((e as Error).message, { status: 404 });
436
508
  }
509
+ }
437
510
 
438
- // POST /api/resize/:keyword body {cols, rows} — drive the agent's PTY size.
439
- // Mirrors `ay attach`: write ~/.agent-yes/winsize/<pid> then SIGWINCH; the
440
- // agent's resize listener picks it up and reflows its TUI to that width.
441
- const resizeM = /^\/api\/resize\/(.+)$/.exec(p);
442
- if (req.method === "POST" && resizeM) {
443
- const keyword = decodeURIComponent(resizeM[1]!);
444
- let body: { cols?: number; rows?: number };
511
+ // POST /api/resize/:keyword body {cols, rows} — drive the agent's PTY size.
512
+ // Mirrors `ay attach`: write ~/.agent-yes/winsize/<pid> then SIGWINCH; the
513
+ // agent's resize listener picks it up and reflows its TUI to that width.
514
+ const resizeM = /^\/api\/resize\/(.+)$/.exec(p);
515
+ if (req.method === "POST" && resizeM) {
516
+ const keyword = decodeURIComponent(resizeM[1]!);
517
+ let body: { cols?: number; rows?: number };
518
+ try {
519
+ body = (await req.json()) as typeof body;
520
+ } catch {
521
+ return new Response("invalid JSON body", { status: 400 });
522
+ }
523
+ const cols = Math.max(1, Math.floor(Number(body.cols) || 0));
524
+ const rows = Math.max(1, Math.floor(Number(body.rows) || 0));
525
+ if (!cols || !rows) return new Response("missing cols/rows", { status: 400 });
526
+ try {
527
+ const record = await resolveOne(keyword, defaultOpts());
528
+ const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
529
+ const winsizeDir = path.join(ayHome, "winsize");
530
+ await mkdir(winsizeDir, { recursive: true });
531
+ await writeFile(
532
+ path.join(winsizeDir, String(record.pid)),
533
+ `${cols} ${rows} ${Date.now()}\n`,
534
+ );
445
535
  try {
446
- body = await req.json();
536
+ process.kill(record.pid, "SIGWINCH");
447
537
  } catch {
448
- return new Response("invalid JSON body", { status: 400 });
449
- }
450
- const cols = Math.max(1, Math.floor(Number(body.cols) || 0));
451
- const rows = Math.max(1, Math.floor(Number(body.rows) || 0));
452
- if (!cols || !rows) return new Response("missing cols/rows", { status: 400 });
453
- try {
454
- const record = await resolveOne(keyword, defaultOpts());
455
- const ayHome = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
456
- const winsizeDir = path.join(ayHome, "winsize");
457
- await mkdir(winsizeDir, { recursive: true });
458
- await writeFile(
459
- path.join(winsizeDir, String(record.pid)),
460
- `${cols} ${rows} ${Date.now()}\n`,
461
- );
462
- try {
463
- process.kill(record.pid, "SIGWINCH");
464
- } catch {
465
- /* agent gone */
466
- }
467
- return Response.json({ ok: true, pid: record.pid, cols, rows });
468
- } catch (e) {
469
- return new Response((e as Error).message, { status: 404 });
538
+ /* agent gone */
470
539
  }
540
+ return Response.json({ ok: true, pid: record.pid, cols, rows });
541
+ } catch (e) {
542
+ return new Response((e as Error).message, { status: 404 });
471
543
  }
544
+ }
472
545
 
473
- // POST /api/spawn body {cli, cwd, prompt} — launch a new agent
474
- if (req.method === "POST" && p === "/api/spawn") {
475
- let body: { cli?: string; cwd?: string; prompt?: string };
476
- try {
477
- body = await req.json();
478
- } catch {
479
- return new Response("invalid JSON body", { status: 400 });
480
- }
481
- const cli = String(body.cli ?? "claude");
482
- if (!SUPPORTED_CLIS.includes(cli as never))
483
- return new Response(`unsupported cli: ${cli}`, { status: 400 });
484
- const cwd = typeof body.cwd === "string" && body.cwd ? body.cwd : process.cwd();
485
- const prompt = String(body.prompt ?? "");
486
- process.stderr.write(
487
- `→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
488
- );
489
- try {
490
- const child = Bun.spawn(["ay", cli, ...(prompt ? ["--", prompt] : [])], {
491
- cwd,
492
- stdin: "ignore",
493
- stdout: "ignore",
494
- stderr: "ignore",
495
- });
496
- child.unref();
497
- return Response.json({ ok: true, pid: child.pid, cli, cwd });
498
- } catch (e) {
499
- return new Response((e as Error).message, { status: 500 });
500
- }
546
+ // POST /api/spawn body {cli, cwd, prompt} — launch a new agent
547
+ if (req.method === "POST" && p === "/api/spawn") {
548
+ let body: { cli?: string; cwd?: string; prompt?: string };
549
+ try {
550
+ body = (await req.json()) as typeof body;
551
+ } catch {
552
+ return new Response("invalid JSON body", { status: 400 });
501
553
  }
554
+ const cli = String(body.cli ?? "claude");
555
+ if (!SUPPORTED_CLIS.includes(cli as never))
556
+ return new Response(`unsupported cli: ${cli}`, { status: 400 });
557
+ const cwd = typeof body.cwd === "string" && body.cwd ? body.cwd : process.cwd();
558
+ const prompt = String(body.prompt ?? "");
559
+ process.stderr.write(
560
+ `→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
561
+ );
562
+ try {
563
+ const child = Bun.spawn(["ay", cli, ...(prompt ? ["--", prompt] : [])], {
564
+ cwd,
565
+ stdin: "ignore",
566
+ stdout: "ignore",
567
+ stderr: "ignore",
568
+ });
569
+ child.unref();
570
+ return Response.json({ ok: true, pid: child.pid, cli, cwd });
571
+ } catch (e) {
572
+ return new Response((e as Error).message, { status: 500 });
573
+ }
574
+ }
502
575
 
503
- return new Response("Not Found", { status: 404 });
504
- },
576
+ return new Response("Not Found", { status: 404 });
577
+ };
578
+
579
+ // Web console: the lab UI served straight from the package, so --http needs
580
+ // no separate proxy and no agent-yes.com. Static routes are unauthenticated
581
+ // (the page holds no secrets); the page carries the token via the #k= link
582
+ // and sends it on every /api call.
583
+ const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "lab", "ui");
584
+ const serveUiFile = async (name: string, type: string): Promise<Response> => {
585
+ try {
586
+ const buf = await readFile(path.join(uiDir, name));
587
+ return new Response(buf, { headers: { "Content-Type": type } });
588
+ } catch {
589
+ return new Response("UI assets not found in this install — use the /api endpoints", {
590
+ status: 404,
591
+ });
592
+ }
593
+ };
594
+ const httpFetch = async (req: Request): Promise<Response> => {
595
+ const p = new URL(req.url).pathname;
596
+ if (req.method === "GET" && (p === "/" || p === "/index.html"))
597
+ return serveUiFile("index.html", "text/html; charset=utf-8");
598
+ if (req.method === "GET" && p === "/room-client.js")
599
+ return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
600
+ if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
601
+ return apiFetch(req);
505
602
  };
506
603
 
604
+ const serverOpts: any = {
605
+ hostname: host,
606
+ port,
607
+ idleTimeout: 0, // never time out SSE/tail streams
608
+ fetch: httpFetch,
609
+ };
507
610
  if (useHttps) {
508
611
  serverOpts.tls = { cert: Bun.file(certPath!), key: Bun.file(keyPath!) };
509
612
  }
510
613
 
511
- const server = Bun.serve(serverOpts);
512
-
513
- process.stdout.write(`ay serve ${scheme}://${host}:${port}\n`);
514
- process.stdout.write(`token: ${token}\n\n`);
515
- process.stdout.write(`connect from another machine:\n`);
516
- process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
517
- process.stdout.write(` ay tail ${token}@<host>:${port}:<keyword>\n`);
518
- process.stdout.write(` ay send ${token}@<host>:${port}:<keyword> "message"\n\n`);
519
- process.stdout.write(`save as alias:\n`);
520
- process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${port}\n\n`);
521
- if (!useHttps) {
522
- process.stdout.write(
523
- `for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
524
- ` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'\n\n`,
525
- );
614
+ let server: ReturnType<typeof Bun.serve> | null = null;
615
+ if (wantHttp) {
616
+ try {
617
+ server = Bun.serve(serverOpts);
618
+ } catch (e) {
619
+ if ((e as { code?: string }).code === "EADDRINUSE") {
620
+ process.stderr.write(
621
+ `ay serve: port ${port} is already in use — pick another with --port N,\n` +
622
+ `or run a port-free WebRTC-only share with: ay serve --webrtc\n`,
623
+ );
624
+ return 1;
625
+ }
626
+ throw e;
627
+ }
628
+
629
+ const uiHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
630
+ process.stdout.write(`ay serve ${scheme}://${host}:${port}\n`);
631
+ process.stdout.write(`token: ${token}\n\n`);
632
+ process.stdout.write(`web console (token in the # is eaten on open):\n`);
633
+ process.stdout.write(` ${scheme}://${uiHost}:${port}/#k=${token}\n\n`);
634
+ process.stdout.write(`connect from another machine:\n`);
635
+ process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
636
+ process.stdout.write(` ay tail ${token}@<host>:${port}:<keyword>\n`);
637
+ process.stdout.write(` ay send ${token}@<host>:${port}:<keyword> "message"\n\n`);
638
+ process.stdout.write(`save as alias:\n`);
639
+ process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${port}\n\n`);
640
+ if (!useHttps) {
641
+ process.stdout.write(
642
+ `for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
643
+ ` openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'\n\n`,
644
+ );
645
+ }
526
646
  }
527
- // --share: bridge this local server to a WebRTC room so the agent-yes.com
528
- // console can reach it peer-to-peer. Bare flag mints a room; a webrtc:// value
529
- // joins an explicit one.
530
- if (argv.share !== undefined) {
647
+
648
+ // --webrtc / --share: bridge to a WebRTC room so the agent-yes.com console
649
+ // can reach this machine peer-to-peer. The bridge calls apiFetch in-process,
650
+ // so without --http no port is opened at all. Bare flag mints a room; a
651
+ // webrtc:// value joins an explicit one.
652
+ if (wantWebrtc) {
653
+ const webrtcVal = (argv.webrtc ?? argv.share) as string | undefined;
531
654
  const shareUrl =
532
- typeof argv.share === "string" && argv.share.startsWith("webrtc://") ? argv.share : undefined;
655
+ typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : undefined;
533
656
  try {
534
657
  const { startShare } = await import("./share.ts");
535
658
  const { link } = await startShare({
536
659
  url: shareUrl,
537
- apiUrl: `http://127.0.0.1:${port}`,
660
+ localFetch: apiFetch,
538
661
  apiToken: token,
539
662
  });
540
663
  process.stdout.write(
541
- `\nshared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n\n`,
664
+ `${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n\n`,
542
665
  );
543
666
  } catch (e) {
544
- process.stderr.write(`ay serve --share failed: ${(e as Error).message}\n`);
667
+ process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
668
+ if (!wantHttp) return 1; // nothing else is running
545
669
  }
546
670
  }
547
671
 
@@ -549,11 +673,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
549
673
 
550
674
  await new Promise<void>((resolve) => {
551
675
  process.on("SIGINT", () => {
552
- server.stop();
676
+ server?.stop();
553
677
  resolve();
554
678
  });
555
679
  process.on("SIGTERM", () => {
556
- server.stop();
680
+ server?.stop();
557
681
  resolve();
558
682
  });
559
683
  });