agent-yes 1.300.3 → 1.300.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/{SUPPORTED_CLIS-CWJr4TJH.js → SUPPORTED_CLIS-BIg_X-jE.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-ChSO8FiS.js → SUPPORTED_CLIS-D7lOxOnw.js} +3 -3
  3. package/dist/{agentShare-NkW0b0mg.js → agentShare-BfHUZgk4.js} +4 -4
  4. package/dist/{callback-CjlaFXp1.js → callback-COR0F2eB.js} +3 -3
  5. package/dist/{callback-B8CUyag4.js → callback-yWhCmi4k.js} +3 -3
  6. package/dist/{channels-aYwLMbFV.js → channels-rhxQArqN.js} +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/{cmdPs-C8DIl8V6.js → cmdPs-B3jTE4f3.js} +3 -3
  9. package/dist/index.js +2 -2
  10. package/dist/{notifyDaemon-DWIfzEE7.js → notifyDaemon-BIo7Nkk2.js} +3 -3
  11. package/dist/{parentPingLoop-Bh3KuFcW.js → parentPingLoop-9T4Pzp6c.js} +2 -2
  12. package/dist/{remotes-B4v0Nowo.js → remotes-B4CNXwDS.js} +2 -2
  13. package/dist/{remotes-DnMQWaqQ.js → remotes-CQTEY8Gt.js} +1 -1
  14. package/dist/{rustBinary-gI_r3g9K.js → rustBinary-CJXUPUWd.js} +48 -5
  15. package/dist/rustBinary-FhGG3Yfl.js +4 -0
  16. package/dist/{schedule-DryC--yx.js → schedule-DmijwnTA.js} +4 -4
  17. package/dist/{serve-DVoYVgBo.js → serve-F5tXYO0-.js} +99 -32
  18. package/dist/{serveRust-DDQiZmMf.js → serveRust-BAawe83i.js} +16 -2
  19. package/dist/{setup--1zRHaGW.js → setup-mCb1JbTX.js} +2 -2
  20. package/dist/{share-C9oGvjXT.js → share-CLataFnI.js} +18 -12
  21. package/dist/share-nmgSIjYP.js +4 -0
  22. package/dist/{shareCmd-BI3hRWGO.js → shareCmd-aOKimJeT.js} +25 -7
  23. package/dist/{subcommands-BviFOQZE.js → subcommands-DRq0MgSm.js} +2 -2
  24. package/dist/{subcommands-UWLkF5pq.js → subcommands-Ddo2TYZs.js} +404 -77
  25. package/dist/{terminal-DviH1E3C.js → terminal-DEXrWOa9.js} +2 -2
  26. package/dist/trayApp-0RVjo43f.js +5 -0
  27. package/dist/{trayApp-PJrwfyDs.js → trayApp-BUXx7cES.js} +2 -2
  28. package/dist/{ts--JmSY117.js → ts-CRbycTni.js} +5 -5
  29. package/dist/{versionChecker-CCGSbjio.js → versionChecker-DeQEhRGB.js} +2 -2
  30. package/dist/{webrtcRemote-Cd4vTrgv.js → webrtcRemote-Cy5kB8aR.js} +115 -14
  31. package/dist/{widget-DgBDIGRQ.js → widget-DneXqnL8.js} +3 -3
  32. package/dist/{ws-DnBg8-g-.js → ws-DPw1lVTi.js} +3 -3
  33. package/dist/{ws-dc7MP054.js → ws-Dr-0Ckq7.js} +2 -2
  34. package/package.json +1 -1
  35. package/ts/remoteCache.bun.spec.ts +191 -0
  36. package/ts/remoteCache.spec.ts +191 -0
  37. package/ts/remoteCache.ts +193 -0
  38. package/ts/rustBinary.bun.spec.ts +39 -2
  39. package/ts/rustBinary.spec.ts +53 -2
  40. package/ts/rustBinary.ts +61 -3
  41. package/ts/serve.spec.ts +103 -0
  42. package/ts/serve.ts +100 -15
  43. package/ts/serveRust.spec.ts +24 -1
  44. package/ts/serveRust.ts +19 -0
  45. package/ts/share.bun.spec.ts +54 -2
  46. package/ts/share.spec.ts +54 -2
  47. package/ts/share.ts +40 -15
  48. package/ts/shareCmd.ts +33 -2
  49. package/ts/strayServe.ts +1 -0
  50. package/ts/subcommands.spec.ts +36 -3
  51. package/ts/subcommands.ts +384 -98
  52. package/ts/webrtcRemote.ts +66 -20
  53. package/ts/webrtcSignal.bun.spec.ts +120 -0
  54. package/ts/webrtcSignal.spec.ts +120 -0
  55. package/ts/webrtcSignal.ts +143 -0
  56. package/dist/rustBinary-BNmAJEBb.js +0 -4
  57. package/dist/share-Bmnd_qT0.js +0 -4
  58. package/dist/trayApp-BDIb8fOj.js +0 -5
