agent-yes 1.302.2 → 1.303.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.
Files changed (47) hide show
  1. package/dist/{SUPPORTED_CLIS-C5Uhdl61.js → SUPPORTED_CLIS-BvWBFlrN.js} +3 -3
  2. package/dist/{SUPPORTED_CLIS-FBshN0v2.js → SUPPORTED_CLIS-CqmE-ESB.js} +2 -2
  3. package/dist/{agentShare-CNgCQMY9.js → agentShare-Gb4vxJzE.js} +4 -4
  4. package/dist/{callback-ayc9yXqJ.js → callback-CuIY-75l.js} +3 -3
  5. package/dist/{callback-CbZ-H_Pn.js → callback-ZzRPkYjf.js} +4 -4
  6. package/dist/cli.js +5 -5
  7. package/dist/{cmdPs-CDj-l5Pk.js → cmdPs-DSKYjOKS.js} +4 -4
  8. package/dist/index.js +2 -2
  9. package/dist/{notifyDaemon-DZbd5fTj.js → notifyDaemon-C_gTWqYZ.js} +4 -4
  10. package/dist/{parentPingLoop-Bim6u0OH.js → parentPingLoop-BhVR1EQm.js} +2 -2
  11. package/dist/{remotes-ByjpYjQ9.js → remotes-C36aI8Ty.js} +2 -2
  12. package/dist/{remotes-D10E_xUn.js → remotes-COBEhmlr.js} +6 -6
  13. package/dist/{rustBinary-DklBSefL.js → rustBinary-Ep0x7WrS.js} +2 -2
  14. package/dist/{rustBinary-Btf18sk5.js → rustBinary-eOcvZ5yS.js} +2 -2
  15. package/dist/{schedule-DzqTosvJ.js → schedule-E2QlsME5.js} +4 -4
  16. package/dist/{serve-DdHbUWtG.js → serve-BWy7zL7j.js} +43 -26
  17. package/dist/{serveRust-BEzTqFRy.js → serveRust-BgY5Te5E.js} +50 -6
  18. package/dist/{setup-CzKkVYp8.js → setup-CD9-RyhT.js} +2 -2
  19. package/dist/{shareCmd-DinezAHY.js → shareCmd-Drxb-SjM.js} +7 -7
  20. package/dist/{subcommands-oIARj_mJ.js → subcommands-C627cuOl.js} +3 -3
  21. package/dist/{subcommands-fX4Ygx3a.js → subcommands-Cc6bmEnB.js} +30 -27
  22. package/dist/{terminal-DlW0KNym.js → terminal-BddOL2fx.js} +2 -2
  23. package/dist/trayApp-CdLbJK5F.js +5 -0
  24. package/dist/{trayApp-DWVdr0Pr.js → trayApp-D0UYVT6n.js} +2 -2
  25. package/dist/{ts-D29CMeWZ.js → ts-Bm6FhYS4.js} +3 -3
  26. package/dist/{versionChecker-DrjMn-XI.js → versionChecker-CFyZKym2.js} +2 -2
  27. package/dist/webrtcLink-CHLgPKWj.js +4 -0
  28. package/dist/{webrtcLink-CKnjNGaV.js → webrtcLink-bWxiCBTV.js} +26 -9
  29. package/dist/webrtcRemote-RmkbzY6Y.js +683 -0
  30. package/dist/{widget-CvcvPWF9.js → widget-C6-P26Ue.js} +3 -3
  31. package/dist/{ws-Cbx5sfsd.js → ws-DCcdDRXH.js} +2 -2
  32. package/dist/{ws-DDB8jh0f.js → ws-DmiSL50p.js} +4 -4
  33. package/lab/ui/index.html +71 -3
  34. package/package.json +1 -1
  35. package/ts/remotes.ts +11 -4
  36. package/ts/serve.ts +24 -2
  37. package/ts/serveRust.spec.ts +23 -6
  38. package/ts/serveRust.ts +34 -7
  39. package/ts/subcommands.ts +4 -1
  40. package/ts/webrtcLink.spec.ts +26 -1
  41. package/ts/webrtcLink.ts +32 -5
  42. package/ts/webrtcRemote.ts +406 -102
  43. package/ts/webrtcSignal.spec.ts +40 -0
  44. package/ts/webrtcSignal.ts +59 -3
  45. package/dist/trayApp-F_Xs3C2N.js +0 -5
  46. package/dist/webrtcLink-CAdvnRM0.js +0 -4
  47. package/dist/webrtcRemote-BYy2lTTl.js +0 -408
