agent-yes 1.190.0 → 1.191.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ import "./ts-BQh1-ljE.js";
2
+ import "./logger-CDIsZ-Pp.js";
3
+ import "./versionChecker-DhCi7ncH.js";
4
+ import "./pidStore-BIvsBQ8X.js";
5
+ import "./globalPidIndex-CoNr7tS8.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-mUaQHk4M.js";
7
+
8
+ export { SUPPORTED_CLIS };
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-CeGEhsC9.js";
1
+ import { t as CLIS_CONFIG } from "./ts-BQh1-ljE.js";
2
2
 
3
3
  //#region ts/SUPPORTED_CLIS.ts
4
4
  const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
5
5
 
6
6
  //#endregion
7
7
  export { SUPPORTED_CLIS as t };
8
- //# sourceMappingURL=SUPPORTED_CLIS-BB7Br0fS.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-mUaQHk4M.js.map
@@ -1,7 +1,7 @@
1
1
  import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./configShared-0MnIQ652.js";
4
- import { j as resolveOne } from "./subcommands-Deaq1dOk.js";
4
+ import { j as resolveOne } from "./subcommands-CNCgJ48t.js";
5
5
  import "./e2e-BeKjLhmO.js";
6
6
  import "./webrtcLink-BG0Xc4-W.js";
7
7
  import "./remotes-CgT91bMo.js";