package/ts/serve.spec.ts CHANGED
@@ -2,6 +2,9 @@ import { readFile } from "fs/promises";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  installerArgv,
5
+ LEGACY_AYRS_TASK,
6
+ legacyAyrsTaskArgv,
7
+ serveLauncher,
5
8
  probeDaemon,
6
9
  isNoNodeExecError,
7
10
  oxmgrCmd,
@@ -284,3 +287,103 @@ describe("vendorAssetType", () => {
284
287
  expect(vendorAssetType("")).toBeNull();
285
288
  });
286
289
  });
290
+
291
+ // The daemon manager stores this argv verbatim and re-runs it on every
292
+ // restart: a baked-in `~/.bun/bin/ay` symlink (recreated on each global
293
+ // upgrade, absent on some images until the first one) or a bare `ay` that a
294
+ // manager's minimal PATH cannot resolve both turn into an ENOENT restart loop.
295
+ describe("serveLauncher", () => {
296
+ const realpath = (p: string) =>
297
+ p.replace(
298
+ "/home/alice/.bun/bin/ay",
299
+ "/home/alice/.bun/install/global/node_modules/agent-yes/dist/cli.js",
300
+ );
301
+
302
+ it("resolves the PATH symlink to the real script and runs it under bun", () => {
303
+ expect(
304
+ serveLauncher({
305
+ which: "/home/alice/.bun/bin/ay",
306
+ script: "/x/cli.js",
307
+ execPath: "/usr/bin/bun",
308
+ platform: "linux",
309
+ realpath,
310
+ }),
311
+ ).toEqual([
312
+ "/usr/bin/bun",
313
+ "/home/alice/.bun/install/global/node_modules/agent-yes/dist/cli.js",
314
+ ]);
315
+ });
316
+
317
+ it("falls back to the running script, never to a bare `ay`, when nothing is on PATH", () => {
318
+ expect(
319
+ serveLauncher({
320
+ which: null,
321
+ script: "/repo/alpha/dist/cli.js",
322
+ execPath: "/usr/bin/bun",
323
+ platform: "linux",
324
+ realpath,
325
+ }),
326
+ ).toEqual(["/usr/bin/bun", "/repo/alpha/dist/cli.js"]);
327
+ });
328
+
329
+ it("survives a realpath failure by keeping the path it was given", () => {
330
+ const boom = () => {
331
+ throw new Error("ENOENT");
332
+ };
333
+ expect(
334
+ serveLauncher({
335
+ which: "/opt/ay",
336
+ script: undefined,
337
+ execPath: "/usr/bin/bun",
338
+ platform: "linux",
339
+ realpath: boom,
340
+ }),
341
+ ).toEqual(["/usr/bin/bun", "/opt/ay"]);
342
+ });
343
+
344
+ it("runs a Windows exe shim directly and a script under bun", () => {
345
+ const id = (p: string) => p;
346
+ expect(
347
+ serveLauncher({
348
+ which: "C:/Users/alice/.bun/bin/ay.exe",
349
+ script: undefined,
350
+ execPath: "C:/bun.exe",
351
+ platform: "win32",
352
+ realpath: id,
353
+ }),
354
+ ).toEqual(["C:/Users/alice/.bun/bin/ay.exe"]);
355
+ expect(
356
+ serveLauncher({
357
+ which: null,
358
+ script: "C:/repo/alpha/dist/cli.js",
359
+ execPath: "C:/bun.exe",
360
+ platform: "win32",
361
+ realpath: id,
362
+ }),
363
+ ).toEqual(["C:/bun.exe", "C:/repo/alpha/dist/cli.js"]);
364
+ });
365
+ });
366
+
367
+ // A scheduled-task host from before the oxmgr move keeps the WebRTC room; the
368
+ // new daemon then waits on it forever. Install and uninstall both clear it.
369
+ describe("legacyAyrsTaskArgv", () => {
370
+ it("names the task ayrs serve install registered, with the right schtasks verbs", () => {
371
+ expect(LEGACY_AYRS_TASK).toBe("com.snomiao.ayrs-serve");
372
+ expect(legacyAyrsTaskArgv("query")).toEqual([
373
+ "schtasks",
374
+ "/query",
375
+ "/tn",
376
+ LEGACY_AYRS_TASK,
377
+ "/fo",
378
+ "LIST",
379
+ ]);
380
+ expect(legacyAyrsTaskArgv("end")).toEqual(["schtasks", "/end", "/tn", LEGACY_AYRS_TASK]);
381
+ expect(legacyAyrsTaskArgv("delete")).toEqual([
382
+ "schtasks",
383
+ "/delete",
384
+ "/tn",
385
+ LEGACY_AYRS_TASK,
386
+ "/f",
387
+ ]);
388
+ });
389
+ });
package/ts/serve.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  unlink,
11
11
  writeFile,
12
12
  } from "fs/promises";
13
- import { existsSync, renameSync, watch, writeFileSync } from "node:fs";
13
+ import { existsSync, realpathSync, renameSync, watch, writeFileSync } from "node:fs";
14
14
  import { execFileSync } from "node:child_process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
