agent-yes 1.144.0 → 1.146.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.
@@ -2,7 +2,8 @@ import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
2
2
  import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
3
3
  import { a as updateGlobalPidStatus, i as readGlobalPids } from "./globalPidIndex-DlmmJlO8.js";
4
4
  import { t as loadSharedCliDefaults } from "./configShared-C1C04bbq.js";
5
- import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-PKKjfTI1.js";
5
+ import { n as isWebrtcSpec } from "./webrtcLink-BWhuA74k.js";
6
+ import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-qK6uozO4.js";
6
7
  import ms from "ms";
7
8
  import yargs from "yargs";
8
9
  import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises";
@@ -553,19 +554,19 @@ async function runSubcommand(argv) {
553
554
  case "restart": return await cmdRestart(rest);
554
555
  case "note": return await cmdNote(rest);
555
556
  case "serve": {
556
- const { cmdServe } = await import("./serve-QXNICu0K.js");
557
+ const { cmdServe } = await import("./serve-CbXS8sWv.js");
557
558
  return cmdServe(rest);
558
559
  }
559
560
  case "setup": {
560
- const { cmdSetup } = await import("./setup-dJlAGJEz.js");
561
+ const { cmdSetup } = await import("./setup-C8vJmagV.js");
561
562
  return cmdSetup(rest);
562
563
  }
563
564
  case "schedule": {
564
- const { cmdSchedule } = await import("./schedule-BKyOLSSe.js");
565
+ const { cmdSchedule } = await import("./schedule-B34TqMjC.js");
565
566
  return cmdSchedule(rest);
566
567
  }
567
568
  case "remote": {
568
- const { cmdRemote } = await import("./remotes-BdankQeI.js");
569
+ const { cmdRemote } = await import("./remotes-5lHLAAqn.js");
569
570
  return cmdRemote(rest);
570
571
  }
571
572
  case "reap":
@@ -893,15 +894,26 @@ async function fetchRemoteRecordsRaw(url, token, opts) {
893
894
  if (opts.all) params.set("all", "1");
894
895
  if (opts.active) params.set("active", "1");
895
896
  if (opts.keyword) params.set("keyword", opts.keyword);
897
+ let bridge = null;
896
898
  try {
897
- const res = await fetch(`${url}/api/ls?${params}`, {
898
- headers: { Authorization: `Bearer ${token}` },
899
- signal: AbortSignal.timeout(5e3)
899
+ let base = url;
900
+ let bearer = token;
901
+ if (isWebrtcSpec(url)) {
902
+ const { startWebrtcBridge } = await import("./webrtcRemote-jGM3ZHK3.js");
903
+ bridge = await startWebrtcBridge(url);
904
+ base = bridge.baseUrl;
905
+ bearer = bridge.token;
906
+ }
907
+ const res = await fetch(`${base}/api/ls?${params}`, {
908
+ headers: { Authorization: `Bearer ${bearer}` },
909
+ signal: AbortSignal.timeout(8e3)
900
910
  });
901
911
  if (!res.ok) return [];
902
912
  return await res.json();
903
913
  } catch {
904
914
  return [];
915
+ } finally {
916
+ bridge?.close();
905
917
  }
906
918
  }
907
919
  async function runAllRemotesLs(opts) {
@@ -2581,4 +2593,4 @@ async function cmdResultSet(rest) {
2581
2593
 
2582
2594
  //#endregion
2583
2595
  export { stopTipForCli as C, snapshotStatus as S, renderRawLog as _, cursorAbs as a, resolveReadWindow as b, extractTaskCounts as c, isExitRequest as d, isPidAlive as f, readNotes as g, matchKeyword as h, controlCodeFromName as i, finalizedLines as l, listRecords as m, READ_PAGE_DEFAULT as n, deriveLiveStatus as o, isSubcommand as p, cmdHelp as r, extractNeedsInput as s, GRACEFUL_EXIT_COMMANDS as t, isAgentStuck as u, renderRawLogLines as v, writeToIpc as w, runSubcommand as x, resolveOne as y };
2584
- //# sourceMappingURL=subcommands-o3WRyHb9.js.map
2596
+ //# sourceMappingURL=subcommands-DWf6MHH6.js.map
@@ -1,7 +1,9 @@
1
1
  import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-DlmmJlO8.js";
3
3
  import "./configShared-C1C04bbq.js";
4
- import "./remotes-PKKjfTI1.js";
5
- import { C as stopTipForCli, S as snapshotStatus, _ as renderRawLog, a as cursorAbs, b as resolveReadWindow, c as extractTaskCounts, d as isExitRequest, f as isPidAlive, g as readNotes, h as matchKeyword, i as controlCodeFromName, l as finalizedLines, m as listRecords, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isSubcommand, r as cmdHelp, s as extractNeedsInput, t as GRACEFUL_EXIT_COMMANDS, u as isAgentStuck, v as renderRawLogLines, w as writeToIpc, x as runSubcommand, y as resolveOne } from "./subcommands-o3WRyHb9.js";
4
+ import "./e2e-ClOI_aqV.js";
5
+ import "./webrtcLink-BWhuA74k.js";
6
+ import "./remotes-qK6uozO4.js";
7
+ import { C as stopTipForCli, S as snapshotStatus, _ as renderRawLog, a as cursorAbs, b as resolveReadWindow, c as extractTaskCounts, d as isExitRequest, f as isPidAlive, g as readNotes, h as matchKeyword, i as controlCodeFromName, l as finalizedLines, m as listRecords, n as READ_PAGE_DEFAULT, o as deriveLiveStatus, p as isSubcommand, r as cmdHelp, s as extractNeedsInput, t as GRACEFUL_EXIT_COMMANDS, u as isAgentStuck, v as renderRawLogLines, w as writeToIpc, x as runSubcommand, y as resolveOne } from "./subcommands-DWf6MHH6.js";
6
8
 
7
9
  export { cmdHelp, isSubcommand, runSubcommand };
@@ -187,4 +187,4 @@ async function startTray() {
187
187
 
188
188
  //#endregion
189
189
  export { ensureTray, startTray };
190
- //# sourceMappingURL=tray-DsTv-C04.js.map
190
+ //# sourceMappingURL=tray-5Hezw9uj.js.map
@@ -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-BjKAfiI-.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-B6EYG8nW.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-_eJa3DaX.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-V4qvXgAw.js";
5
5
  import { t as PidStore } from "./pidStore-fqXqTKkh.js";
@@ -1800,4 +1800,4 @@ function sleep(ms) {
1800
1800
 
1801
1801
  //#endregion
1802
1802
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1803
- //# sourceMappingURL=ts-Qh0Z7nsZ.js.map
1803
+ //# sourceMappingURL=ts-CLOLKDtz.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.144.0";
10
+ var version = "1.146.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-BjKAfiI-.js.map
218
+ //# sourceMappingURL=versionChecker-B6EYG8nW.js.map
@@ -0,0 +1,45 @@
1
+ import { u as parseSecret } from "./e2e-ClOI_aqV.js";
2
+
3
+ //#region ts/webrtcLink.ts
4
+ const SIGNAL_SUBPROTOCOL = "ay-signal-1";
5
+ const DEFAULT_SIGHOST = "s.agent-yes.com";
6
+ /**
7
+ * Parse a share link into { room, secret, signaling-host }. Accepts:
8
+ * webrtc://<room>:<token>@<host>
9
+ * https://<anyhost>/w/#<room>:<token> (signaling host defaults to s.agent-yes.com)
10
+ * https://<anyhost>/w/#<room>:<token>@<sighost> (explicit signaling host)
11
+ * Returns null if the string isn't a recognizable share link.
12
+ */
13
+ function parseWebrtcLink(link) {
14
+ const wr = /^webrtc:\/\/([^:]+):([^@]+)@(.+)$/.exec(link);
15
+ if (wr) {
16
+ const { s } = parseSecret(wr[2]);
17
+ return {
18
+ room: wr[1],
19
+ s,
20
+ host: wr[3]
21
+ };
22
+ }
23
+ if (/^https?:\/\//.test(link) && link.includes("#")) {
24
+ const at = (link.split("#")[1] ?? "").split("@");
25
+ const host = at[1] || DEFAULT_SIGHOST;
26
+ const seg = at[0];
27
+ const i = seg.indexOf(":");
28
+ if (i < 0) return null;
29
+ const { s } = parseSecret(seg.slice(i + 1));
30
+ return {
31
+ room: seg.slice(0, i),
32
+ s,
33
+ host
34
+ };
35
+ }
36
+ return null;
37
+ }
38
+ /** True if `spec` looks like a WebRTC share link (vs. an http remote or alias). */
39
+ function isWebrtcSpec(spec) {
40
+ return spec.startsWith("webrtc://") || /^https?:\/\//.test(spec) && spec.includes("#");
41
+ }
42
+
43
+ //#endregion
44
+ export { isWebrtcSpec as n, parseWebrtcLink as r, SIGNAL_SUBPROTOCOL as t };
45
+ //# sourceMappingURL=webrtcLink-BWhuA74k.js.map
@@ -0,0 +1,259 @@
1
+ import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-ClOI_aqV.js";
2
+ import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-BWhuA74k.js";
3
+ import { RTCPeerConnection } from "node-datachannel/polyfill";
4
+
5
+ //#region ts/webrtcRemote.ts
6
+ const CONNECT_TIMEOUT_MS = 25e3;
7
+ /** A live, key-confirmed WebRTC connection to a single share room. */
8
+ var WebRtcConn = class {
9
+ ws;
10
+ pc = null;
11
+ dc = null;
12
+ send = { sendCtr: 0n };
13
+ recv = { lastSeen: -1n };
14
+ keyC2H = null;
15
+ keyH2C = null;
16
+ th = null;
17
+ hostPeer = null;
18
+ myNonce = randomHex(16);
19
+ confirmedIn = false;
20
+ confirmedOut = false;
21
+ confirmed = false;
22
+ sendChain = Promise.resolve();
23
+ recvChain = Promise.resolve();
24
+ idCounter = 0;
25
+ pending = /* @__PURE__ */ new Map();
26
+ ready;
27
+ resolveReady;
28
+ rejectReady;
29
+ constructor(link) {
30
+ this.link = link;
31
+ this.ready = new Promise((res, rej) => {
32
+ this.resolveReady = res;
33
+ this.rejectReady = rej;
34
+ });
35
+ const timer = setTimeout(() => this.rejectReady(/* @__PURE__ */ new Error(`WebRTC connect timeout after ${CONNECT_TIMEOUT_MS}ms`)), CONNECT_TIMEOUT_MS);
36
+ this.ready.then(() => clearTimeout(timer), () => clearTimeout(timer));
37
+ this.ws = this.dial();
38
+ }
39
+ dial() {
40
+ const { room, host } = this.link;
41
+ const ws = new WebSocket(`wss://${host}/${room}`, [SIGNAL_SUBPROTOCOL]);
42
+ ws.onopen = async () => {
43
+ const authToken = await deriveAuthToken(this.link.s, room, host);
44
+ ws.send(JSON.stringify({
45
+ type: "hello",
46
+ role: "client",
47
+ v: 2,
48
+ token: authToken
49
+ }));
50
+ };
51
+ ws.onerror = (e) => this.rejectReady(/* @__PURE__ */ new Error(`signaling error: ${String(e?.message ?? e)}`));
52
+ ws.onclose = () => {
53
+ if (!this.confirmed) this.rejectReady(/* @__PURE__ */ new Error("signaling closed before connect"));
54
+ };
55
+ ws.onmessage = (ev) => void this.onSignal(ev);
56
+ return ws;
57
+ }
58
+ async onSignal(ev) {
59
+ const m = JSON.parse(typeof ev.data === "string" ? ev.data : await ev.data.text());
60
+ if (m.type === "offer") {
61
+ this.hostPeer = m.from;
62
+ const pc = new RTCPeerConnection({ iceServers: m.iceServers || [] });
63
+ this.pc = pc;
64
+ pc.onicecandidate = (e) => {
65
+ if (e.candidate) this.ws.send(JSON.stringify({
66
+ type: "candidate",
67
+ to: this.hostPeer,
68
+ candidate: e.candidate
69
+ }));
70
+ };
71
+ pc.ondatachannel = (e) => {
72
+ const dc = e.channel;
73
+ this.dc = dc;
74
+ dc.binaryType = "arraybuffer";
75
+ dc.onopen = () => this.enqueueSeal(FLAG_CONFIRM, {
76
+ t: "confirm",
77
+ nonce: this.myNonce
78
+ });
79
+ dc.onmessage = (ev2) => {
80
+ this.recvChain = this.recvChain.then(() => this.onFrame(ev2.data)).catch(() => {});
81
+ };
82
+ };
83
+ await pc.setRemoteDescription({
84
+ type: "offer",
85
+ sdp: m.sdp
86
+ });
87
+ const answer = await pc.createAnswer();
88
+ await pc.setLocalDescription(answer);
89
+ this.th = await computeTranscriptHash(m.sdp, pc.localDescription.sdp);
90
+ const keys = await deriveDirKeys(this.link.s, this.th);
91
+ this.keyC2H = keys.keyC2H;
92
+ this.keyH2C = keys.keyH2C;
93
+ this.ws.send(JSON.stringify({
94
+ type: "answer",
95
+ to: this.hostPeer,
96
+ sdp: pc.localDescription.sdp
97
+ }));
98
+ } else if (m.type === "candidate" && this.pc) await this.pc.addIceCandidate(m.candidate).catch(() => {});
99
+ }
100
+ enqueueSeal(flags, obj) {
101
+ this.sendChain = this.sendChain.then(async () => {
102
+ if (!this.dc || this.dc.readyState !== "open" || !this.keyC2H || !this.th) return;
103
+ const frame = await seal(this.keyC2H, this.send, flags, this.th, packEnvelope(obj));
104
+ try {
105
+ this.dc.send(frame);
106
+ } catch {}
107
+ });
108
+ return this.sendChain;
109
+ }
110
+ async onFrame(data) {
111
+ if (typeof data === "string" || !this.keyH2C || !this.th) return;
112
+ let env;
113
+ try {
114
+ const { plaintext } = await open(this.keyH2C, data, this.th, this.recv);
115
+ env = unpackEnvelope(plaintext);
116
+ } catch {
117
+ return;
118
+ }
119
+ if (!this.confirmed) {
120
+ if (!env || env.t !== "confirm") return;
121
+ if (typeof env.nonce === "string" && !this.confirmedOut) {
122
+ await this.enqueueSeal(FLAG_CONFIRM, {
123
+ t: "confirm",
124
+ nonce: this.myNonce,
125
+ echo: env.nonce
126
+ });
127
+ this.confirmedOut = true;
128
+ }
129
+ if (env.echo && env.echo === this.myNonce) this.confirmedIn = true;
130
+ if (this.confirmedIn && this.confirmedOut) {
131
+ this.confirmed = true;
132
+ this.resolveReady();
133
+ }
134
+ return;
135
+ }
136
+ if (!env || env.t === "confirm") return;
137
+ const p = this.pending.get(env.id);
138
+ if (!p) return;
139
+ if (env.t === "res") p.onRes(env.status, env.ct);
140
+ else if (env.t === "data") p.onData(env.chunk);
141
+ else if (env.t === "end") p.onEnd(env.error);
142
+ }
143
+ /**
144
+ * Issue one request over the channel. Resolves once the response head (status,
145
+ * content-type) arrives; the body is a ReadableStream fed by `data` frames as
146
+ * they land, so streaming endpoints (SSE `tail`) flow without buffering.
147
+ */
148
+ request(method, path, body) {
149
+ const id = String(++this.idCounter);
150
+ return new Promise((resolve, reject) => {
151
+ let controller;
152
+ const enc = new TextEncoder();
153
+ let head = false;
154
+ const stream = new ReadableStream({
155
+ start: (c) => {
156
+ controller = c;
157
+ },
158
+ cancel: () => {
159
+ this.pending.delete(id);
160
+ this.enqueueSeal(0, {
161
+ t: "abort",
162
+ id
163
+ });
164
+ }
165
+ });
166
+ this.pending.set(id, {
167
+ onRes: (status, ct) => {
168
+ if (!head) {
169
+ head = true;
170
+ resolve({
171
+ status,
172
+ ct,
173
+ stream
174
+ });
175
+ }
176
+ },
177
+ onData: (chunk) => {
178
+ try {
179
+ controller.enqueue(enc.encode(chunk));
180
+ } catch {}
181
+ },
182
+ onEnd: (error) => {
183
+ this.pending.delete(id);
184
+ if (error) {
185
+ if (!head) {
186
+ head = true;
187
+ reject(new Error(error));
188
+ }
189
+ try {
190
+ controller.error(new Error(error));
191
+ } catch {}
192
+ } else try {
193
+ controller.close();
194
+ } catch {}
195
+ }
196
+ });
197
+ this.enqueueSeal(0, {
198
+ t: "req",
199
+ id,
200
+ method,
201
+ path,
202
+ body
203
+ });
204
+ });
205
+ }
206
+ close() {
207
+ try {
208
+ this.ws.close();
209
+ } catch {}
210
+ try {
211
+ this.pc?.close();
212
+ } catch {}
213
+ }
214
+ };
215
+ /**
216
+ * Connect to a share room and start a local HTTP server that forwards every
217
+ * request over the encrypted DataChannel. Returns the loopback base URL the
218
+ * existing remote commands can `fetch()` against. The process owns teardown:
219
+ * `ay` subcommands `process.exit()` when done, which closes the socket/peer.
220
+ */
221
+ async function startWebrtcBridge(link) {
222
+ const parsed = parseWebrtcLink(link);
223
+ if (!parsed) throw new Error(`not a WebRTC share link: ${link}`);
224
+ const conn = new WebRtcConn(parsed);
225
+ await conn.ready;
226
+ const server = Bun.serve({
227
+ port: 0,
228
+ hostname: "127.0.0.1",
229
+ idleTimeout: 0,
230
+ async fetch(req) {
231
+ const u = new URL(req.url);
232
+ const pathWithQuery = u.pathname + u.search;
233
+ const body = req.method !== "GET" && req.method !== "HEAD" ? await req.text() : void 0;
234
+ try {
235
+ const { status, ct, stream } = await conn.request(req.method, pathWithQuery, body);
236
+ return new Response(stream, {
237
+ status,
238
+ headers: ct ? { "content-type": ct } : {}
239
+ });
240
+ } catch (e) {
241
+ return new Response(String(e?.message ?? e), { status: 502 });
242
+ }
243
+ }
244
+ });
245
+ return {
246
+ baseUrl: `http://127.0.0.1:${server.port}`,
247
+ token: "webrtc",
248
+ close: () => {
249
+ try {
250
+ server.stop(true);
251
+ } catch {}
252
+ conn.close();
253
+ }
254
+ };
255
+ }
256
+
257
+ //#endregion
258
+ export { startWebrtcBridge };
259
+ //# sourceMappingURL=webrtcRemote-jGM3ZHK3.js.map
@@ -53,4 +53,4 @@ function resolveSpawnCwd(input) {
53
53
 
54
54
  //#endregion
55
55
  export { resolveSpawnCwd as n, setWorkspaceRoot as r, getWorkspaceRoot as t };
56
- //# sourceMappingURL=workspaceConfig-BCOqRBEW.js.map
56
+ //# sourceMappingURL=workspaceConfig-B3ylOZAO.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.144.0",
3
+ "version": "1.146.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,6 +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 } from "./webrtcLink.ts";
5
6
 
6
7
  function remotesPath(): string {
7
8
  const dir = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
@@ -79,6 +80,10 @@ export function parseDirectRemoteSpec(
79
80
  * Returns null if the spec doesn't match any remote.
80
81
  */
81
82
  export async function resolveRemoteSpec(spec: string): Promise<ResolvedRemote | null> {
83
+ // Inline WebRTC share link: `ay ls webrtc://…` or `ay ls https://…/w/#room:token`.
84
+ // These carry their own secret and have no keyword (use an alias to add one).
85
+ if (isWebrtcSpec(spec)) return resolveWebrtc(spec, undefined);
86
+
82
87
  const direct = parseDirectRemoteSpec(spec);
83
88
  if (direct) {
84
89
  return { url: direct.baseUrl, token: direct.token, keyword: direct.keyword };
@@ -92,9 +97,22 @@ export async function resolveRemoteSpec(spec: string): Promise<ResolvedRemote |
92
97
  const remotes = await readRemotes();
93
98
  const cfg = remotes.get(alias);
94
99
  if (!cfg) return null;
100
+ // A saved alias may point at a WebRTC link; bridge it just like an inline one.
101
+ if (isWebrtcSpec(cfg.url)) return resolveWebrtc(cfg.url, keyword);
95
102
  return { url: cfg.url, token: cfg.token, keyword };
96
103
  }
97
104
 
105
+ /**
106
+ * Start a local HTTP↔WebRTC bridge for a share link and present it as an
107
+ * ordinary http remote, so every fetch-based remote command works unchanged.
108
+ * The bridge lives for the rest of the process (torn down on `process.exit`).
109
+ */
110
+ async function resolveWebrtc(link: string, keyword?: string): Promise<ResolvedRemote> {
111
+ const { startWebrtcBridge } = await import("./webrtcRemote.ts");
112
+ const bridge = await startWebrtcBridge(link);
113
+ return { url: bridge.baseUrl, token: bridge.token, keyword };
114
+ }
115
+
98
116
  // ---------------------------------------------------------------------------
99
117
  // ay remote subcommand
100
118
  // ---------------------------------------------------------------------------
@@ -108,7 +126,9 @@ export async function cmdRemote(rest: string[]): Promise<number> {
108
126
  `Manage saved remote server aliases.\n\n` +
109
127
  `Subcommands:\n` +
110
128
  ` ay remote ls list configured remotes\n` +
111
- ` ay remote add <alias> http://<token>@<host>:<port> add a remote\n` +
129
+ ` ay remote add <alias> http://<token>@<host>:<port> add an http remote\n` +
130
+ ` ay remote add <alias> webrtc://<room>:<token>@<host> add a WebRTC share remote\n` +
131
+ ` ay remote add <alias> https://agent-yes.com/w/#<room>:<token> (share link form)\n` +
112
132
  ` ay remote rm <alias> remove a remote\n\n` +
113
133
  `Once added, use the alias anywhere a keyword is accepted:\n` +
114
134
  ` ay ls <alias>\n` +
@@ -145,6 +165,13 @@ export async function cmdRemote(rest: string[]): Promise<number> {
145
165
  );
146
166
  return 1;
147
167
  }
168
+ // WebRTC share links carry their own secret — store verbatim (token in the link).
169
+ if (isWebrtcSpec(rawUrl)) {
170
+ await writeRemoteAlias(alias, { url: rawUrl, token: "" });
171
+ process.stdout.write(`remote '${alias}' added → ${rawUrl} (webrtc)\n`);
172
+ process.stderr.write(`\n ay ls ${alias} # list agents on ${alias}\n`);
173
+ return 0;
174
+ }
148
175
  let url: string, token: string;
149
176
  try {
150
177
  const parsed = new URL(rawUrl);
package/ts/subcommands.ts CHANGED
@@ -32,6 +32,7 @@ import { invokedCliName } from "./invokedCli.ts";
32
32
  import type { AgentCliConfig } from "./index.ts";
33
33
  import yargs from "yargs";
34
34
  import { type ResolvedRemote, readRemotes, resolveRemoteSpec } from "./remotes.ts";
35
+ import { isWebrtcSpec } from "./webrtcLink.ts";
35
36
 
36
37
  // ---------------------------------------------------------------------------
37
38
  // notes store (~/.agent-yes/notes.jsonl)
@@ -865,15 +866,27 @@ async function fetchRemoteRecordsRaw(
865
866
  if (opts.all) params.set("all", "1");
866
867
  if (opts.active) params.set("active", "1");
867
868
  if (opts.keyword) params.set("keyword", opts.keyword);
869
+ // WebRTC remotes have no http port — bridge them, then fetch the loopback URL.
870
+ let bridge: { baseUrl: string; token: string; close: () => void } | null = null;
868
871
  try {
869
- const res = await fetch(`${url}/api/ls?${params}`, {
870
- headers: { Authorization: `Bearer ${token}` },
871
- signal: AbortSignal.timeout(5000),
872
+ let base = url;
873
+ let bearer = token;
874
+ if (isWebrtcSpec(url)) {
875
+ const { startWebrtcBridge } = await import("./webrtcRemote.ts");
876
+ bridge = await startWebrtcBridge(url);
877
+ base = bridge.baseUrl;
878
+ bearer = bridge.token;
879
+ }
880
+ const res = await fetch(`${base}/api/ls?${params}`, {
881
+ headers: { Authorization: `Bearer ${bearer}` },
882
+ signal: AbortSignal.timeout(8000),
872
883
  });
873
884
  if (!res.ok) return [];
874
885
  return (await res.json()) as any[];
875
886
  } catch {
876
887
  return [];
888
+ } finally {
889
+ bridge?.close();
877
890
  }
878
891
  }
879
892
 
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
3
+
4
+ // A representative v2 share secret (e1.<64 hex>); parseSecret keeps the hex `s`.
5
+ const SECRET = "e1.982610a3034f065bfe9700037b306a6afeb7dc48567064058e6c4bbc09e502c2";
6
+
7
+ describe("isWebrtcSpec", () => {
8
+ it("accepts webrtc:// links", () => {
9
+ expect(isWebrtcSpec(`webrtc://r1:${SECRET}@s.agent-yes.com`)).toBe(true);
10
+ });
11
+ it("accepts https share links (have a # fragment)", () => {
12
+ expect(isWebrtcSpec(`https://agent-yes.com/w/#r1:${SECRET}`)).toBe(true);
13
+ expect(isWebrtcSpec(`http://localhost:8080/w/#r1:${SECRET}`)).toBe(true);
14
+ });
15
+ it("rejects http remotes and bare aliases", () => {
16
+ expect(isWebrtcSpec("token@192.168.1.5:7432")).toBe(false);
17
+ expect(isWebrtcSpec("work-mac")).toBe(false);
18
+ expect(isWebrtcSpec("work-mac:claude")).toBe(false);
19
+ expect(isWebrtcSpec("http://192.168.1.5:7432")).toBe(false); // no fragment
20
+ });
21
+ });
22
+
23
+ describe("parseWebrtcLink", () => {
24
+ it("parses webrtc://room:token@host", () => {
25
+ const r = parseWebrtcLink(`webrtc://r223104:${SECRET}@example.com`);
26
+ expect(r).toEqual({ room: "r223104", s: expect.any(String), host: "example.com" });
27
+ expect(r!.s.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ it("parses an https share link and defaults the signaling host", () => {
31
+ const r = parseWebrtcLink(`https://agent-yes.com/w/#r223104:${SECRET}`);
32
+ expect(r).toMatchObject({ room: "r223104", host: DEFAULT_SIGHOST });
33
+ });
34
+
35
+ it("honors an explicit @sighost in the fragment", () => {
36
+ const r = parseWebrtcLink(`https://agent-yes.com/w/#r1:${SECRET}@sig.example.com`);
37
+ expect(r).toMatchObject({ room: "r1", host: "sig.example.com" });
38
+ });
39
+
40
+ it("returns null for non-share strings and malformed fragments", () => {
41
+ expect(parseWebrtcLink("token@host:7432")).toBeNull();
42
+ expect(parseWebrtcLink("just-an-alias")).toBeNull();
43
+ expect(parseWebrtcLink("https://agent-yes.com/w/#noColonHere")).toBeNull();
44
+ });
45
+ });
@@ -0,0 +1,45 @@
1
+ // Pure parsing/detection for WebRTC share links. Kept free of node-datachannel
2
+ // (the native WebRTC dep) so callers — and resolveRemoteSpec on every remote
3
+ // command — can detect/parse a link without loading the native module, and so
4
+ // the helpers stay unit-testable. The actual connection lives in webrtcRemote.ts.
5
+ import { parseSecret } from "../lab/ui/e2e.js";
6
+
7
+ export const SIGNAL_SUBPROTOCOL = "ay-signal-1";
8
+ export const DEFAULT_SIGHOST = "s.agent-yes.com";
9
+
10
+ export interface WebrtcLink {
11
+ room: string;
12
+ s: string;
13
+ host: string;
14
+ }
15
+
16
+ /**
17
+ * Parse a share link into { room, secret, signaling-host }. Accepts:
18
+ * webrtc://<room>:<token>@<host>
19
+ * https://<anyhost>/w/#<room>:<token> (signaling host defaults to s.agent-yes.com)
20
+ * https://<anyhost>/w/#<room>:<token>@<sighost> (explicit signaling host)
21
+ * Returns null if the string isn't a recognizable share link.
22
+ */
23
+ export function parseWebrtcLink(link: string): WebrtcLink | null {
24
+ const wr = /^webrtc:\/\/([^:]+):([^@]+)@(.+)$/.exec(link);
25
+ if (wr) {
26
+ const { s } = parseSecret(wr[2]!);
27
+ return { room: wr[1]!, s, host: wr[3]! };
28
+ }
29
+ if (/^https?:\/\//.test(link) && link.includes("#")) {
30
+ const frag = link.split("#")[1] ?? "";
31
+ const at = frag.split("@");
32
+ const host = at[1] || DEFAULT_SIGHOST;
33
+ const seg = at[0]!;
34
+ const i = seg.indexOf(":");
35
+ if (i < 0) return null;
36
+ const { s } = parseSecret(seg.slice(i + 1));
37
+ return { room: seg.slice(0, i), s, host };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** True if `spec` looks like a WebRTC share link (vs. an http remote or alias). */
43
+ export function isWebrtcSpec(spec: string): boolean {
44
+ return spec.startsWith("webrtc://") || (/^https?:\/\//.test(spec) && spec.includes("#"));
45
+ }