@@ -228,4 +228,4 @@ function transformEvent(rawEvent, agentId, forwarded) {
228
228
 
229
229
  //#endregion
230
230
  export { createScopedShare, listShares, revokeAllShares, revokeShare };
231
- //# sourceMappingURL=agentShare-BXfWzPJu.js.map
231
+ //# sourceMappingURL=agentShare-Boa9P2dK.js.map
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
3
3
  import { n as logger } from "./logger-CDIsZ-Pp.js";
4
- import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-DXwf5ip7.js";
5
- import { n as getRustBinary } from "./rustBinary-C8YZUZGT.js";
4
+ import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-DhCi7ncH.js";
5
+ import { n as getRustBinary } from "./rustBinary-Cu8q2m9s.js";
6
6
  import { argv } from "process";
7
7
  import { spawn } from "child_process";
8
8
  import ms from "ms";
@@ -285,7 +285,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
285
285
  const rawArg = process.argv[2];
286
286
  const managerCommands = !invokedCliName(process.argv);
287
287
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
288
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-D6TbKFTF.js");
288
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-Uerol8Tz.js");
289
289
  if (isHelpFlag && process.argv.length === 3) {
290
290
  await cmdHelp(managerCommands);
291
291
  process.exit(0);
@@ -327,7 +327,7 @@ if (config.useRust) {
327
327
  }
328
328
  }
329
329
  if (rustBinary) {
330
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-B729E086.js");
330
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-P3I0D3Ka.js");
331
331
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
332
332
  if (config.verbose) {
333
333
  console.log(`[rust] Using binary: ${rustBinary}`);
@@ -327,7 +327,7 @@ function exposuresPath() {
327
327
  mkdirSync(home, { recursive: true });
328
328
  return path.join(home, "exposures.json");
329
329
  }
330
- /** Stable id+key per (relay, port): re-running `ay expose 5173` keeps its URL. */
330
+ /** Stable id+key per (relay, port): re-exposing a port keeps its URL. */
331
331
  function loadOrCreateExposure(relayHost, port) {
332
332
  const file = exposuresPath();
333
333
  let all = {};
@@ -369,6 +369,139 @@ function wsTransport(ws) {
369
369
  onClose: (cb) => ws.addEventListener("close", () => cb())
370
370
  };
371
371
  }
372
+ /**
373
+ * Start (or fail) one exposure. Resolves once the relay has accepted the daemon
374
+ * and the tunnel is live; rejects if the relay refuses this exposure (bad key).
375
+ * Reconnects with backoff for the life of the handle.
376
+ */
377
+ function startExposure(opts) {
378
+ const relay = opts.relay ?? DEFAULT_RELAY;
379
+ const port = opts.port;
380
+ const log = opts.log ?? (() => {});
381
+ const relayUrl = new URL(relay);
382
+ const rec = loadOrCreateExposure(relayUrl.host, port);
383
+ const tunnelUrl = `${relayUrl.protocol === "http:" ? "ws:" : "wss:"}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
384
+ const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
385
+ const publicUrl = `https://${publicHost}/`;
386
+ let stopped = false;
387
+ let sock = null;
388
+ let ready = false;
389
+ let backoff = RECONNECT_MIN_MS;
390
+ const handle = {
391
+ id: rec.id,
392
+ port,
393
+ publicHost,
394
+ url: publicUrl,
395
+ relayHost: relayUrl.host,
396
+ createdAt: Date.now(),
397
+ mintClaim() {
398
+ const token = randomBytes(18).toString("base64url");
399
+ const hash = createHash("sha256").update(token).digest("hex");
400
+ if (sock && sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({
401
+ t: "claim",
402
+ claims: [hash]
403
+ }));
404
+ return `https://${publicHost}/_ay/claim?t=${token}`;
405
+ },
406
+ stop() {
407
+ stopped = true;
408
+ try {
409
+ sock?.close();
410
+ } catch {}
411
+ }
412
+ };
413
+ return new Promise((resolve, reject) => {
414
+ const connect = () => {
415
+ if (stopped) return;
416
+ sock = new WebSocket(tunnelUrl);
417
+ sock.binaryType = "arraybuffer";
418
+ const ws = sock;
419
+ let ping = null;
420
+ ws.addEventListener("open", () => {
421
+ ws.send(JSON.stringify({
422
+ t: "hello",
423
+ key: rec.key,
424
+ port,
425
+ v: 1
426
+ }));
427
+ });
428
+ ws.addEventListener("message", (ev) => {
429
+ if (typeof ev.data !== "string") return;
430
+ let msg;
431
+ try {
432
+ msg = JSON.parse(ev.data);
433
+ } catch {
434
+ return;
435
+ }
436
+ if (msg.t === "ready") {
437
+ backoff = RECONNECT_MIN_MS;
438
+ new TunnelHost(wsTransport(ws), { port });
439
+ ping = setInterval(() => {
440
+ if (ws.readyState === WebSocket.OPEN) ws.send("ping");
441
+ }, PING_MS);
442
+ if (!ready) {
443
+ ready = true;
444
+ log(`sharing 127.0.0.1:${port} at ${publicUrl}`);
445
+ resolve(handle);
446
+ } else log(`reconnected`);
447
+ }
448
+ });
449
+ ws.addEventListener("close", (ev) => {
450
+ if (ping) clearInterval(ping);
451
+ if (stopped) return;
452
+ if (ev.code === 1008) {
453
+ const err = /* @__PURE__ */ new Error(`relay refused exposure (${ev.reason || "forbidden"})`);
454
+ if (!ready) return reject(err);
455
+ log(err.message);
456
+ return;
457
+ }
458
+ log(`connection lost, retrying in ${Math.round(backoff / 1e3)}s…`);
459
+ setTimeout(connect, backoff);
460
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
461
+ });
462
+ ws.addEventListener("error", () => {});
463
+ };
464
+ connect();
465
+ });
466
+ }
467
+ const active = /* @__PURE__ */ new Map();
468
+ /** In-flight starts, so concurrent POSTs for the same port share one dial. */
469
+ const starting = /* @__PURE__ */ new Map();
470
+ /** Start an exposure for `port` (or reuse a running one). Idempotent per port. */
471
+ async function ensureExposure(port, relay) {
472
+ const existing = active.get(port);
473
+ if (existing) return existing;
474
+ const inflight = starting.get(port);
475
+ if (inflight) return inflight;
476
+ const p = startExposure({
477
+ port,
478
+ relay
479
+ }).then((h) => {
480
+ active.set(port, h);
481
+ starting.delete(port);
482
+ return h;
483
+ }).catch((e) => {
484
+ starting.delete(port);
485
+ throw e;
486
+ });
487
+ starting.set(port, p);
488
+ return p;
489
+ }
490
+ function listExposures() {
491
+ return [...active.values()].sort((a, b) => b.createdAt - a.createdAt).map((h) => ({
492
+ id: h.id,
493
+ port: h.port,
494
+ url: h.url,
495
+ createdAt: h.createdAt
496
+ }));
497
+ }
498
+ function stopExposure(port) {
499
+ const h = active.get(port);
500
+ if (!h) return false;
501
+ h.stop();
502
+ active.delete(port);
503
+ return true;
504
+ }
372
505
  async function cmdExpose(args) {
373
506
  let relay = DEFAULT_RELAY;
374
507
  let port = 0;
@@ -386,74 +519,24 @@ async function cmdExpose(args) {
386
519
  console.error("usage: ay expose <port> [--relay https://…] (see ay expose --help)");
387
520
  return 1;
388
521
  }
389
- const relayUrl = new URL(relay);
390
- const rec = loadOrCreateExposure(relayUrl.host, port);
391
- const tunnelUrl = `${relayUrl.protocol === "http:" ? "ws:" : "wss:"}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
392
- const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
393
- const claimToken = randomBytes(18).toString("base64url");
394
- const claimHash = createHash("sha256").update(claimToken).digest("hex");
395
- let stopped = false;
396
- let ws = null;
397
- let backoff = RECONNECT_MIN_MS;
398
- let announced = false;
399
- const connect = () => {
400
- if (stopped) return;
401
- ws = new WebSocket(tunnelUrl);
402
- ws.binaryType = "arraybuffer";
403
- const sock = ws;
404
- let ping = null;
405
- sock.addEventListener("open", () => {
406
- sock.send(JSON.stringify({
407
- t: "hello",
408
- key: rec.key,
409
- port,
410
- claims: [claimHash],
411
- v: 1
412
- }));
413
- });
414
- sock.addEventListener("message", (ev) => {
415
- if (typeof ev.data !== "string") return;
416
- let msg;
417
- try {
418
- msg = JSON.parse(ev.data);
419
- } catch {
420
- return;
421
- }
422
- if (msg.t === "ready") {
423
- backoff = RECONNECT_MIN_MS;
424
- new TunnelHost(wsTransport(sock), { port });
425
- ping = setInterval(() => {
426
- if (sock.readyState === WebSocket.OPEN) sock.send("ping");
427
- }, PING_MS);
428
- if (!announced) {
429
- announced = true;
430
- console.log(`[ay expose] sharing 127.0.0.1:${port}`);
431
- console.log(` url: https://${publicHost}/`);
432
- console.log(` claim: https://${publicHost}/_ay/claim?t=${claimToken}`);
433
- console.log(` (one-time link — opens access for 8h in that browser)`);
434
- } else console.log(`[ay expose] reconnected`);
435
- }
436
- });
437
- sock.addEventListener("close", (ev) => {
438
- if (ping) clearInterval(ping);
439
- if (stopped) return;
440
- if (ev.code === 1008) {
441
- console.error(`[ay expose] relay refused this exposure (${ev.reason || "forbidden"}) — giving up`);
442
- process.exit(1);
443
- }
444
- console.log(`[ay expose] connection lost, retrying in ${Math.round(backoff / 1e3)}s…`);
445
- setTimeout(connect, backoff);
446
- backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
522
+ let handle;
523
+ try {
524
+ handle = await startExposure({
525
+ port,
526
+ relay,
527
+ log: (m) => console.log(`[ay expose] ${m}`)
447
528
  });
448
- sock.addEventListener("error", () => {});
449
- };
450
- connect();
529
+ } catch (e) {
530
+ console.error(`[ay expose] ${e.message} — giving up`);
531
+ return 1;
532
+ }
533
+ const claimUrl = handle.mintClaim();
534
+ console.log(` url: ${handle.url}`);
535
+ console.log(` claim: ${claimUrl}`);
536
+ console.log(` (one-time link — opens access for 8h in that browser)`);
451
537
  const shutdown = () => {
452
- stopped = true;
453
538
  console.log("\n[ay expose] stopped — the URL now answers 502 until you expose again");
454
- try {
455
- ws?.close();
456
- } catch {}
539
+ handle.stop();
457
540
  process.exit(0);
458
541
  };
459
542
  process.on("SIGINT", shutdown);
@@ -462,5 +545,5 @@ async function cmdExpose(args) {
462
545
  }
463
546
 
464
547
  //#endregion
465
- export { cmdExpose };
466
- //# sourceMappingURL=expose-DUYH0tvh.js.map
548
+ export { cmdExpose, ensureExposure, listExposures, stopExposure };
549
+ //# sourceMappingURL=expose-B3AGhVhM.js.map
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-CeGEhsC9.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-BQh1-ljE.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-DXwf5ip7.js";
3
+ import "./versionChecker-DhCi7ncH.js";
4
4
  import "./pidStore-BIvsBQ8X.js";
5
5
  import "./globalPidIndex-CoNr7tS8.js";
6
6
 
@@ -1,7 +1,7 @@
1
1
  import { n as logger } from "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./configShared-0MnIQ652.js";
4
- import { G as hostId, J as readInbox, K as listInboxParents, O as renderLogTailLines, Q as notifyDir, S as listRecords, U as appendEvent, W as gcInboxes, X as daemonLockDir, Y as shouldStealLock, Z as daemonLockOwnerPath, _ as isPidAlive, c as deriveLiveState, q as liveWatchers } from "./subcommands-Deaq1dOk.js";
4
+ import { G as hostId, J as readInbox, K as listInboxParents, O as renderLogTailLines, Q as notifyDir, S as listRecords, U as appendEvent, W as gcInboxes, X as daemonLockDir, Y as shouldStealLock, Z as daemonLockOwnerPath, _ as isPidAlive, c as deriveLiveState, q as liveWatchers } from "./subcommands-CNCgJ48t.js";
5
5
  import "./e2e-BeKjLhmO.js";
6
6
  import "./webrtcLink-BG0Xc4-W.js";
7
7
  import "./remotes-CgT91bMo.js";
@@ -588,4 +588,4 @@ async function ensureDaemon() {
588
588
 
589
589
  //#endregion
590
590
  export { daemonStatus, ensureDaemon, requestDaemonStop, runDaemon };
591
- //# sourceMappingURL=notifyDaemon-APkkoCzC.js.map
591
+ //# sourceMappingURL=notifyDaemon-hKT38cBs.js.map
@@ -1,4 +1,4 @@
1
- import { r as getInstalledPackage } from "./versionChecker-DXwf5ip7.js";
1
+ import { r as getInstalledPackage } from "./versionChecker-DhCi7ncH.js";
2
2
  import { execFileSync } from "child_process";
3
3
  import { existsSync, mkdirSync, unlinkSync } from "fs";
4
4
  import { chmod, copyFile } from "fs/promises";
@@ -225,4 +225,4 @@ async function getRustBinary(options = {}) {
225
225
 
226
226
  //#endregion
227
227
  export { getRustBinary as n, findSpawnHiddenLauncher as t };
228
- //# sourceMappingURL=rustBinary-C8YZUZGT.js.map
228
+ //# sourceMappingURL=rustBinary-Cu8q2m9s.js.map
@@ -1,9 +1,9 @@
1
- import "./ts-CeGEhsC9.js";
1
+ import "./ts-BQh1-ljE.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-DXwf5ip7.js";
3
+ import "./versionChecker-DhCi7ncH.js";
4
4
  import "./pidStore-BIvsBQ8X.js";
5
5
  import "./globalPidIndex-CoNr7tS8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BB7Br0fS.js";
6
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-mUaQHk4M.js";
7
7
  import { d as resolveSpawnCwd } from "./workspaceConfig-_GtAZtsi.js";
8
8
  import { createHash } from "node:crypto";
9
9
 
@@ -141,4 +141,4 @@ async function cmdSchedule(rest) {
141
141
 
142
142
  //#endregion
143
143
  export { cmdSchedule };
144
- //# sourceMappingURL=schedule-BYG6MGpH.js.map
144
+ //# sourceMappingURL=schedule-Bb98iG9W.js.map
@@ -1,13 +1,13 @@
1
- import "./ts-CeGEhsC9.js";
1
+ import "./ts-BQh1-ljE.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import { r as getInstalledPackage } from "./versionChecker-DXwf5ip7.js";
4
- import { t as findSpawnHiddenLauncher } from "./rustBinary-C8YZUZGT.js";
3
+ import { r as getInstalledPackage } from "./versionChecker-DhCi7ncH.js";
4
+ import { t as findSpawnHiddenLauncher } from "./rustBinary-Cu8q2m9s.js";
5
5
  import "./pidStore-BIvsBQ8X.js";
6
6
  import { a as updateGlobalPidStatus } from "./globalPidIndex-CoNr7tS8.js";
7
7
  import { t as pgidForWrapper } from "./reaper-CWF2_ATd.js";
8
8
  import "./configShared-0MnIQ652.js";
9
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BB7Br0fS.js";
10
- import { $ as TYPING_BADGE, D as recentReadEdges, E as readPtysize, H as writeToIpc, I as snapshotStatus, O as renderLogTailLines, S as listRecords, T as readNotes, b as isUserTyping, f as extractNeedsInput, j as resolveOne, k as renderRawLog, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, u as extractBadges } from "./subcommands-Deaq1dOk.js";
9
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-mUaQHk4M.js";
10
+ import { $ as TYPING_BADGE, D as recentReadEdges, E as readPtysize, H as writeToIpc, I as snapshotStatus, O as renderLogTailLines, S as listRecords, T as readNotes, b as isUserTyping, f as extractNeedsInput, j as resolveOne, k as renderRawLog, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, u as extractBadges } from "./subcommands-CNCgJ48t.js";
11
11
  import "./e2e-BeKjLhmO.js";
12
12
  import "./webrtcLink-BG0Xc4-W.js";
13
13
  import "./remotes-CgT91bMo.js";
@@ -1851,7 +1851,7 @@ Options:
1851
1851
  const perm = body.perm ?? "r";
1852
1852
  if (perm !== "r" && perm !== "rw") return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
1853
1853
  try {
1854
- const { createScopedShare } = await import("./agentShare-BXfWzPJu.js");
1854
+ const { createScopedShare } = await import("./agentShare-Boa9P2dK.js");
1855
1855
  const share = await createScopedShare({
1856
1856
  agent: body.agent,
1857
1857
  perm,
@@ -1866,15 +1866,48 @@ Options:
1866
1866
  }
1867
1867
  }
1868
1868
  if (req.method === "GET" && p === "/api/shares") {
1869
- const { listShares } = await import("./agentShare-BXfWzPJu.js");
1869
+ const { listShares } = await import("./agentShare-Boa9P2dK.js");
1870
1870
  return Response.json(listShares());
1871
1871
  }
1872
1872
  const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
1873
1873
  if (req.method === "DELETE" && revokeM) {
1874
- const { revokeShare } = await import("./agentShare-BXfWzPJu.js");
1874
+ const { revokeShare } = await import("./agentShare-Boa9P2dK.js");
1875
1875
  const ok = revokeShare(decodeURIComponent(revokeM[1]));
1876
1876
  return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
1877
1877
  }
1878
+ if (req.method === "POST" && p === "/api/expose") {
1879
+ let body;
1880
+ try {
1881
+ body = await req.json();
1882
+ } catch {
1883
+ return new Response("invalid JSON body", { status: 400 });
1884
+ }
1885
+ const port = Number(body.port);
1886
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return new Response("valid port required", { status: 400 });
1887
+ try {
1888
+ const { ensureExposure } = await import("./expose-B3AGhVhM.js");
1889
+ const h = await ensureExposure(port, body.relay);
1890
+ return Response.json({
1891
+ id: h.id,
1892
+ port: h.port,
1893
+ url: h.url,
1894
+ claim: h.mintClaim(),
1895
+ createdAt: h.createdAt
1896
+ });
1897
+ } catch (e) {
1898
+ return new Response(`expose failed: ${e.message}`, { status: 502 });
1899
+ }
1900
+ }
1901
+ if (req.method === "GET" && p === "/api/exposes") {
1902
+ const { listExposures } = await import("./expose-B3AGhVhM.js");
1903
+ return Response.json(listExposures());
1904
+ }
1905
+ const unexposeM = /^\/api\/expose\/(\d+)$/.exec(p);
1906
+ if (req.method === "DELETE" && unexposeM) {
1907
+ const { stopExposure } = await import("./expose-B3AGhVhM.js");
1908
+ const ok = stopExposure(Number(unexposeM[1]));
1909
+ return new Response(ok ? "revoked" : "no such exposure", { status: ok ? 200 : 404 });
1910
+ }
1878
1911
  return new Response("Not Found", { status: 404 });
1879
1912
  };
1880
1913
  const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "lab", "ui");
@@ -1912,8 +1945,11 @@ Options:
1912
1945
  if (req.method === "GET" && p === "/r/main.js.map") return serveUiFile("rgui/dist/main.js.map", "application/json");
1913
1946
  if (req.method === "GET" && p === "/room-client.js") return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
1914
1947
  if (req.method === "GET" && p === "/console-logic.js") return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
1948
+ if (req.method === "GET" && p === "/rtc.js") return serveUiFile("rtc.js", "text/javascript; charset=utf-8");
1915
1949
  if (req.method === "GET" && p === "/e2e.js") return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
1916
1950
  if (req.method === "GET" && p === "/qrcode.js") return serveUiFile("qrcode.js", "text/javascript; charset=utf-8");
1951
+ if (req.method === "GET" && p === "/manifest.webmanifest") return serveUiFile("manifest.webmanifest", "application/manifest+json");
1952
+ if (req.method === "GET" && p === "/icon.svg") return serveUiFile("icon.svg", "image/svg+xml");
1917
1953
  if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
1918
1954
  return apiFetch(req);
1919
1955
  };
@@ -2016,7 +2052,7 @@ Options:
2016
2052
  const shutdown = (resolve) => {
2017
2053
  if (heartbeat) clearInterval(heartbeat);
2018
2054
  closeShare?.();
2019
- import("./agentShare-BXfWzPJu.js").then((m) => m.revokeAllShares()).catch(() => {});
2055
+ import("./agentShare-Boa9P2dK.js").then((m) => m.revokeAllShares()).catch(() => {});
2020
2056
  server?.stop();
2021
2057
  resolve();
2022
2058
  };
@@ -2029,4 +2065,4 @@ Options:
2029
2065
 
2030
2066
  //#endregion
2031
2067
  export { cmdServe };
2032
- //# sourceMappingURL=serve-Df3wcUU-.js.map
2068
+ //# sourceMappingURL=serve-DVhbtjR4.js.map
@@ -32,7 +32,7 @@ async function cmdSetup(rest) {
32
32
  if (!existsSync(abs)) process.stderr.write(` note: that directory doesn't exist yet — create it, or agents spawned there will fail\n`);
33
33
  if (noShare) return 0;
34
34
  process.stdout.write(`\nsharing this machine to agent-yes.com…\n`);
35
- const { cmdServe } = await import("./serve-Df3wcUU-.js");
35
+ const { cmdServe } = await import("./serve-DVhbtjR4.js");
36
36
  return cmdServe([
37
37
  "install",
38
38
  "--share",
@@ -42,4 +42,4 @@ async function cmdSetup(rest) {
42
42
 
43
43
  //#endregion
44
44
  export { cmdSetup };
45
- //# sourceMappingURL=setup-C65eaHZ8.js.map
45
+ //# sourceMappingURL=setup-Dj8Grg0K.js.map
@@ -1146,15 +1146,15 @@ async function runSubcommand(argv) {
1146
1146
  case "restart": return await cmdRestart(rest);
1147
1147
  case "note": return await cmdNote(rest);
1148
1148
  case "serve": {
1149
- const { cmdServe } = await import("./serve-Df3wcUU-.js");
1149
+ const { cmdServe } = await import("./serve-DVhbtjR4.js");
1150
1150
  return cmdServe(rest);
1151
1151
  }
1152
1152
  case "setup": {
1153
- const { cmdSetup } = await import("./setup-C65eaHZ8.js");
1153
+ const { cmdSetup } = await import("./setup-Dj8Grg0K.js");
1154
1154
  return cmdSetup(rest);
1155
1155
  }
1156
1156
  case "schedule": {
1157
- const { cmdSchedule } = await import("./schedule-BYG6MGpH.js");
1157
+ const { cmdSchedule } = await import("./schedule-Bb98iG9W.js");
1158
1158
  return cmdSchedule(rest);
1159
1159
  }
1160
1160
  case "remote": {
@@ -1162,7 +1162,7 @@ async function runSubcommand(argv) {
1162
1162
  return cmdRemote(rest);
1163
1163
  }
1164
1164
  case "expose": {
1165
- const { cmdExpose } = await import("./expose-DUYH0tvh.js");
1165
+ const { cmdExpose } = await import("./expose-B3AGhVhM.js");
1166
1166
  return cmdExpose(rest);
1167
1167
  }
1168
1168
  case "reap":
@@ -3996,7 +3996,7 @@ async function cmdNotify(rest) {
3996
3996
  }
3997
3997
  const ensure = async () => {
3998
3998
  if (!argv["ensure-daemon"]) return;
3999
- const { ensureDaemon } = await import("./notifyDaemon-APkkoCzC.js");
3999
+ const { ensureDaemon } = await import("./notifyDaemon-hKT38cBs.js");
4000
4000
  await ensureDaemon().catch(() => null);
4001
4001
  };
4002
4002
  await heartbeatWatcher(parent, selfStartedAt);
@@ -4070,7 +4070,7 @@ async function cmdNotifyCursor(args) {
4070
4070
  }
4071
4071
  async function cmdNotifyd(rest) {
4072
4072
  const sub = rest[0] ?? "status";
4073
- const daemon = await import("./notifyDaemon-APkkoCzC.js");
4073
+ const daemon = await import("./notifyDaemon-hKT38cBs.js");
4074
4074
  switch (sub) {
4075
4075
  case "run": return daemon.runDaemon();
4076
4076
  case "once": return daemon.runDaemon({ once: true });
@@ -4097,4 +4097,4 @@ async function cmdNotifyd(rest) {
4097
4097
 
4098
4098
  //#endregion
4099
4099
  export { TYPING_BADGE as $, renderRawLogLines as A, waitForLogQuiet as B, matchKeyword as C, recentReadEdges as D, readPtysize as E, runSubcommand as F, hostId as G, writeToIpc as H, snapshotStatus as I, readInbox as J, listInboxParents as K, stdinActivityPath as L, resolveReadWindow as M, resolveResumeArgs as N, renderLogTailLines as O, restartHintLines as P, notifyDir as Q, stopTipForCli as R, listRecords as S, readNotes as T, appendEvent as U, writeKeysPaced as V, gcInboxes as W, daemonLockDir as X, shouldStealLock as Y, daemonLockOwnerPath as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, resolveOne as j, renderRawLog as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, liveWatchers as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, submitAndConfirm as z };
4100
- //# sourceMappingURL=subcommands-Deaq1dOk.js.map
4100
+ //# sourceMappingURL=subcommands-CNCgJ48t.js.map
@@ -1,7 +1,7 @@
1
1
  import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./configShared-0MnIQ652.js";
4
- import { A as renderRawLogLines, B as waitForLogQuiet, C as matchKeyword, D as recentReadEdges, E as readPtysize, F as runSubcommand, H as writeToIpc, I as snapshotStatus, L as stdinActivityPath, M as resolveReadWindow, N as resolveResumeArgs, O as renderLogTailLines, P as restartHintLines, R as stopTipForCli, S as listRecords, T as readNotes, V as writeKeysPaced, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as resolveOne, k as renderRawLog, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as submitAndConfirm } from "./subcommands-Deaq1dOk.js";
4
+ import { A as renderRawLogLines, B as waitForLogQuiet, C as matchKeyword, D as recentReadEdges, E as readPtysize, F as runSubcommand, H as writeToIpc, I as snapshotStatus, L as stdinActivityPath, M as resolveReadWindow, N as resolveResumeArgs, O as renderLogTailLines, P as restartHintLines, R as stopTipForCli, S as listRecords, T as readNotes, V as writeKeysPaced, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as resolveOne, k as renderRawLog, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as submitAndConfirm } from "./subcommands-CNCgJ48t.js";
5
5
  import "./e2e-BeKjLhmO.js";
6
6
  import "./webrtcLink-BG0Xc4-W.js";
7
7
  import "./remotes-CgT91bMo.js";
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
2
- import { r as getInstalledPackage } from "./versionChecker-DXwf5ip7.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-DhCi7ncH.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CNMl13dC.js";
5
5
  import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
@@ -1824,4 +1824,4 @@ function sleep(ms) {
1824
1824
 
1825
1825
  //#endregion
1826
1826
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1827
- //# sourceMappingURL=ts-CeGEhsC9.js.map
1827
+ //# sourceMappingURL=ts-BQh1-ljE.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.190.0";
10
+ var version = "1.191.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-DXwf5ip7.js.map
218
+ //# sourceMappingURL=versionChecker-DhCi7ncH.js.map
package/lab/ui/index.html CHANGED
@@ -352,6 +352,46 @@
352
352
  .app.steer-agent #shareAgentRW {
353
353
  display: none !important;
354
354
  }
355
+ /* Expose modal — ports manager list rows. */
356
+ .exposeList {
357
+ margin: 10px 0;
358
+ display: flex;
359
+ flex-direction: column;
360
+ gap: 6px;
361
+ max-height: 40vh;
362
+ overflow-y: auto;
363
+ }
364
+ .exposerow {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 8px;
368
+ border: 1px solid var(--line);
369
+ border-radius: 8px;
370
+ padding: 7px 9px;
371
+ }
372
+ .exposerow .exmeta {
373
+ flex: 1;
374
+ min-width: 0;
375
+ }
376
+ .exposerow .explabel {
377
+ font-weight: 600;
378
+ }
379
+ .exposerow .exurl {
380
+ font-size: 11px;
381
+ opacity: 0.7;
382
+ overflow: hidden;
383
+ text-overflow: ellipsis;
384
+ white-space: nowrap;
385
+ }
386
+ .exposerow .exhost {
387
+ font-size: 10px;
388
+ opacity: 0.55;
389
+ }
390
+ .exposePrompt {
391
+ border-bottom: 1px solid var(--line);
392
+ padding-bottom: 10px;
393
+ margin-bottom: 4px;
394
+ }
355
395
  /* Share modal — QR + link for a single-agent view-only share. */
356
396
  .modal-backdrop {
357
397
  position: fixed;
@@ -1685,6 +1725,9 @@
1685
1725
  <button id="foldbtn" class="viewbtn" title="fold subagent trees">⊞ subs</button>
1686
1726
  <button id="sortbtn" class="viewbtn" title="cycle sort order">⇅ state</button>
1687
1727
  <button id="viewbtn" class="viewbtn" title="toggle compact list">☰</button>
1728
+ <button id="portsbtn" class="viewbtn" title="manage exposed localhost ports">
1729
+ ⇄ ports
1730
+ </button>
1688
1731
  <button id="rguibtn" class="viewbtn" title="open the agent graph view (/r/) for this fleet">
1689
1732
  ⌗ graph
1690
1733
  </button>
@@ -1890,6 +1933,32 @@
1890
1933
  </div>
1891
1934
  </div>
1892
1935
 
1936
+ <!-- Expose modal: prompt to publish a clicked localhost:PORT through
1937
+ agent-yes.com, plus the ports manager (list + revoke active exposures). -->
1938
+ <div class="modal-backdrop" id="exposeModal" hidden>
1939
+ <div class="sharebox" role="dialog" aria-modal="true" aria-label="Expose localhost port">
1940
+ <h3 id="exposeTitle">Exposed ports</h3>
1941
+ <div class="exposePrompt" id="exposePrompt" hidden>
1942
+ <p class="sub" id="exposePromptSub">Publish this local server on the internet?</p>
1943
+ <p class="note">
1944
+ A private link on <strong>agent-yes.com</strong> will tunnel to this port on the agent's
1945
+ machine. Only someone who opens the one-time claim link (which opens automatically for
1946
+ you) can reach it. Revoke anytime below.
1947
+ </p>
1948
+ <div class="srow">
1949
+ <button class="primary" id="exposeGo" type="button">Expose &amp; open</button>
1950
+ <button id="exposeCancel" type="button">Cancel</button>
1951
+ </div>
1952
+ </div>
1953
+ <div class="exposeList" id="exposeList">
1954
+ <p class="sub" id="exposeEmpty">No ports are exposed right now.</p>
1955
+ </div>
1956
+ <div class="srow">
1957
+ <button id="exposeClose" type="button">Close</button>
1958
+ </div>
1959
+ </div>
1960
+ </div>
1961
+
1893
1962
  <!-- Cmd/Ctrl+K omnibox: search agents by title (instant) then output (tail),
1894
1963
  or spawn a new agent in the highlighted agent's cwd with the typed prompt. -->
1895
1964
  <div class="omni" id="omni" style="display: none">
@@ -3242,6 +3311,157 @@
3242
3311
  if (modal) modal.hidden = true;
3243
3312
  pendingRw = null; // a cancelled read-write flow never minted anything
3244
3313
  }
3314
+
3315
+ // ---- Port exposure: publish a clicked localhost:PORT through agent-yes.com ----
3316
+
3317
+ // Recognise a local-loopback URL and pull its port (default 80). Only these
3318
+ // are offered for exposure — a real public URL just opens normally.
3319
+ function localhostPort(uri) {
3320
+ let u;
3321
+ try {
3322
+ u = new URL(uri);
3323
+ } catch {
3324
+ return null;
3325
+ }
3326
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
3327
+ const h = u.hostname;
3328
+ if (h !== "localhost" && h !== "127.0.0.1" && h !== "0.0.0.0" && h !== "::1") return null;
3329
+ const port = Number(u.port) || (u.protocol === "https:" ? 443 : 80);
3330
+ return port >= 1 && port <= 65535 ? port : null;
3331
+ }
3332
+
3333
+ // The tx the expose prompt should call (the daemon whose terminal was clicked).
3334
+ let exposePendingTx = null;
3335
+ let exposePendingPort = 0;
3336
+
3337
+ // Called by the terminal link handler when a localhost URL is clicked.
3338
+ function promptExpose(port, tx) {
3339
+ exposePendingTx = tx || localTx;
3340
+ exposePendingPort = port;
3341
+ $("exposeTitle").textContent = "Expose localhost:" + port + "?";
3342
+ $("exposePromptSub").textContent =
3343
+ "Publish this local server (port " + port + ") on the internet via agent-yes.com?";
3344
+ $("exposePrompt").hidden = false;
3345
+ openExposeManager(false);
3346
+ }
3347
+
3348
+ // Open the ports manager (list of active exposures across every host).
3349
+ function openExposeManager(resetPrompt) {
3350
+ if (resetPrompt !== false) {
3351
+ $("exposePrompt").hidden = true;
3352
+ $("exposeTitle").textContent = "Exposed ports";
3353
+ }
3354
+ const modal = $("exposeModal");
3355
+ if (modal) modal.hidden = false;
3356
+ refreshExposeList();
3357
+ }
3358
+ function closeExposeModal() {
3359
+ const modal = $("exposeModal");
3360
+ if (modal) modal.hidden = true;
3361
+ exposePendingTx = null;
3362
+ exposePendingPort = 0;
3363
+ }
3364
+
3365
+ // Agree → mint the exposure on its host and open the claim link (which sets
3366
+ // the 8h cookie and redirects to the app), then refresh the manager list.
3367
+ async function doExpose() {
3368
+ const tx = exposePendingTx || localTx;
3369
+ const port = exposePendingPort;
3370
+ $("exposePrompt").hidden = true;
3371
+ if (!port) return;
3372
+ let info = null;
3373
+ try {
3374
+ const r = await tx.post("/api/expose", { port });
3375
+ if (r.ok) info = JSON.parse(r.text);
3376
+ } catch {}
3377
+ if (!info || !info.claim) {
3378
+ $("exposeEmpty").hidden = false;
3379
+ $("exposeEmpty").textContent = "Couldn't expose port " + port + " (is `ay serve` reachable?).";
3380
+ return;
3381
+ }
3382
+ window.open(info.claim, "_blank", "noopener,noreferrer");
3383
+ refreshExposeList();
3384
+ }
3385
+
3386
+ // Gather active exposures from every live host and render the manager rows.
3387
+ async function refreshExposeList() {
3388
+ const list = $("exposeList");
3389
+ if (!list) return;
3390
+ const rows = [];
3391
+ await Promise.all(
3392
+ [...sources.values()].map(async (s) => {
3393
+ const tx = s.tx || (s.id === "local" ? localTx : null);
3394
+ if (!tx) return;
3395
+ let arr;
3396
+ try {
3397
+ arr = await tx.fetchJSON("/api/exposes");
3398
+ } catch {
3399
+ return;
3400
+ }
3401
+ if (Array.isArray(arr))
3402
+ for (const ex of arr) rows.push({ ...ex, _srcName: s.name || s.id, _tx: tx });
3403
+ }),
3404
+ );
3405
+ list.textContent = "";
3406
+ if (!rows.length) {
3407
+ const p = document.createElement("p");
3408
+ p.className = "sub";
3409
+ p.id = "exposeEmpty";
3410
+ p.textContent = "No ports are exposed right now.";
3411
+ list.appendChild(p);
3412
+ return;
3413
+ }
3414
+ rows.sort((a, b) => b.createdAt - a.createdAt);
3415
+ for (const ex of rows) list.appendChild(renderExposeRow(ex));
3416
+ }
3417
+
3418
+ function renderExposeRow(ex) {
3419
+ const row = document.createElement("div");
3420
+ row.className = "exposerow";
3421
+ const meta = document.createElement("div");
3422
+ meta.className = "exmeta";
3423
+ const label = document.createElement("div");
3424
+ label.className = "explabel";
3425
+ label.textContent = "localhost:" + ex.port;
3426
+ const url = document.createElement("div");
3427
+ url.className = "exurl";
3428
+ url.textContent = ex.url;
3429
+ const host = document.createElement("div");
3430
+ host.className = "exhost";
3431
+ host.textContent = ex._srcName;
3432
+ meta.appendChild(label);
3433
+ meta.appendChild(url);
3434
+ meta.appendChild(host);
3435
+ row.appendChild(meta);
3436
+
3437
+ const openBtn = document.createElement("button");
3438
+ openBtn.className = "primary";
3439
+ openBtn.textContent = "Open";
3440
+ // Re-mint a fresh claim so the owner (or a guest) gets a usable link.
3441
+ openBtn.onclick = async () => {
3442
+ try {
3443
+ const r = await ex._tx.post("/api/expose", { port: ex.port });
3444
+ const info = r.ok ? JSON.parse(r.text) : null;
3445
+ window.open(info && info.claim ? info.claim : ex.url, "_blank", "noopener,noreferrer");
3446
+ } catch {
3447
+ window.open(ex.url, "_blank", "noopener,noreferrer");
3448
+ }
3449
+ };
3450
+ row.appendChild(openBtn);
3451
+
3452
+ const revoke = document.createElement("button");
3453
+ revoke.className = "danger";
3454
+ revoke.textContent = "Revoke";
3455
+ revoke.onclick = async () => {
3456
+ revoke.disabled = true;
3457
+ try {
3458
+ await ex._tx.del("/api/expose/" + ex.port);
3459
+ } catch {}
3460
+ refreshExposeList();
3461
+ };
3462
+ row.appendChild(revoke);
3463
+ return row;
3464
+ }
3245
3465
  // Draw the QR onto a fresh canvas (white quiet-zone always, so it scans in
3246
3466
  // dark mode too). `qrcode` is the vendored global from qrcode.js.
3247
3467
  function renderQr(container, text) {
@@ -3291,6 +3511,18 @@
3291
3511
  currentShare = null;
3292
3512
  closeShareModal();
3293
3513
  }
3514
+ (function exposeModalBoot() {
3515
+ $("portsbtn")?.addEventListener("click", () => openExposeManager(true));
3516
+ $("exposeGo")?.addEventListener("click", doExpose);
3517
+ $("exposeCancel")?.addEventListener("click", () => {
3518
+ $("exposePrompt").hidden = true;
3519
+ $("exposeTitle").textContent = "Exposed ports";
3520
+ });
3521
+ $("exposeClose")?.addEventListener("click", closeExposeModal);
3522
+ $("exposeModal")?.addEventListener("click", (e) => {
3523
+ if (e.target === $("exposeModal")) closeExposeModal();
3524
+ });
3525
+ })();
3294
3526
  (function shareModalBoot() {
3295
3527
  $("shareClose")?.addEventListener("click", closeShareModal);
3296
3528
  $("shareCloseAlt")?.addEventListener("click", closeShareModal);
@@ -4256,9 +4488,13 @@
4256
4488
  // new tab (noopener so the page can't be tampered with via window.opener).
4257
4489
  try {
4258
4490
  term.loadAddon(
4259
- new WebLinksAddon.WebLinksAddon((e, uri) =>
4260
- window.open(uri, "_blank", "noopener,noreferrer"),
4261
- ),
4491
+ new WebLinksAddon.WebLinksAddon((ev, uri) => {
4492
+ // A localhost URL isn't reachable from the viewer's browser — offer
4493
+ // to publish it through agent-yes.com (on THIS agent's host: txFor(e)).
4494
+ const port = localhostPort(uri);
4495
+ if (port) return promptExpose(port, txFor(e));
4496
+ window.open(uri, "_blank", "noopener,noreferrer");
4497
+ }),
4262
4498
  );
4263
4499
  } catch {
4264
4500
  /* addon CDN blocked — terminal still works, just without auto-links */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.190.0",
3
+ "version": "1.191.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
package/ts/expose.ts CHANGED
@@ -3,9 +3,15 @@
3
3
  //
4
4
  // The daemon dials OUT (wss://<relay>/_ay/tunnel/<id>), so it works behind any
5
5
  // NAT, and runs the codehost tunnel protocol's host half against the local
6
- // port. Private by default: visitors need the single-use claim link printed
7
- // below (it swaps for an 8h HttpOnly cookie at the edge; unauthenticated
8
- // requests never reach this machine). See lab/ui/cf/exposure.ts for the edge.
6
+ // port. Private by default: visitors need a single-use claim link (it swaps
7
+ // for an 8h HttpOnly cookie at the edge; unauthenticated requests never reach
8
+ // this machine). See lab/ui/cf/exposure.ts for the edge.
9
+ //
10
+ // Two front doors share one implementation:
11
+ // - the CLI (`cmdExpose`), which starts one exposure and blocks; and
12
+ // - the in-process manager (`ensureExposure` / `listExposures` /
13
+ // `stopExposure`), which `ay serve` drives from POST /api/expose so the web
14
+ // console can expose a clicked localhost port and revoke it later.
9
15
 
10
16
  import { randomBytes, createHash } from "node:crypto";
11
17
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -24,13 +30,41 @@ interface ExposureRecord {
24
30
  key: string;
25
31
  }
26
32
 
33
+ /** Live handle for one exposed port. */
34
+ export interface ExposureHandle {
35
+ /** Opaque exposure id (also the subdomain label). */
36
+ id: string;
37
+ /** Local loopback port being shared. */
38
+ port: number;
39
+ /** Public host, e.g. x….agent-yes.com (or the relay host for a dev relay). */
40
+ publicHost: string;
41
+ /** Public root URL. */
42
+ url: string;
43
+ /** Relay host this exposure is registered on. */
44
+ relayHost: string;
45
+ createdAt: number;
46
+ /** Mint a fresh single-use claim link and register it with the relay. The
47
+ * visitor opening it gets an 8h session cookie for this exposure. */
48
+ mintClaim(): string;
49
+ /** Stop sharing (closes the relay socket; the URL then answers 502). */
50
+ stop(): void;
51
+ }
52
+
53
+ /** Serializable view of an active exposure (for the console's ports manager). */
54
+ export interface ExposureInfo {
55
+ id: string;
56
+ port: number;
57
+ url: string;
58
+ createdAt: number;
59
+ }
60
+
27
61
  function exposuresPath(): string {
28
62
  const home = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
29
63
  mkdirSync(home, { recursive: true });
30
64
  return path.join(home, "exposures.json");
31
65
  }
32
66
 
33
- /** Stable id+key per (relay, port): re-running `ay expose 5173` keeps its URL. */
67
+ /** Stable id+key per (relay, port): re-exposing a port keeps its URL. */
34
68
  function loadOrCreateExposure(relayHost: string, port: number): ExposureRecord {
35
69
  const file = exposuresPath();
36
70
  let all: Record<string, ExposureRecord> = {};
@@ -79,6 +113,160 @@ function wsTransport(ws: WebSocket): TunnelTransport {
79
113
  };
80
114
  }
81
115
 
116
+ /**
117
+ * Start (or fail) one exposure. Resolves once the relay has accepted the daemon
118
+ * and the tunnel is live; rejects if the relay refuses this exposure (bad key).
119
+ * Reconnects with backoff for the life of the handle.
120
+ */
121
+ export function startExposure(opts: {
122
+ port: number;
123
+ relay?: string;
124
+ /** Log lifecycle transitions (CLI wants this; the manager stays quiet). */
125
+ log?: (msg: string) => void;
126
+ }): Promise<ExposureHandle> {
127
+ const relay = opts.relay ?? DEFAULT_RELAY;
128
+ const port = opts.port;
129
+ const log = opts.log ?? (() => {});
130
+ const relayUrl = new URL(relay);
131
+ const rec = loadOrCreateExposure(relayUrl.host, port);
132
+ const wsProto = relayUrl.protocol === "http:" ? "ws:" : "wss:";
133
+ const tunnelUrl = `${wsProto}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
134
+ // Public hostname: <id>.<zone> on the real relay; the relay host itself (with
135
+ // a Host-header spoof) when pointing at a dev relay (wrangler dev).
136
+ const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
137
+ const publicUrl = `https://${publicHost}/`;
138
+
139
+ let stopped = false;
140
+ let sock: WebSocket | null = null;
141
+ let ready = false;
142
+ let backoff = RECONNECT_MIN_MS;
143
+
144
+ const handle: ExposureHandle = {
145
+ id: rec.id,
146
+ port,
147
+ publicHost,
148
+ url: publicUrl,
149
+ relayHost: relayUrl.host,
150
+ createdAt: Date.now(),
151
+ mintClaim() {
152
+ const token = randomBytes(18).toString("base64url");
153
+ const hash = createHash("sha256").update(token).digest("hex");
154
+ if (sock && sock.readyState === WebSocket.OPEN) {
155
+ sock.send(JSON.stringify({ t: "claim", claims: [hash] }));
156
+ }
157
+ return `https://${publicHost}/_ay/claim?t=${token}`;
158
+ },
159
+ stop() {
160
+ stopped = true;
161
+ try {
162
+ sock?.close();
163
+ } catch {
164
+ /* ignore */
165
+ }
166
+ },
167
+ };
168
+
169
+ return new Promise<ExposureHandle>((resolve, reject) => {
170
+ const connect = () => {
171
+ if (stopped) return;
172
+ sock = new WebSocket(tunnelUrl);
173
+ sock.binaryType = "arraybuffer";
174
+ const ws = sock;
175
+ let ping: ReturnType<typeof setInterval> | null = null;
176
+
177
+ ws.addEventListener("open", () => {
178
+ ws.send(JSON.stringify({ t: "hello", key: rec.key, port, v: 1 }));
179
+ });
180
+ ws.addEventListener("message", (ev) => {
181
+ if (typeof ev.data !== "string") return; // binary frames belong to the TunnelHost
182
+ let msg: { t?: string };
183
+ try {
184
+ msg = JSON.parse(ev.data);
185
+ } catch {
186
+ return;
187
+ }
188
+ if (msg.t === "ready") {
189
+ backoff = RECONNECT_MIN_MS;
190
+ new TunnelHost(wsTransport(ws), { port });
191
+ ping = setInterval(() => {
192
+ if (ws.readyState === WebSocket.OPEN) ws.send("ping");
193
+ }, PING_MS);
194
+ if (!ready) {
195
+ ready = true;
196
+ log(`sharing 127.0.0.1:${port} at ${publicUrl}`);
197
+ resolve(handle);
198
+ } else {
199
+ log(`reconnected`);
200
+ }
201
+ }
202
+ });
203
+ ws.addEventListener("close", (ev) => {
204
+ if (ping) clearInterval(ping);
205
+ if (stopped) return;
206
+ if (ev.code === 1008) {
207
+ const err = new Error(`relay refused exposure (${ev.reason || "forbidden"})`);
208
+ if (!ready) return reject(err);
209
+ log(err.message);
210
+ return;
211
+ }
212
+ log(`connection lost, retrying in ${Math.round(backoff / 1000)}s…`);
213
+ setTimeout(connect, backoff);
214
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
215
+ });
216
+ ws.addEventListener("error", () => {
217
+ /* close fires right after; retry there */
218
+ });
219
+ };
220
+ connect();
221
+ });
222
+ }
223
+
224
+ // ---- in-process manager (driven by `ay serve` POST /api/expose) ----
225
+
226
+ const active = new Map<number, ExposureHandle>();
227
+ /** In-flight starts, so concurrent POSTs for the same port share one dial. */
228
+ const starting = new Map<number, Promise<ExposureHandle>>();
229
+
230
+ /** Start an exposure for `port` (or reuse a running one). Idempotent per port. */
231
+ export async function ensureExposure(port: number, relay?: string): Promise<ExposureHandle> {
232
+ const existing = active.get(port);
233
+ if (existing) return existing;
234
+ const inflight = starting.get(port);
235
+ if (inflight) return inflight;
236
+ const p = startExposure({ port, relay })
237
+ .then((h) => {
238
+ active.set(port, h);
239
+ starting.delete(port);
240
+ return h;
241
+ })
242
+ .catch((e) => {
243
+ starting.delete(port);
244
+ throw e;
245
+ });
246
+ starting.set(port, p);
247
+ return p;
248
+ }
249
+
250
+ export function listExposures(): ExposureInfo[] {
251
+ return [...active.values()]
252
+ .sort((a, b) => b.createdAt - a.createdAt)
253
+ .map((h) => ({ id: h.id, port: h.port, url: h.url, createdAt: h.createdAt }));
254
+ }
255
+
256
+ export function stopExposure(port: number): boolean {
257
+ const h = active.get(port);
258
+ if (!h) return false;
259
+ h.stop();
260
+ active.delete(port);
261
+ return true;
262
+ }
263
+
264
+ export function stopAllExposures(): void {
265
+ for (const port of [...active.keys()]) stopExposure(port);
266
+ }
267
+
268
+ // ---- CLI ----
269
+
82
270
  export async function cmdExpose(args: string[]): Promise<number> {
83
271
  let relay = DEFAULT_RELAY;
84
272
  let port = 0;
@@ -102,83 +290,21 @@ export async function cmdExpose(args: string[]): Promise<number> {
102
290
  return 1;
103
291
  }
104
292
 
105
- const relayUrl = new URL(relay);
106
- const rec = loadOrCreateExposure(relayUrl.host, port);
107
- const wsProto = relayUrl.protocol === "http:" ? "ws:" : "wss:";
108
- const tunnelUrl = `${wsProto}//${relayUrl.host}/_ay/tunnel/${rec.id}`;
109
- // Public hostname: <id>.<zone> on the real relay; the relay host itself (with
110
- // a Host-header spoof) when pointing at wrangler dev.
111
- const publicHost = relayUrl.host === "agent-yes.com" ? `${rec.id}.agent-yes.com` : relayUrl.host;
112
-
113
- // Fresh single-use claim token every run; only its hash goes to the edge.
114
- const claimToken = randomBytes(18).toString("base64url");
115
- const claimHash = createHash("sha256").update(claimToken).digest("hex");
116
-
117
- let stopped = false;
118
- let ws: WebSocket | null = null;
119
- let backoff = RECONNECT_MIN_MS;
120
- let announced = false;
121
-
122
- const connect = () => {
123
- if (stopped) return;
124
- ws = new WebSocket(tunnelUrl);
125
- ws.binaryType = "arraybuffer";
126
- const sock = ws;
127
- let ping: ReturnType<typeof setInterval> | null = null;
128
-
129
- sock.addEventListener("open", () => {
130
- sock.send(JSON.stringify({ t: "hello", key: rec.key, port, claims: [claimHash], v: 1 }));
131
- });
132
- sock.addEventListener("message", (ev) => {
133
- if (typeof ev.data !== "string") return; // binary frames belong to the TunnelHost
134
- let msg: { t?: string };
135
- try {
136
- msg = JSON.parse(ev.data);
137
- } catch {
138
- return;
139
- }
140
- if (msg.t === "ready") {
141
- backoff = RECONNECT_MIN_MS;
142
- new TunnelHost(wsTransport(sock), { port });
143
- ping = setInterval(() => {
144
- if (sock.readyState === WebSocket.OPEN) sock.send("ping");
145
- }, PING_MS);
146
- if (!announced) {
147
- announced = true;
148
- console.log(`[ay expose] sharing 127.0.0.1:${port}`);
149
- console.log(` url: https://${publicHost}/`);
150
- console.log(` claim: https://${publicHost}/_ay/claim?t=${claimToken}`);
151
- console.log(` (one-time link — opens access for 8h in that browser)`);
152
- } else {
153
- console.log(`[ay expose] reconnected`);
154
- }
155
- }
156
- });
157
- sock.addEventListener("close", (ev) => {
158
- if (ping) clearInterval(ping);
159
- if (stopped) return;
160
- if (ev.code === 1008) {
161
- console.error(`[ay expose] relay refused this exposure (${ev.reason || "forbidden"}) — giving up`);
162
- process.exit(1);
163
- }
164
- console.log(`[ay expose] connection lost, retrying in ${Math.round(backoff / 1000)}s…`);
165
- setTimeout(connect, backoff);
166
- backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
167
- });
168
- sock.addEventListener("error", () => {
169
- /* close fires right after; retry there */
170
- });
171
- };
172
- connect();
293
+ let handle: ExposureHandle;
294
+ try {
295
+ handle = await startExposure({ port, relay, log: (m) => console.log(`[ay expose] ${m}`) });
296
+ } catch (e) {
297
+ console.error(`[ay expose] ${(e as Error).message} giving up`);
298
+ return 1;
299
+ }
300
+ const claimUrl = handle.mintClaim();
301
+ console.log(` url: ${handle.url}`);
302
+ console.log(` claim: ${claimUrl}`);
303
+ console.log(` (one-time link opens access for 8h in that browser)`);
173
304
 
174
305
  const shutdown = () => {
175
- stopped = true;
176
306
  console.log("\n[ay expose] stopped — the URL now answers 502 until you expose again");
177
- try {
178
- ws?.close();
179
- } catch {
180
- /* ignore */
181
- }
307
+ handle.stop();
182
308
  process.exit(0);
183
309
  };
184
310
  process.on("SIGINT", shutdown);
package/ts/serve.ts CHANGED
@@ -2729,6 +2729,42 @@ export async function cmdServe(rest: string[]): Promise<number> {
2729
2729
  return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
2730
2730
  }
2731
2731
 
2732
+ // POST /api/expose body {port} → share 127.0.0.1:<port> through the edge
2733
+ // relay and return {url, claim} (a fresh single-use claim link each call).
2734
+ if (req.method === "POST" && p === "/api/expose") {
2735
+ let body: { port?: number; relay?: string };
2736
+ try {
2737
+ body = (await req.json()) as typeof body;
2738
+ } catch {
2739
+ return new Response("invalid JSON body", { status: 400 });
2740
+ }
2741
+ const port = Number(body.port);
2742
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
2743
+ return new Response("valid port required", { status: 400 });
2744
+ }
2745
+ try {
2746
+ const { ensureExposure } = await import("./expose.ts");
2747
+ const h = await ensureExposure(port, body.relay);
2748
+ return Response.json({ id: h.id, port: h.port, url: h.url, claim: h.mintClaim(), createdAt: h.createdAt });
2749
+ } catch (e) {
2750
+ return new Response(`expose failed: ${(e as Error).message}`, { status: 502 });
2751
+ }
2752
+ }
2753
+
2754
+ // GET /api/exposes → active port exposures (for the console's ports manager).
2755
+ if (req.method === "GET" && p === "/api/exposes") {
2756
+ const { listExposures } = await import("./expose.ts");
2757
+ return Response.json(listExposures());
2758
+ }
2759
+
2760
+ // DELETE /api/expose/:port → revoke (the URL then answers 502).
2761
+ const unexposeM = /^\/api\/expose\/(\d+)$/.exec(p);
2762
+ if (req.method === "DELETE" && unexposeM) {
2763
+ const { stopExposure } = await import("./expose.ts");
2764
+ const ok = stopExposure(Number(unexposeM[1]));
2765
+ return new Response(ok ? "revoked" : "no such exposure", { status: ok ? 200 : 404 });
2766
+ }
2767
+
2732
2768
  return new Response("Not Found", { status: 404 });
2733
2769
  };
2734
2770
 
@@ -2793,10 +2829,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
2793
2829
  return serveUiFile("room-client.js", "text/javascript; charset=utf-8");
2794
2830
  if (req.method === "GET" && p === "/console-logic.js")
2795
2831
  return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
2832
+ // rtc.js is a STATIC import of the console module (import { RTCClient }) — a
2833
+ // 401 here fails the whole module link and the console never boots.
2834
+ if (req.method === "GET" && p === "/rtc.js")
2835
+ return serveUiFile("rtc.js", "text/javascript; charset=utf-8");
2796
2836
  if (req.method === "GET" && p === "/e2e.js")
2797
2837
  return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
2798
2838
  if (req.method === "GET" && p === "/qrcode.js")
2799
2839
  return serveUiFile("qrcode.js", "text/javascript; charset=utf-8");
2840
+ if (req.method === "GET" && p === "/manifest.webmanifest")
2841
+ return serveUiFile("manifest.webmanifest", "application/manifest+json");
2842
+ if (req.method === "GET" && p === "/icon.svg")
2843
+ return serveUiFile("icon.svg", "image/svg+xml");
2800
2844
  if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
2801
2845
  return apiFetch(req);
2802
2846
  };
@@ -1,8 +0,0 @@
1
- import "./ts-CeGEhsC9.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-DXwf5ip7.js";
4
- import "./pidStore-BIvsBQ8X.js";
5
- import "./globalPidIndex-CoNr7tS8.js";
6
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-BB7Br0fS.js";
7
-
8
- export { SUPPORTED_CLIS };