@@ -949,16 +949,72 @@ export function oxmgrCmd(argv: string[]): string {
949
949
  return argv.map((a) => (/[\\\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" ");
950
950
  }
951
951
 
952
+ /**
953
+ * The argv a daemon manager runs for `ay serve …`. Pure so the contract is
954
+ * testable: `which` is the `ay` on PATH (or null), `script` is THIS process's
955
+ * own entry (`process.argv[1]`, always present), `realpath` resolves symlinks.
956
+ *
957
+ * Never bake in the `~/.bun/bin/ay` symlink: `bun add -g` removes and recreates
958
+ * it on every upgrade, and some images ship without it until the first update
959
+ * — a manager that stored it then restarts into ENOENT thousands of times.
960
+ * Resolve to the real file instead, and when nothing is on PATH fall back to
961
+ * the script running this very install (never a bare `ay`, which a manager's
962
+ * minimal PATH cannot resolve). On POSIX the entry is a `#!/usr/bin/env bun`
963
+ * script run via bun; on Windows it is a self-contained exe.
964
+ */
965
+ export function serveLauncher(opts: {
966
+ which: string | null;
967
+ script: string | undefined;
968
+ execPath: string;
969
+ platform: NodeJS.Platform;
970
+ realpath: (p: string) => string;
971
+ }): string[] {
972
+ const resolve = (p: string) => {
973
+ try {
974
+ return opts.realpath(p);
975
+ } catch {
976
+ return p;
977
+ }
978
+ };
979
+ const entry = opts.which ? resolve(opts.which) : opts.script ? resolve(opts.script) : null;
980
+ if (!entry) return ["ay"];
981
+ if (opts.platform === "win32")
982
+ return /\.(exe|cmd|bat)$/i.test(entry) ? [entry] : [opts.execPath, entry];
983
+ return [opts.execPath, entry];
984
+ }
985
+
952
986
  function ayServeArgv(args: string[]): string[] {
953
- const ayBin = Bun.which("ay");
954
- const launcher = ayBin
955
- ? process.platform === "win32"
956
- ? [ayBin]
957
- : [process.execPath, ayBin]
958
- : ["ay"];
987
+ const launcher = serveLauncher({
988
+ which: Bun.which("ay"),
989
+ script: process.argv[1],
990
+ execPath: process.execPath,
991
+ platform: process.platform,
992
+ realpath: realpathSync,
993
+ });
959
994
  return [...launcher, "serve", ...args];
960
995
  }
961
996
 
997
+ // The Task Scheduler entry `ayrs serve install` registered before the daemon
998
+ // moved under oxmgr. A leftover one keeps hosting the WebRTC room, and the new
999
+ // daemon then waits forever on "another ayrs host is already serving room …".
1000
+ // Mirrors rs/src/serve/service.rs LABEL / uninstall_quiet.
1001
+ export const LEGACY_AYRS_TASK = "com.snomiao.ayrs-serve";
1002
+
1003
+ export function legacyAyrsTaskArgv(step: "query" | "end" | "delete"): string[] {
1004
+ const base = ["schtasks", `/${step}`, "/tn", LEGACY_AYRS_TASK];
1005
+ return step === "query" ? [...base, "/fo", "LIST"] : step === "delete" ? [...base, "/f"] : base;
1006
+ }
1007
+
1008
+ async function stopLegacyAyrsTask(): Promise<void> {
1009
+ if (process.platform !== "win32") return;
1010
+ if ((await spawnExit(legacyAyrsTaskArgv("query"))) !== 0) return; // not registered
1011
+ process.stdout.write(
1012
+ `stopping legacy scheduled task '${LEGACY_AYRS_TASK}' (it would keep hosting the WebRTC room)\n`,
1013
+ );
1014
+ await spawnExit(legacyAyrsTaskArgv("end"));
1015
+ await spawnExit(legacyAyrsTaskArgv("delete"));
1016
+ }
1017
+
962
1018
  // Per-user login auto-start entry on Windows. pm2 core has no Windows startup
963
1019
  // integration (`pm2 startup` errors "Init system not found"), so we register a
964
1020
  // HKCU Run value that runs `pm2 resurrect` at login — no admin, removed on
@@ -1197,6 +1253,14 @@ async function warnStrayServeProcesses(): Promise<void> {
1197
1253
  // mints a persisted room instead). Mirrors how cmdServe resolves argv.webrtc/argv.share,
1198
1254
  // but over the raw arg list install holds (oxmgr splits the command on whitespace →
1199
1255
  // `--webrtc url`; pm2/`=` → `--webrtc=url`).
1256
+ // Which runtime a daemon registered with `args` hosts its room under — see
1257
+ // serveRust.ts daemonShareRuntime; the binary check is the live one cmdServe makes.
1258
+ async function daemonShareRuntime(args: string[]): Promise<"ts" | "rust"> {
1259
+ const { daemonShareRuntime: decide } = await import("./serveRust.ts");
1260
+ const { findAyrsBinary } = await import("./rustBinary.ts");
1261
+ return decide(args, () => !!findAyrsBinary());
1262
+ }
1263
+
1200
1264
  function explicitWebrtcUrl(args: string[]): string | undefined {
1201
1265
  for (let i = 0; i < args.length; i++) {
1202
1266
  const a = args[i]!;
@@ -1329,6 +1393,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1329
1393
  }
1330
1394
  if (removed === 0 && failed === 0)
1331
1395
  process.stdout.write(`no '${DAEMON_NAME}' daemon registered with any manager\n`);
1396
+ await stopLegacyAyrsTask();
1332
1397
  await warnStrayServeProcesses();
1333
1398
  return failed ? 1 : 0;
1334
1399
  }
@@ -1380,11 +1445,20 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1380
1445
  const webrtcDaemon = effArgs.some((a) => a.startsWith("--webrtc") || a.startsWith("--share"));
1381
1446
  let shareLink: string | null = null;
1382
1447
  let shareLinkMinted = false; // auto-minted (persisted/rotatable) vs explicit URL
1448
+ // The daemon's `ay serve …` delegates to ayrs whenever it can (serveRust.ts),
1449
+ // and ayrs persists its room in a DIFFERENT file (.share-room-ayrs) — mint
1450
+ // and print from the file of the runtime that will actually host.
1451
+ let roomFile = ".share-room";
1383
1452
  if (webrtcDaemon) {
1384
1453
  try {
1385
- const { loadOrCreateShareRoom, shareLinkFromRoomUrl } = await import("./share.ts");
1454
+ const { loadOrCreateShareRoom, shareLinkFromRoomUrl, shareRoomFileName } =
1455
+ await import("./share.ts");
1456
+ const runtime = await daemonShareRuntime(effArgs);
1457
+ roomFile = shareRoomFileName(runtime);
1386
1458
  const explicit = explicitWebrtcUrl(effArgs);
1387
- shareLink = shareLinkFromRoomUrl(explicit ?? (await loadOrCreateShareRoom()));
1459
+ shareLink = shareLinkFromRoomUrl(
1460
+ explicit ?? (await loadOrCreateShareRoom(undefined, runtime)),
1461
+ );
1388
1462
  shareLinkMinted = !explicit;
1389
1463
  } catch {
1390
1464
  /* best effort — fall back to the .share-link file hint in emitShareLink */
@@ -1397,7 +1471,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1397
1471
  `\nshared over WebRTC — open this link (the token is eaten from the URL on open):\n` +
1398
1472
  ` ${shareLink}\n` +
1399
1473
  (shareLinkMinted
1400
- ? ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n`
1474
+ ? ` (persistent room — same link across restarts; delete ~/.agent-yes/${roomFile} to rotate)\n`
1401
1475
  : ``),
1402
1476
  );
1403
1477
  // Offer to jump straight into the console (default yes); no-ops on a
@@ -1407,8 +1481,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1407
1481
  } else
1408
1482
  process.stdout.write(
1409
1483
  `\nthe WebRTC share link carries a secret, so the daemon does NOT log it —\n` +
1410
- `read it from ~/.agent-yes/.share-link (mode 0600). The room persists in\n` +
1411
- `~/.agent-yes/.share-room, so the link survives restarts.\n`,
1484
+ `read it with \`ay share status\`. The room persists in\n` +
1485
+ `~/.agent-yes/${roomFile}, so the link survives restarts.\n`,
1412
1486
  );
1413
1487
  };
1414
1488
 
@@ -1439,6 +1513,9 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1439
1513
  await spawnExit([mgr.bin, "stop", DAEMON_NAME]);
1440
1514
  await spawnExit([mgr.bin, "delete", DAEMON_NAME]);
1441
1515
  }
1516
+ // A pre-oxmgr scheduled-task host left behind would keep the WebRTC room
1517
+ // and the new daemon would wait on it forever.
1518
+ await stopLegacyAyrsTask();
1442
1519
  await warnStrayServeProcesses();
1443
1520
 
1444
1521
  // oxmgr takes the command as one string; pm2 takes the binary plus its
@@ -1627,11 +1704,18 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1627
1704
  const json = args.includes("--json");
1628
1705
  const mgr = await resolveActiveManager();
1629
1706
  const token = await loadTokenReadOnly();
1630
- const shareLink = await readShareLink();
1631
1707
 
1632
1708
  // A non-null arg list means the daemon is registered with the manager.
1633
1709
  const daemonArgs = mgr ? await readDaemonServeArgs(mgr) : null;
1634
1710
  const installed = daemonArgs !== null;
1711
+ // The link of the room the registered daemon HOSTS: derived read-only from
1712
+ // the room file of its runtime (ayrs by default — a different file from the
1713
+ // TS host's). `.share-link` is only ever written by the TS host, so for a
1714
+ // Rust daemon it is stale or absent; keep it as the fallback for TS daemons
1715
+ // and for the uninstalled-but-left-over case shareLinkStatus flags.
1716
+ const { daemonShareLink } = await import("./shareCmd.ts");
1717
+ const shareLink =
1718
+ (installed ? await daemonShareLink(daemonArgs) : null) ?? (await readShareLink());
1635
1719
  // Manager-reported process state — the only up/down signal for webrtc daemons.
1636
1720
  const running = installed ? await readDaemonRunning(mgr!) : false;
1637
1721
  const a = daemonArgs ?? [];
@@ -1781,8 +1865,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
1781
1865
  ` agent-yes.com, or pass a room link to join that room:\n` +
1782
1866
  ` https://agent-yes.com/room/#room=<id>&s=e1.<secret>).\n` +
1783
1867
  ` Alone it needs NO port — combine with --http for both.\n` +
1784
- ` The minted room persists in ~/.agent-yes/.share-room\n` +
1785
- ` (stable link across restarts; delete the file to rotate).\n` +
1868
+ ` The minted room persists in ~/.agent-yes/.share-room-ayrs\n` +
1869
+ ` (.share-room under --no-rust); stable link across restarts,\n` +
1870
+ ` delete the file to rotate.\n` +
1786
1871
  ` --share [URL] Legacy alias for --http --webrtc\n\n` +
1787
1872
  `Runtime: like \`ay <cli>\`, the Rust daemon (ayrs serve) runs by default for\n` +
1788
1873
  ` the flags it supports — --webrtc [webrtc://url], --port N, --sighost H.\n` +
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { planRustServe } from "./serveRust.ts";
2
+ import { daemonShareRuntime, planRustServe } from "./serveRust.ts";
3
3
 
4
4
  // `ay serve` runs the Rust daemon (`ayrs serve`) by default, like `ay <cli>`
5
5
  // runs the Rust agent runtime — but only for invocations ayrs can express.
@@ -83,3 +83,26 @@ describe("planRustServe", () => {
83
83
  });
84
84
  });
85
85
  });