@@ -1,4 +1,4 @@
1
- import { V as listRecords } from "./subcommands-fX4Ygx3a.js";
1
+ import { V as listRecords } from "./subcommands-Cc6bmEnB.js";
2
2
  import { a as getProvisionRoot } from "./workspaceConfig-CWGh8o36.js";
3
3
  import { existsSync } from "fs";
4
4
  import { lstat, opendir } from "fs/promises";
@@ -424,4 +424,4 @@ async function cmdWs(args) {
424
424
 
425
425
  //#endregion
426
426
  export { isPathInside as a, walkWorkspaces as c, importProvisionModule as i, workspaceStatus as l, cmdWs as n, loadProvision as o, collectWorkspaces as r, resolveOperand as s, WS_JSON_SCHEMA as t };
427
- //# sourceMappingURL=ws-Cbx5sfsd.js.map
427
+ //# sourceMappingURL=ws-DCcdDRXH.js.map
@@ -5,10 +5,10 @@ import "./pidStore-433kvh9D.js";
5
5
  import "./messageLog-Dq_ka-Bt.js";
6
6
  import "./e2e-BdLf8DvY.js";
7
7
  import "./configShared-De4bR5hH.js";
8
- import "./subcommands-fX4Ygx3a.js";
8
+ import "./subcommands-Cc6bmEnB.js";
9
9
  import "./notifyStore-DI4iKFut.js";
10
- import "./webrtcLink-CKnjNGaV.js";
11
- import "./remotes-D10E_xUn.js";
12
- import { a as isPathInside, c as walkWorkspaces, i as importProvisionModule, l as workspaceStatus, n as cmdWs, o as loadProvision, r as collectWorkspaces, s as resolveOperand, t as WS_JSON_SCHEMA } from "./ws-Cbx5sfsd.js";
10
+ import "./webrtcLink-bWxiCBTV.js";
11
+ import "./remotes-COBEhmlr.js";
12
+ import { a as isPathInside, c as walkWorkspaces, i as importProvisionModule, l as workspaceStatus, n as cmdWs, o as loadProvision, r as collectWorkspaces, s as resolveOperand, t as WS_JSON_SCHEMA } from "./ws-DCcdDRXH.js";
13
13
 
14
14
  export { WS_JSON_SCHEMA, cmdWs, collectWorkspaces, isPathInside, loadProvision, walkWorkspaces, workspaceStatus };
package/lab/ui/index.html CHANGED
@@ -8223,7 +8223,8 @@ my.translate = async (text, lang) => {
8223
8223
  } catch {}
8224
8224
  return;
8225
8225
  }
8226
- removeSource(name); // an ay-share room IS its single machine
8226
+ // An ay-share room: its own source plus one per further member.
8227
+ for (const s of roomSources(name)) removeSource(s.id);
8227
8228
  }
8228
8229
 
8229
8230
  // Exponential-backoff reconnect for an agent-yes share room (the RTCClient