86
+
87
+ // Every printed share link must come from the room file of the runtime that
88
+ // hosts the room: ayrs persists its room in .share-room-ayrs, TS in .share-room.
89
+ // A link derived from the wrong file points at a room nobody serves.
90
+ describe("daemonShareRuntime", () => {
91
+ const env = {};
92
+ const ayrs = () => true;
93
+ const noAyrs = () => false;
94
+
95
+ it("is rust for the args ayrs serve accepts, when the binary exists", () => {
96
+ expect(daemonShareRuntime(["--webrtc"], ayrs, env)).toBe("rust");
97
+ expect(daemonShareRuntime(["--webrtc", "--sighost", "s.example"], ayrs, env)).toBe("rust");
98
+ expect(daemonShareRuntime(["--webrtc", "--port", "0"], ayrs, env)).toBe("rust");
99
+ });
100
+
101
+ it("is ts whenever cmdServe would fall back to the TypeScript host", () => {
102
+ expect(daemonShareRuntime(["--webrtc"], noAyrs, env)).toBe("ts");
103
+ expect(daemonShareRuntime(["--webrtc", "--no-rust"], ayrs, env)).toBe("ts");
104
+ expect(daemonShareRuntime(["--webrtc"], ayrs, { AGENT_YES_NO_RUST: "1" })).toBe("ts");
105
+ expect(daemonShareRuntime(["--share"], ayrs, env)).toBe("ts");
106
+ expect(daemonShareRuntime([], ayrs, env)).toBe("ts");
107
+ });
108
+ });
package/ts/serveRust.ts CHANGED
@@ -112,3 +112,22 @@ export function planRustServe(
112
112
  }
113
113
  return { kind: "rust", argv };
114
114
  }
115
+
116
+ /**
117
+ * Which runtime a daemon registered with these `ay serve` args actually runs —
118
+ * and therefore which persisted room file holds the room it hosts
119
+ * (`.share-room` for TypeScript, `.share-room-ayrs` for ayrs; see share.ts).
120
+ *
121
+ * Mirrors cmdServe exactly: an argv that planRustServe delegates runs under
122
+ * ayrs whenever the binary exists, and falls back to TypeScript otherwise. Any
123
+ * link we print for a daemon must be derived through this, or `ay share` hands
124
+ * out a link to a room nobody serves. `hasAyrs` is injected so the decision
125
+ * stays a pure function for tests.
126
+ */
127
+ export function daemonShareRuntime(
128
+ args: string[],
129
+ hasAyrs: () => boolean,
130
+ env: Record<string, string | undefined> = process.env,
131
+ ): "ts" | "rust" {
132
+ return planRustServe(args, env).kind === "rust" && hasAyrs() ? "rust" : "ts";
133
+ }
@@ -1,5 +1,15 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { formatRoomLink, shareLinkFromRoomUrl } from "./share.ts";
1
+ import { afterEach, beforeEach, describe, it, expect } from "bun:test";
2
+ import {
3
+ formatRoomLink,
4
+ loadOrCreateShareRoom,
5
+ readShareRoom,
6
+ shareLinkFromRoomUrl,
7
+ shareRoomFileName,
8
+ shareRoomPath,
9
+ } from "./share.ts";
10
+ import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
11
+ import { tmpdir } from "os";
12
+ import path from "path";
3
13
  import { parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
4
14
  import { MARKER } from "../lab/ui/e2e.js";
5
15
 
@@ -56,3 +66,45 @@ describe("formatRoomLink \u2194 the CLI parser", () => {
56
66
  expect(parseWebrtcLink(link)).toMatchObject({ room: "r1a2b3c", host: "localhost:7778" });
57
67
  });
58
68
  });