@@ -8257,7 +8258,10 @@ my.translate = async (text, lang) => {
8257
8258
  try {
8258
8259
  s.client?.close?.(); // drop the stale peer AND its signaling socket
8259
8260
  } catch {}
8260
- const c = new RTCClient(s.host, s.id, s.token);
8261
+ // One client per room member: a member source names its member; the
8262
+ // room's own source sticks to the member it first got (see syncRtcMembers).
8263
+ const c = new RTCClient(s.host, s.room || s.id, s.token, { member: s.member });
8264
+ if (!s.parent) c.onroster = () => syncRtcMembers(s);
8261
8265
  c.onstate = (st) => {
8262
8266
  if (st === "failed" || st === "closed") {
8263
8267
  s.live = false;
@@ -8269,6 +8273,12 @@ my.translate = async (text, lang) => {
8269
8273
  await c.connect();
8270
8274
  } catch (e) {
8271
8275
  c.close(); // connect() already opened the signaling socket — don't
8276
+ // A member that left: its own source goes away; the room's source
8277
+ // takes whichever member the room lists first next time.
8278
+ if (String(e?.message) === "that host left the room") {
8279
+ if (s.parent) removeSource(s.id);
8280
+ else s.member = null;
8281
+ }
8272
8282
  throw e; // leak it on every failed retry of an offline room
8273
8283
  }
8274
8284
  if (s.removed) {
@@ -8277,6 +8287,10 @@ my.translate = async (text, lang) => {
8277
8287
  }
8278
8288
  s.client = c;
8279
8289
  s.tx = rtcTx(c);
8290
+ if (!s.parent) {
8291
+ s.member = c.member;
8292
+ syncRtcMembers(s);
8293
+ }
8280
8294
  // Negotiate the stdin fast path explicitly. A new page can connect to
8281
8295
  // an older host during a rolling upgrade; sending an unknown envelope
8282
8296
  // there would look successful while silently dropping every key. Keep
@@ -8363,6 +8377,59 @@ my.translate = async (text, lang) => {
8363
8377
  }, delay);
8364
8378
  }
8365
8379
 
8380
+ // A share room with several hosts (`ay serve --webrtc <link> --join`):
8381
+ // the room's own source talks to one member, and every other member the
8382
+ // room lists gets a source of its own, "<room>/<member>", so its agents
8383
+ // show under the room like a codehost room's machines do.
8384
+ function syncRtcMembers(s) {
8385
+ const roster = s.client?.roster;
8386
+ if (!roster || s.removed) return;
8387
+ const want = new Set(roster.map((h) => h.peer).filter((p) => p !== s.client.member));
8388
+ let changed = false;
8389
+ for (const peer of want) {
8390
+ const id = s.id + "/" + peer;
8391
+ if (sources.has(id)) continue;
8392
+ const m = {
8393
+ id,
8394
+ room: s.id,
8395
+ parent: s.id,
8396
+ member: peer,
8397
+ host: s.host,
8398
+ token: s.token,
8399
+ kind: "rtc",
8400
+ tx: null,
8401
+ client: null,
8402
+ live: false,
8403
+ tried: false,
8404
+ devices: new Set(),
8405
+ serverCount: 0,
8406
+ };
8407
+ sources.set(id, m);
8408
+ changed = true;
8409
+ connectRtcSource(m)
8410
+ .then(() => {
8411
+ m.live = true;
8412
+ subscribeSource(m);
8413
+ })
8414
+ .catch(() => {
8415
+ m.live = false;
8416
+ m.failCount = (m.failCount || 0) + 1;
8417
+ scheduleRtcReconnect(m);
8418
+ })
8419
+ .finally(() => {
8420
+ m.tried = true;
8421
+ renderRoomsIfOpen();
8422
+ loadList();
8423
+ });
8424
+ }
8425
+ for (const x of [...sources.values()])
8426
+ if (x.parent === s.id && !want.has(x.member)) {
8427
+ removeSource(x.id);
8428
+ changed = true;
8429
+ }
8430
+ if (changed) renderRoomsIfOpen();
8431
+ }
8432
+
8366
8433
  // Add a room to the fleet and connect it — WITHOUT dropping the others, so
8367
8434
  // every saved room streams its agents at once. Idempotent: a room already
8368
8435
  // in the fleet just refreshes. The connection runs in the background; the
@@ -8472,7 +8539,8 @@ my.translate = async (text, lang) => {
8472
8539
  return token ? { room: chName(token), token, host: CH_HOST } : null;
8473
8540
  }
8474
8541
  const m = /^([A-Za-z0-9_-]+):([^@\s]+)(?:@(.+))?$/.exec(s);
8475
- return m ? { room: m[1], token: m[2], host: m[3] } : null;
8542
+ // A host link's join secret (":j1.…") is for member hosts only.
8543
+ return m ? { room: m[1], token: m[2].replace(/:j1\..*$/, ""), host: m[3] } : null;
8476
8544
  }
8477
8545
 
8478
8546
  // Re-render the rooms panel only if it's currently open (so live-count
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.302.2",
3
+ "version": "1.303.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/remotes.ts CHANGED
@@ -2,7 +2,7 @@ import { mkdir, readFile, writeFile } from "fs/promises";
2
2
  import { homedir } from "os";
3
3
  import path from "path";
4
4
  import yaml from "yaml";
5
- import { isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
5
+ import { hasJoinSecret, isWebrtcSpec, parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
6
6
 
7
7
  function remotesPath(): string {
8
8
  const dir = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
@@ -171,8 +171,10 @@ async function resolveWebrtc(
171
171
  label: string,
172
172
  ): Promise<ResolvedRemote> {
173
173
  const { startWebrtcBridge } = await import("./webrtcRemote.ts");
174
- const bridge = await startWebrtcBridge(link);
175
- return { url: bridge.baseUrl, token: bridge.token, keyword, label };
174
+ // In a room with several hosts the keyword also picks the host
175
+ // (`<alias>:<host>:<agent>`, or the one host with a matching agent).
176
+ const bridge = await startWebrtcBridge(link, { keyword });
177
+ return { url: bridge.baseUrl, token: bridge.token, keyword: bridge.keyword, label };
176
178
  }
177
179
 
178
180
  // ---------------------------------------------------------------------------
@@ -414,7 +416,12 @@ export async function cmdConnect(rest: string[]): Promise<number> {
414
416
  const alias = aliasArg ?? defaultConnectAlias(link);
415
417
 
416
418
  if (!parsed) {
417
- await writeRemoteAlias(alias, { url: link, token: "" });
419
+ // A host link carries the room's join secret, which a viewer never needs:
420
+ // save the view part only.
421
+ await writeRemoteAlias(alias, {
422
+ url: hasJoinSecret(link) ? (toWebrtcUrl(link) ?? link) : link,
423
+ token: "",
424
+ });
418
425
  } else {
419
426
  try {
420
427
  const r = await fetch(`${parsed.url}/api/version`, {
package/ts/serve.ts CHANGED
@@ -1875,7 +1875,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1875
1875
  ` The minted room persists in ~/.agent-yes/.share-room-ayrs\n` +
1876
1876
  ` (.share-room under --no-rust); stable link across restarts,\n` +
1877
1877
  ` delete the file to rotate.\n` +
1878
- ` --share [URL] Legacy alias for --http --webrtc\n\n` +
1878
+ ` --share [URL] Legacy alias for --http --webrtc\n` +
1879
+ ` --webrtc URL --join\n` +
1880
+ ` Host URL's room as one more member next to its other\n` +
1881
+ ` hosts (all members' agents show up for every viewer).\n` +
1882
+ ` Needs AY_JOIN_SECRET; AY_NAME names this member.\n\n` +
1879
1883
  `Runtime: like \`ay <cli>\`, the Rust daemon (ayrs serve) runs by default for\n` +
1880
1884
  ` the flags it supports — --webrtc [webrtc://url], --port N, --sighost H.\n` +
1881
1885
  ` Everything else (bare \`ay serve\`, --share/--http/--host/--tls-*, the\n` +
@@ -1895,7 +1899,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
1895
1899
  ` ay serve install install as background daemon (oxmgr; pm2 fallback on Windows without the winfix build)\n` +
1896
1900
  ` ay serve status show daemon/server status (add --json for scripts)\n` +
1897
1901
  ` ay serve uninstall remove daemon\n` +
1898
- ` ay serve logs view daemon logs\n\n` +
1902
+ ` ay serve logs view daemon logs\n` +
1903
+ ` ay serve join-secret [--webrtc URL]\n` +
1904
+ ` print AY_ROOM + AY_JOIN_SECRET for members of this\n` +
1905
+ ` host's room (mints the secret; the room then requires it)\n\n` +
1899
1906
  `Once running, connect from another machine:\n` +
1900
1907
  ` ay ls <token>@<host>:<port>\n` +
1901
1908
  ` ay remote add <alias> http://<token>@<host>:<port>\n`,
@@ -1909,6 +1916,16 @@ export async function cmdServe(rest: string[]): Promise<number> {
1909
1916
  {
1910
1917
  const { planRustServe } = await import("./serveRust.ts");
1911
1918
  const plan = planRustServe(rest);
1919
+ if (plan.kind === "rust-only") {
1920
+ const { findAyrsBinary } = await import("./rustBinary.ts");
1921
+ const ayrs = findAyrsBinary();
1922
+ if (ayrs) return runAyrsServe(ayrs, plan.argv);
1923
+ process.stderr.write(
1924
+ `ay serve: ${plan.feature} needs the Rust daemon (ayrs), which was not found ` +
1925
+ `(bun run build:rs, or reinstall)\n`,
1926
+ );
1927
+ return 1;
1928
+ }
1912
1929
  if (plan.kind === "rust") {
1913
1930
  const { findAyrsBinary } = await import("./rustBinary.ts");
1914
1931
  const ayrs = findAyrsBinary();
@@ -1919,6 +1936,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1919
1936
  );
1920
1937
  } else {
1921
1938
  rest = plan.rest;
1939
+ // Never let the TypeScript server quietly host its own room instead.
1940
+ if (rest[0] === "join-secret" || rest.includes("--join")) {
1941
+ process.stderr.write(`ay serve: ${plan.reason}: joining rooms needs the Rust daemon\n`);
1942
+ return 1;
1943
+ }
1922
1944
  }
1923
1945
  }
1924
1946
 
@@ -65,13 +65,30 @@ describe("planRustServe", () => {
65
65
  kind: "ts",
66
66
  reason: "--host",
67
67
  });
68
- // ayrs pins a room by webrtc:// url only; an https room link stays on TS,
69
- // which knows how to normalise it.
70
- expect(
71
- planRustServe(["--webrtc", "https://agent-yes.com/room/#room=r1&s=e1.ab"], env),
72
- ).toMatchObject({
68
+ // Any room link spelling reaches ayrs as webrtc://; a non-link stays on TS.
69
+ expect(planRustServe(["--webrtc", "https://agent-yes.com/room/#room=r1&s=e1.ab"], env)).toEqual(
70
+ { kind: "rust", argv: ["serve", "--webrtc", "webrtc://r1:e1.ab@s.agent-yes.com"] },
71
+ );
72
+ expect(planRustServe(["--webrtc", "nonsense"], env)).toMatchObject({
73
73
  kind: "ts",
74
- reason: expect.stringContaining("webrtc:// urls only"),
74
+ reason: expect.stringContaining("not a room link"),
75
+ });
76
+ // Joining a room as a member is ayrs-only, and needs the room.
77
+ expect(planRustServe(["--webrtc", "webrtc://r1:e1.ab@s.example", "--join"], env)).toEqual({
78
+ kind: "rust-only",
79
+ argv: ["serve", "--webrtc", "webrtc://r1:e1.ab@s.example", "--join"],
80
+ feature: "--join",
81
+ });
82
+ expect(planRustServe(["--webrtc", "--join"], env)).toMatchObject({ kind: "ts" });
83
+ expect(planRustServe(["join-secret"], env)).toEqual({
84
+ kind: "rust-only",
85
+ argv: ["serve", "join-secret"],
86
+ feature: "join-secret",
87
+ });
88
+ expect(planRustServe(["join-secret", "--webrtc", "webrtc://r1:e1.ab@s.example"], env)).toEqual({
89
+ kind: "rust-only",
90
+ argv: ["serve", "--webrtc", "webrtc://r1:e1.ab@s.example", "join-secret"],
91
+ feature: "join-secret",
75
92
  });
76
93
  expect(planRustServe(["--port", "abc"], env)).toMatchObject({
77
94
  kind: "ts",
package/ts/serveRust.ts CHANGED
@@ -10,7 +10,10 @@
10
10
  *
11
11
  * What delegates today — exactly the flags `ayrs serve` accepts:
12
12
  * --webrtc (bare: load/mint the persisted room)
13
- * --webrtc <url> / --webrtc=<url> (a webrtc:// room url)
13
+ * --webrtc <link> / --webrtc=<link> (a room link, any spelling; passed on
14
+ * as webrtc://)
15
+ * --join (host that room as one of its members; ayrs only)
16
+ * join-secret (print the link + join secret members need; ayrs only)
14
17
  * --port <n> / --port=<n> (loopback HTTP console; 0 = free port)
15
18
  * --sighost <host> / --sighost=<host>
16
19
  *
@@ -22,10 +25,12 @@
22
25
  * healthcheck) and -d/--daemon: ayrs has its own OS-supervisor install and
23
26
  * the two must not manage each other's registration
24
27
  * - --share (http+webrtc over Portless), --http, --host, --token, --tls-*,
25
- * --local, https room links (ayrs pins rooms by webrtc:// url only)
28
+ * --local
26
29
  */
27
30
 
28
31
  /** Subcommands `ay serve` dispatches before flag parsing — never delegated. */
32
+ import { toWebrtcUrl } from "./webrtcLink.ts";
33
+
29
34
  const SERVE_SUBCOMMANDS = new Set([
30
35
  "install",
31
36
  "uninstall",
@@ -38,7 +43,9 @@ const SERVE_SUBCOMMANDS = new Set([
38
43
 
39
44
  export type RustServePlan =
40
45
  | { kind: "rust"; argv: string[] }
41
- | { kind: "ts"; reason: string; rest: string[] };
46
+ | { kind: "ts"; reason: string; rest: string[] }
47
+ /** Only ayrs can do this; there is no TypeScript fallback. */
48
+ | { kind: "rust-only"; argv: string[]; feature: string };
42
49
 
43
50
  /**
44
51
  * Decide whether `rest` (the argv after `serve`) can run as `ayrs serve`.
@@ -60,6 +67,15 @@ export function planRustServe(
60
67
  return { kind: "ts", reason: "bare `ay serve` (Portless HTTPS console)", rest };
61
68
  }
62
69
  const first = rest[0]!;
70
+ if (first === "join-secret") {
71
+ const flags = rest.slice(1);
72
+ const plan = flags.length
73
+ ? planRustServe(flags, env)
74
+ : ({ kind: "rust", argv: ["serve"] } as const);
75
+ if (plan.kind !== "rust" || plan.argv.some((a) => a === "--port" || a === "--join"))
76
+ return { kind: "ts", reason: `\`ay serve join-secret\` takes only --webrtc/--sighost`, rest };
77
+ return { kind: "rust-only", argv: [...plan.argv, "join-secret"], feature: first };
78
+ }
63
79
  if (SERVE_SUBCOMMANDS.has(first)) {
64
80
  return { kind: "ts", reason: `\`ay serve ${first}\``, rest };
65
81
  }
@@ -83,15 +99,21 @@ export function planRustServe(
83
99
  switch (name) {
84
100
  case "--webrtc": {
85
101
  const v = takeNext();
102
+ const url = v === undefined ? undefined : toWebrtcUrl(v, { join: true });
86
103
  if (v === undefined) {
87
104
  argv.push("--webrtc");
88
- } else if (v.startsWith("webrtc://")) {
89
- argv.push("--webrtc", v);
105
+ } else if (url) {
106
+ argv.push("--webrtc", url);
90
107
  } else {
91
- return { kind: "ts", reason: `--webrtc ${v} (ayrs takes webrtc:// urls only)`, rest };
108
+ return { kind: "ts", reason: `--webrtc ${v} (not a room link)`, rest };
92
109
  }
93
110
  break;
94
111
  }
112
+ case "--join": {
113
+ if (inline !== undefined) return { kind: "ts", reason: a, rest };
114
+ argv.push("--join");
115
+ break;
116
+ }
95
117
  case "--port": {
96
118
  const v = takeNext();
97
119
  if (v === undefined || !/^\d+$/.test(v)) {
@@ -110,6 +132,11 @@ export function planRustServe(
110
132
  return { kind: "ts", reason: a, rest };
111
133
  }
112
134
  }
135
+ if (argv.includes("--join")) {
136
+ if (!argv.some((x) => x.startsWith("webrtc://")))
137
+ return { kind: "ts", reason: "--join needs --webrtc <room link>", rest };
138
+ return { kind: "rust-only", argv, feature: "--join" };
139
+ }
113
140
  return { kind: "rust", argv };
114
141
  }
115
142
 
@@ -129,5 +156,5 @@ export function daemonShareRuntime(
129
156
  hasAyrs: () => boolean,
130
157
  env: Record<string, string | undefined> = process.env,
131
158
  ): "ts" | "rust" {
132
- return planRustServe(args, env).kind === "rust" && hasAyrs() ? "rust" : "ts";
159
+ return planRustServe(args, env).kind !== "ts" && hasAyrs() ? "rust" : "ts";
133
160
  }
package/ts/subcommands.ts CHANGED
@@ -2524,7 +2524,10 @@ async function runAllRemotesLs(opts: {
2524
2524
  const rows: HostedRow[] = [];
2525
2525
  for (const { host, records, cached } of byHost) {
2526
2526
  for (const { record, prefix } of flattenForest(buildAgentForest(records))) {
2527
- rows.push({ host, rec: record, prefix, cached: cached === true });
2527
+ // A room with several hosts tags each row with its member: `alias/member`
2528
+ // is also how `ay send alias:member:<agent>` addresses it.
2529
+ const where = typeof record.member === "string" ? `${host}/${record.member}` : host;
2530
+ rows.push({ host: where, rec: record, prefix, cached: cached === true });
2528
2531
  }
2529
2532
  }
2530
2533
 
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
2
+ import {
3
+ DEFAULT_SIGHOST,
4
+ hasJoinSecret,
5
+ isWebrtcSpec,
6
+ parseWebrtcLink,
7
+ toWebrtcUrl,
8
+ } from "./webrtcLink.ts";
3
9
 
4
10
  // A representative v2 share secret (e1.<64 hex>); parseSecret keeps the hex `s`.
5
11
  const SECRET = "e1.982610a3034f065bfe9700037b306a6afeb7dc48567064058e6c4bbc09e502c2";
@@ -87,3 +93,22 @@ describe("toWebrtcUrl", () => {
87
93
  expect(toWebrtcUrl("just-an-alias")).toBeNull();
88
94
  });
89
95
  });
96
+
97
+ describe("host links (view link + join secret)", () => {
98
+ const v = `e1.${"ab".repeat(32)}`;
99
+ const j = `j1.${"cd".repeat(32)}`;
100
+
101
+ it("keeps the join secret apart from the view secret, in every spelling", () => {
102
+ for (const link of [
103
+ `https://agent-yes.com/w/#r1:${v}:${j}`,
104
+ `https://agent-yes.com/room/#room=r1&s=${v}&j=${j}`,
105
+ `webrtc://r1:${v}:${j}@s.agent-yes.com`,
106
+ ]) {
107
+ expect(hasJoinSecret(link)).toBe(true);
108
+ expect(parseWebrtcLink(link)).toMatchObject({ room: "r1", s: "ab".repeat(32) });
109
+ expect(toWebrtcUrl(link)).toBe(`webrtc://r1:${v}@s.agent-yes.com`);
110
+ expect(toWebrtcUrl(link, { join: true })).toBe(`webrtc://r1:${v}:${j}@s.agent-yes.com`);
111
+ }
112
+ expect(hasJoinSecret(`https://agent-yes.com/w/#r1:${v}`)).toBe(false);
113
+ });
114
+ });
package/ts/webrtcLink.ts CHANGED
@@ -11,6 +11,8 @@ export interface WebrtcLink {
11
11
  room: string;
12
12
  s: string;
13
13
  host: string;
14
+ /** The room's join secret (`j1.<64hex>`), on a HOST link only. */
15
+ join?: string;
14
16
  }
15
17
 
16
18
  /** room + RAW token (marker and all), before parseSecret strips the `e1.`. */
@@ -18,6 +20,18 @@ interface RawLink {
18
20
  room: string;
19
21
  token: string;
20
22
  host: string;
23
+ join?: string;
24
+ }
25
+
26
+ // A HOST link is a view link that also carries the room's join secret, for the
27
+ // machines that host in the room as its members (`ay serve --webrtc <host link>
28
+ // --join`): `#<room>:e1.<view>:j1.<join>[@<sighost>]`, `&j=j1.<join>` in the
29
+ // key=value form, `webrtc://<room>:e1.<view>:j1.<join>@<host>`. Viewers ignore
30
+ // the join part; it lets a machine host in the room, so keep host links private.
31
+ function splitJoin(raw: RawLink): RawLink {
32
+ const i = raw.token.indexOf(":j1.");
33
+ if (i < 0) return raw;
34
+ return { ...raw, token: raw.token.slice(0, i), join: raw.token.slice(i + 1) };
21
35
  }
22
36
 
23
37
  // The fragment of a /room/ link is a key=value set, parsed with URLSearchParams:
@@ -40,19 +54,24 @@ function parseFragment(frag: string): RawLink | null {
40
54
  const room = q.get("room");
41
55
  const token = q.get("s");
42
56
  if (!room || !token) return null;
43
- return { room, token, host: q.get("sig") || DEFAULT_SIGHOST };
57
+ const join = q.get("j") || undefined;
58
+ return { room, token, host: q.get("sig") || DEFAULT_SIGHOST, ...(join ? { join } : {}) };
44
59
  }
45
60
  const at = frag.split("@");
46
61
  const seg = at[0]!;
47
62
  const i = seg.indexOf(":");
48
63
  if (i < 0) return null;
49
- return { room: seg.slice(0, i), token: seg.slice(i + 1), host: at[1] || DEFAULT_SIGHOST };
64
+ return splitJoin({
65
+ room: seg.slice(0, i),
66
+ token: seg.slice(i + 1),
67
+ host: at[1] || DEFAULT_SIGHOST,
68
+ });
50
69
  }
51
70
 
52
71
  /** Parse a share link WITHOUT validating/stripping the secret's version marker. */
53
72
  function parseRaw(link: string): RawLink | null {
54
73
  const wr = /^webrtc:\/\/([^:]+):([^@]+)@(.+)$/.exec(link);
55
- if (wr) return { room: wr[1]!, token: wr[2]!, host: wr[3]! };
74
+ if (wr) return splitJoin({ room: wr[1]!, token: wr[2]!, host: wr[3]! });
56
75
  if (/^https?:\/\//.test(link) && link.includes("#"))
57
76
  return parseFragment(link.split("#")[1] ?? "");
58
77
  return null;
@@ -78,9 +97,17 @@ export function parseWebrtcLink(link: string): WebrtcLink | null {
78
97
  * Returns the RAW token, so a legacy markerless room round-trips unchanged
79
98
  * instead of being handed a marker it never had.
80
99
  */
81
- export function toWebrtcUrl(link: string): string | null {
100
+ export function toWebrtcUrl(link: string, opts: { join?: boolean } = {}): string | null {
82
101
  const raw = parseRaw(link);
83
- return raw ? `webrtc://${raw.room}:${raw.token}@${raw.host}` : null;
102
+ if (!raw) return null;
103
+ // The join secret goes on only where a member host needs it (ayrs --join).
104
+ const join = opts.join && raw.join ? `:${raw.join}` : "";
105
+ return `webrtc://${raw.room}:${raw.token}${join}@${raw.host}`;
106
+ }
107
+
108
+ /** True when the link is a HOST link (carries the room's join secret). */
109
+ export function hasJoinSecret(link: string): boolean {
110
+ return !!parseRaw(link)?.join;
84
111
  }
85
112
 
86
113
  /** True if `spec` looks like a WebRTC share link (vs. an http remote or alias). */