69
+
70
+ // The two hosts persist their rooms in different files (same format). Minting
71
+ // and reading must target the file of the runtime that hosts, and a read must
72
+ // never mint — `ay serve status` is read-only.
73
+ describe("share room files per runtime", () => {
74
+ let home: string;
75
+ const prev = process.env.AGENT_YES_HOME;
76
+ beforeEach(async () => {
77
+ home = await mkdtemp(path.join(tmpdir(), "ay-share-room-"));
78
+ process.env.AGENT_YES_HOME = home;
79
+ });
80
+ afterEach(async () => {
81
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
82
+ else process.env.AGENT_YES_HOME = prev;
83
+ await rm(home, { recursive: true, force: true });
84
+ });
85
+
86
+ it("maps each runtime to its own file", () => {
87
+ expect(shareRoomFileName("ts")).toBe(".share-room");
88
+ expect(shareRoomFileName("rust")).toBe(".share-room-ayrs");
89
+ expect(shareRoomPath("rust")).toBe(path.join(home, ".share-room-ayrs"));
90
+ expect(shareRoomPath()).toBe(path.join(home, ".share-room"));
91
+ });
92
+
93
+ it("mints into the rust file and leaves the ts file alone", async () => {
94
+ const url = await loadOrCreateShareRoom("s.example", "rust");
95
+ expect(url).toMatch(/^webrtc:\/\/r[0-9a-f]{12}:e1\.[0-9a-f]{64}@s\.example$/);
96
+ expect((await readFile(path.join(home, ".share-room-ayrs"), "utf-8")).trim()).toBe(url);
97
+ await expect(readFile(path.join(home, ".share-room"))).rejects.toThrow();
98
+ // Stable: a second load returns the persisted room, no re-mint.
99
+ expect(await loadOrCreateShareRoom("s.example", "rust")).toBe(url);
100
+ });
101
+
102
+ it("reads without minting, and ignores a legacy unencrypted room", async () => {
103
+ expect(await readShareRoom("rust")).toBeNull();
104
+ await expect(readFile(path.join(home, ".share-room-ayrs"))).rejects.toThrow();
105
+ await writeFile(path.join(home, ".share-room"), `webrtc://room0:${S}@s.example`);
106
+ expect(await readShareRoom("ts")).toBeNull();
107
+ await writeFile(path.join(home, ".share-room"), `webrtc://room0:${TOK}@s.example`);
108
+ expect(await readShareRoom("ts")).toBe(`webrtc://room0:${TOK}@s.example`);
109
+ });
110
+ });
package/ts/share.spec.ts CHANGED
@@ -1,5 +1,15 @@
1
- import { describe, it, expect } from "vitest";
2
- import { formatRoomLink, shareLinkFromRoomUrl } from "./share.ts";
1
+ import { afterEach, beforeEach, describe, it, expect } from "vitest";
2
+ import {
3
+ formatRoomLink,
4
+ loadOrCreateShareRoom,
5
+ readShareRoom,
6
+ shareLinkFromRoomUrl,
7
+ shareRoomFileName,
8
+ shareRoomPath,
9
+ } from "./share.ts";
10
+ import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
11
+ import { tmpdir } from "os";
12
+ import path from "path";
3
13
  import { parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
4
14
  import { MARKER } from "../lab/ui/e2e.js";
5
15
 
@@ -56,3 +66,45 @@ describe("formatRoomLink \u2194 the CLI parser", () => {
56
66
  expect(parseWebrtcLink(link)).toMatchObject({ room: "r1a2b3c", host: "localhost:7778" });
57
67
  });
58
68
  });
69
+
70
+ // The two hosts persist their rooms in different files (same format). Minting
71
+ // and reading must target the file of the runtime that hosts, and a read must
72
+ // never mint — `ay serve status` is read-only.
73
+ describe("share room files per runtime", () => {
74
+ let home: string;
75
+ const prev = process.env.AGENT_YES_HOME;
76
+ beforeEach(async () => {
77
+ home = await mkdtemp(path.join(tmpdir(), "ay-share-room-"));
78
+ process.env.AGENT_YES_HOME = home;
79
+ });
80
+ afterEach(async () => {
81
+ if (prev === undefined) delete process.env.AGENT_YES_HOME;
82
+ else process.env.AGENT_YES_HOME = prev;
83
+ await rm(home, { recursive: true, force: true });
84
+ });
85
+
86
+ it("maps each runtime to its own file", () => {
87
+ expect(shareRoomFileName("ts")).toBe(".share-room");
88
+ expect(shareRoomFileName("rust")).toBe(".share-room-ayrs");
89
+ expect(shareRoomPath("rust")).toBe(path.join(home, ".share-room-ayrs"));
90
+ expect(shareRoomPath()).toBe(path.join(home, ".share-room"));
91
+ });
92
+
93
+ it("mints into the rust file and leaves the ts file alone", async () => {
94
+ const url = await loadOrCreateShareRoom("s.example", "rust");
95
+ expect(url).toMatch(/^webrtc:\/\/r[0-9a-f]{12}:e1\.[0-9a-f]{64}@s\.example$/);
96
+ expect((await readFile(path.join(home, ".share-room-ayrs"), "utf-8")).trim()).toBe(url);
97
+ await expect(readFile(path.join(home, ".share-room"))).rejects.toThrow();
98
+ // Stable: a second load returns the persisted room, no re-mint.
99
+ expect(await loadOrCreateShareRoom("s.example", "rust")).toBe(url);
100
+ });
101
+
102
+ it("reads without minting, and ignores a legacy unencrypted room", async () => {
103
+ expect(await readShareRoom("rust")).toBeNull();
104
+ await expect(readFile(path.join(home, ".share-room-ayrs"))).rejects.toThrow();
105
+ await writeFile(path.join(home, ".share-room"), `webrtc://room0:${S}@s.example`);
106
+ expect(await readShareRoom("ts")).toBeNull();
107
+ await writeFile(path.join(home, ".share-room"), `webrtc://room0:${TOK}@s.example`);
108
+ expect(await readShareRoom("ts")).toBe(`webrtc://room0:${TOK}@s.example`);
109
+ });
110
+ });
package/ts/share.ts CHANGED
@@ -154,30 +154,54 @@ export interface ShareOpts {
154
154
  // browser that saved the room) survives restarts — important for daemons,
155
155
  // which would otherwise mint a new link on every restart. Delete the file to
156
156
  // rotate the room.
157
- function shareRoomPath(): string {
157
+ //
158
+ // Two hosts, two files. The TypeScript server persists its room in
159
+ // `.share-room`; the Rust daemon (`ayrs serve --webrtc`, the default whenever
160
+ // the ayrs binary exists — see serveRust.ts) persists its own in
161
+ // `.share-room-ayrs`, so the two can run side by side without fighting over one
162
+ // room (they hold different host locks). Same on-disk format. Every link we
163
+ // PRINT must therefore come from the file of the runtime that actually hosts,
164
+ // or the operator hands out a link to a room nobody serves.
165
+ export type ShareRuntime = "ts" | "rust";
166
+
167
+ export function shareRoomFileName(runtime: ShareRuntime): string {
168
+ return runtime === "rust" ? ".share-room-ayrs" : ".share-room";
169
+ }
170
+
171
+ export function shareRoomPath(runtime: ShareRuntime = "ts"): string {
158
172
  const home = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
159
- return path.join(home, ".share-room");
173
+ return path.join(home, shareRoomFileName(runtime));
160
174
  }
161
175
 
162
- export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<string> {
176
+ // Read-only: the persisted room url, or null when none (or a legacy markerless
177
+ // one) is on disk. `ay serve status` must never mint.
178
+ export async function readShareRoom(runtime: ShareRuntime = "ts"): Promise<string | null> {
163
179
  try {
164
- const url = (await readFile(shareRoomPath(), "utf-8")).trim();
165
- if (url.startsWith("webrtc://")) {
166
- // A v2 (encrypted) room carries the e1. marker on its secret — reuse it.
167
- // A legacy markerless room is rotated to a fresh encrypted room below: the
168
- // signaling DO has pinned the old room to its plaintext token, so we must
169
- // mint a NEW room name. This is a one-time, deliberate security upgrade —
170
- // old share links stop working; re-open the new printed link.
171
- if (parseShareUrl(url).token.startsWith(MARKER)) return url;
172
- }
180
+ const url = (await readFile(shareRoomPath(runtime), "utf-8")).trim();
181
+ // A v2 (encrypted) room carries the e1. marker on its secret — reuse it.
182
+ // A legacy markerless room is rotated to a fresh encrypted room by
183
+ // loadOrCreateShareRoom: the signaling DO has pinned the old room to its
184
+ // plaintext token, so we must mint a NEW room name. This is a one-time,
185
+ // deliberate security upgrade — old share links stop working; re-open the
186
+ // new printed link.
187
+ if (url.startsWith("webrtc://") && parseShareUrl(url).token.startsWith(MARKER)) return url;
173
188
  } catch {
174
189
  /* not yet minted */
175
190
  }
191
+ return null;
192
+ }
193
+
194
+ export async function loadOrCreateShareRoom(
195
+ sighost = DEFAULT_SIGHOST,
196
+ runtime: ShareRuntime = "ts",
197
+ ): Promise<string> {
198
+ const existing = await readShareRoom(runtime);
199
+ if (existing) return existing;
176
200
  const room = "r" + randomBytes(6).toString("hex");
177
201
  const s = randomBytes(32).toString("hex");
178
202
  const url = `webrtc://${room}:${MARKER}${s}@${sighost}`;
179
- await mkdir(path.dirname(shareRoomPath()), { recursive: true });
180
- await writeFile(shareRoomPath(), url, { mode: 0o600 });
203
+ await mkdir(path.dirname(shareRoomPath(runtime)), { recursive: true });
204
+ await writeFile(shareRoomPath(runtime), url, { mode: 0o600 });
181
205
  return url;
182
206
  }
183
207
 
@@ -236,7 +260,8 @@ export function shareLinkFromRoomUrl(url: string): string {
236
260
  const { s, v2 } = parseSecret(token);
237
261
  if (!v2)
238
262
  throw new Error(
239
- "refusing to derive a link for an unencrypted room — delete ~/.agent-yes/.share-room to rotate to an encrypted link",
263
+ "refusing to derive a link for an unencrypted room — delete the persisted room file " +
264
+ "(~/.agent-yes/.share-room, or .share-room-ayrs for the Rust daemon) to rotate to an encrypted link",
240
265
  );
241
266
  return formatShareLink(room, s, host);
242
267
  }
package/ts/shareCmd.ts CHANGED
@@ -132,6 +132,27 @@ const HELP =
132
132
  `The URL carries this host's serve token: whoever holds it can read and steer\n` +
133
133
  `every agent here. Rotate: rm ~/.agent-yes/.serve-token && ay serve install\n`;
134
134
 
135
+ // Which runtime hosts the room for a daemon registered with `args` — the Rust
136
+ // daemon (ayrs) whenever the args delegate and the binary exists, else TS.
137
+ async function shareRuntimeOf(args: string[] | null): Promise<"ts" | "rust"> {
138
+ const { daemonShareRuntime } = await import("./serveRust.ts");
139
+ const { findAyrsBinary } = await import("./rustBinary.ts");
140
+ return daemonShareRuntime(args ?? [], () => !!findAyrsBinary());
141
+ }
142
+
143
+ // The console link for the room a `--webrtc` daemon hosts, derived read-only
144
+ // from the persisted room file of its runtime; null when nothing is minted yet.
145
+ export async function daemonShareLink(args: string[] | null): Promise<string | null> {
146
+ const { readShareRoom, shareLinkFromRoomUrl } = await import("./share.ts");
147
+ const room = await readShareRoom(await shareRuntimeOf(args));
148
+ if (!room) return null;
149
+ try {
150
+ return shareLinkFromRoomUrl(room);
151
+ } catch {
152
+ return null; // legacy unencrypted room — the host itself rotates it on start
153
+ }
154
+ }
155
+
135
156
  // ay share status — end-to-end health of each way this machine is shared.
136
157
  async function cmdShareStatus(mount: string): Promise<number> {
137
158
  const { inspectServeDaemon } = await import("./serve.ts");
@@ -148,7 +169,12 @@ async function cmdShareStatus(mount: string): Promise<number> {
148
169
  d.port ? (d.httpUp ? `up on 127.0.0.1:${d.port}` : `DOWN (127.0.0.1:${d.port})`) : "off",
149
170
  );
150
171
  if (d.consoleUrl) line("portless:", d.httpUp ? d.consoleUrl.replace(/#.*/, "") : "DOWN");
151
- line("webrtc:", webrtc ? "on (agent-yes.com room)" : "off");
172
+ if (webrtc) {
173
+ // Print the link the RUNNING host serves — from the room file of the
174
+ // runtime that hosts it (Rust by default), read-only: status never mints.
175
+ const link = await daemonShareLink(d.args);
176
+ line("webrtc:", link ? `on — ${link}` : "on (room not minted yet — run `ay share webrtc`)");
177
+ } else line("webrtc:", "off");
152
178
  if (!ts) {
153
179
  line("tailscale:", Bun.which("tailscale") ? "not running" : "not installed");
154
180
  return d.httpUp || webrtc ? 0 : 1;
@@ -306,8 +332,13 @@ export async function cmdShare(rest: string[]): Promise<number> {
306
332
  let url: string;
307
333
  const port = state.port ?? (portArg ? Number(portArg) : DEFAULT_SHARE_PORT);
308
334
  if (mode === "webrtc") {
335
+ // The daemon just (re)installed runs `ay serve --webrtc`, which is the Rust
336
+ // host whenever ayrs exists — read ITS room file, not the TS one, or the
337
+ // link points at a room nobody hosts.
309
338
  const { loadOrCreateShareRoom, shareLinkFromRoomUrl } = await import("./share.ts");
310
- url = shareLinkFromRoomUrl(await loadOrCreateShareRoom());
339
+ url = shareLinkFromRoomUrl(
340
+ await loadOrCreateShareRoom(undefined, await shareRuntimeOf(state.args)),
341
+ );
311
342
  } else if (mode === "portless") {
312
343
  url = state.consoleUrl ?? httpShareUrl("https://agent-yes.localhost", state.token);
313
344
  } else if (mode === "local") {
package/ts/strayServe.ts CHANGED
@@ -7,6 +7,7 @@ const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
7
7
  function isServeCommand(command: string): boolean {
8
8
  return (
9
9
  /\bay(?:\.js)?\s+serve\b/.test(command) ||
10
+ /\bayrs(?:\.exe)?\s+serve\b/.test(command) ||
10
11
  /\bagent-yes(?:\.js)?\s+serve\b/.test(command) ||
11
12
  /\bagent-yes\.js\s+serve\b/.test(command)
12
13
  );