agent-yes 1.121.0 → 1.122.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 (39) hide show
  1. package/default.config.yaml +27 -4
  2. package/dist/SUPPORTED_CLIS-BVqe3k7F.js +8 -0
  3. package/dist/{SUPPORTED_CLIS-O57LGUEG.js → SUPPORTED_CLIS-DFYbm2uk.js} +2 -2
  4. package/dist/{agent-yes.config-kmtJKJHk.js → agent-yes.config-z-IPzH5U.js} +3 -2
  5. package/dist/cli.js +5 -5
  6. package/dist/index.js +2 -2
  7. package/dist/reaper-Dj8R7ltI.js +64 -0
  8. package/dist/reaper-HqcUms2d.js +3 -0
  9. package/dist/{remotes-DavR4Hca.js → remotes-CpGcTr7A.js} +1 -1
  10. package/dist/{remotes-BufkGk0e.js → remotes-D2fqaRU8.js} +1 -1
  11. package/dist/schedule-CJaNc82S.js +144 -0
  12. package/dist/{serve-D2czcYNC.js → serve-VczpuYDk.js} +121 -26
  13. package/dist/{setup-f1FIFcZm.js → setup-DveWqmSR.js} +5 -42
  14. package/dist/{share-B6QVr5D1.js → share-ClsUSd_0.js} +69 -9
  15. package/dist/{subcommands-CzpZQHO6.js → subcommands-CmNGbbIA.js} +15 -6
  16. package/dist/{subcommands-DobVXouH.js → subcommands-CzZ4uBIa.js} +2 -2
  17. package/dist/{tray-B8_rx1iu.js → tray-DjCIyakK.js} +22 -10
  18. package/dist/{ts-D91dm1E0.js → ts-7kSDmCpQ.js} +76 -7
  19. package/dist/{versionChecker-CAtpgnoQ.js → versionChecker-B5vxV_hH.js} +13 -19
  20. package/dist/workspaceConfig-XP2NEWmV.js +56 -0
  21. package/lab/ui/index.html +63 -32
  22. package/package.json +1 -1
  23. package/ts/autoRetry.spec.ts +19 -0
  24. package/ts/autoRetry.ts +16 -0
  25. package/ts/configShared.ts +4 -0
  26. package/ts/index.ts +102 -0
  27. package/ts/oxmgrService.ts +36 -0
  28. package/ts/pty.ts +19 -1
  29. package/ts/reaper.spec.ts +45 -0
  30. package/ts/reaper.ts +77 -0
  31. package/ts/schedule.spec.ts +30 -0
  32. package/ts/schedule.ts +161 -0
  33. package/ts/serve.ts +174 -19
  34. package/ts/share.ts +81 -10
  35. package/ts/subcommands.ts +0 -0
  36. package/ts/tray.spec.ts +9 -1
  37. package/ts/tray.ts +30 -14
  38. package/ts/versionChecker.ts +24 -27
  39. package/dist/SUPPORTED_CLIS-CegJgoEf.js +0 -8
package/ts/schedule.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
3
+ import { resolveSpawnCwd } from "./workspaceConfig.ts";
4
+ import { ensureBootAutostart } from "./oxmgrService.ts";
5
+
6
+ // `ay schedule` — run an agent on a recurring schedule via oxmgr's cron support.
7
+ // A scheduled job runs once immediately AND on every cron tick (with
8
+ // --restart never so it isn't relaunched merely on exit), and — like the share
9
+ // daemon — survives reboot once oxmgr's system service is installed.
10
+
11
+ const SCHED_PREFIX = "agent-yes-cron-";
12
+
13
+ /** `HH:MM` → a daily cron; otherwise a raw 5-field cron passes through. null if neither. */
14
+ export function toCron(spec: string): string | null {
15
+ const s = spec.trim();
16
+ const hm = /^(\d{1,2}):(\d{2})$/.exec(s);
17
+ if (hm) {
18
+ const h = Number(hm[1]),
19
+ m = Number(hm[2]);
20
+ return h < 24 && m < 60 ? `${m} ${h} * * *` : null;
21
+ }
22
+ return /^\S+(\s+\S+){4}$/.test(s) ? s : null; // exactly 5 whitespace-separated fields
23
+ }
24
+
25
+ /** Single-quote for oxmgr's shell-style command parsing (verified: it respects quotes). */
26
+ export function shellQuote(s: string): string {
27
+ return `'${s.replace(/'/g, `'\\''`)}'`;
28
+ }
29
+
30
+ function schedName(explicit: string | undefined, cli: string, key: string): string {
31
+ if (explicit) return explicit.startsWith(SCHED_PREFIX) ? explicit : SCHED_PREFIX + explicit;
32
+ return SCHED_PREFIX + cli + "-" + createHash("sha1").update(key).digest("hex").slice(0, 6);
33
+ }
34
+
35
+ async function run(cmd: string[], capture = false): Promise<{ code: number; out: string }> {
36
+ const p = Bun.spawn(cmd, {
37
+ stdin: "ignore",
38
+ stdout: capture ? "pipe" : "inherit",
39
+ stderr: capture ? "pipe" : "inherit",
40
+ });
41
+ const out = capture ? await new Response(p.stdout).text() : "";
42
+ return { code: (await p.exited) ?? 1, out };
43
+ }
44
+
45
+ export async function cmdSchedule(rest: string[]): Promise<number> {
46
+ const oxmgrBin = Bun.which("oxmgr");
47
+ if (!oxmgrBin) {
48
+ process.stderr.write(
49
+ "ay schedule: oxmgr not found\n" +
50
+ " install with: cargo install oxmgr\n" +
51
+ " or: bun add -g oxmgr\n",
52
+ );
53
+ return 1;
54
+ }
55
+
56
+ const sub = rest[0];
57
+
58
+ if (!sub || sub === "-h" || sub === "--help") {
59
+ process.stdout.write(
60
+ `Usage:\n` +
61
+ ` ay schedule <when> <cli> [--cwd DIR] [--name N] [-- <prompt>]\n` +
62
+ ` schedule a recurring agent\n` +
63
+ ` ay schedule list list scheduled agents\n` +
64
+ ` ay schedule remove <name> remove one\n\n` +
65
+ `<when> is a daily HH:MM (e.g. 10:00) or a 5-field cron ("0 10 * * *").\n` +
66
+ `The agent runs once now and then on every tick, and survives reboot.\n\n` +
67
+ `Example — a 10am daily QA pass:\n` +
68
+ ` ay schedule 10:00 claude --cwd ~/ws/product -- "run a full QA pass and write a report"\n`,
69
+ );
70
+ return 0;
71
+ }
72
+
73
+ if (sub === "list" || sub === "ls") {
74
+ // oxmgr lists everything; scheduled agents are the agent-yes-cron-* rows.
75
+ process.stdout.write(`scheduled agents are named '${SCHED_PREFIX}*':\n`);
76
+ return (await run([oxmgrBin, "list"])).code;
77
+ }
78
+
79
+ if (sub === "remove" || sub === "rm" || sub === "delete") {
80
+ const name = rest[1];
81
+ if (!name) {
82
+ process.stderr.write("usage: ay schedule remove <name>\n");
83
+ return 1;
84
+ }
85
+ const full = name.startsWith(SCHED_PREFIX) ? name : SCHED_PREFIX + name;
86
+ return (await run([oxmgrBin, "delete", full])).code;
87
+ }
88
+
89
+ // ay schedule <when> <cli> [--cwd DIR] [--name N] [-- <prompt>]
90
+ const dashIdx = rest.indexOf("--");
91
+ const head = dashIdx >= 0 ? rest.slice(0, dashIdx) : rest;
92
+ const prompt = dashIdx >= 0 ? rest.slice(dashIdx + 1).join(" ") : "";
93
+
94
+ let nameFlag: string | undefined;
95
+ let cwdFlag: string | undefined;
96
+ const pos: string[] = [];
97
+ for (let i = 0; i < head.length; i++) {
98
+ if (head[i] === "--name") nameFlag = head[++i];
99
+ else if (head[i] === "--cwd") cwdFlag = head[++i];
100
+ else pos.push(head[i]!);
101
+ }
102
+
103
+ const [when, cli] = pos;
104
+ if (!when || !cli) {
105
+ process.stderr.write("usage: ay schedule <when> <cli> [-- <prompt>]\n");
106
+ return 1;
107
+ }
108
+ const cron = toCron(when);
109
+ if (!cron) {
110
+ process.stderr.write(`ay schedule: bad <when> "${when}" — use HH:MM or a 5-field cron\n`);
111
+ return 1;
112
+ }
113
+ if (!SUPPORTED_CLIS.includes(cli as never)) {
114
+ process.stderr.write(`ay schedule: unsupported cli "${cli}"\n`);
115
+ return 1;
116
+ }
117
+
118
+ const cwd = resolveSpawnCwd(cwdFlag);
119
+ // Absolute interpreter + bin: oxmgr's daemon PATH may lack ~/.bun/bin. Quote
120
+ // every piece — oxmgr shell-parses the command, so a space in the interpreter
121
+ // path, the ay path, or the prompt would otherwise split into bogus args.
122
+ const ayBin = Bun.which("ay");
123
+ const ayInvoke = ayBin ? `${shellQuote(process.execPath)} ${shellQuote(ayBin)}` : "ay";
124
+ const agentCmd = `${ayInvoke} ${cli}${prompt ? ` -- ${shellQuote(prompt)}` : ""}`;
125
+ const name = schedName(nameFlag, cli, cron + "\0" + prompt + "\0" + cwd);
126
+
127
+ const { code } = await run([
128
+ oxmgrBin,
129
+ "start",
130
+ agentCmd,
131
+ "--name",
132
+ name,
133
+ "--cwd",
134
+ cwd,
135
+ "--restart",
136
+ "never", // only (re)launched by the cron tick, not on plain exit
137
+ "--cron-restart",
138
+ cron,
139
+ ]);
140
+ if (code !== 0) return code;
141
+
142
+ // Persist across reboots (idempotent; same wrapper the daemon install uses).
143
+ // Best-effort: the schedule is already registered, so don't fail the command if
144
+ // boot registration can't be done here — just report it honestly. This SKIPS
145
+ // the install when the service is already registered, so it won't bounce the
146
+ // oxmgr daemon (and every process it manages) on a routine `ay schedule`.
147
+ const onBoot = await ensureBootAutostart(oxmgrBin);
148
+
149
+ process.stdout.write(
150
+ `\nscheduled '${name}'\n` +
151
+ ` ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}"` : ""}\n` +
152
+ ` when: ${cron} cwd: ${cwd}\n` +
153
+ (onBoot
154
+ ? ` runs now and on every tick; survives reboot.\n`
155
+ : ` runs now and on every tick.\n` +
156
+ ` start-on-boot: not registered — run \`oxmgr service install\` to enable\n`) +
157
+ `\n ay schedule list # all scheduled agents\n` +
158
+ ` ay schedule remove ${name.slice(SCHED_PREFIX.length)}\n`,
159
+ );
160
+ return 0;
161
+ }
package/ts/serve.ts CHANGED
@@ -40,6 +40,26 @@ async function loadOrCreateToken(tokenFlag?: string): Promise<string> {
40
40
  }
41
41
  }
42
42
 
43
+ // Read the serve token WITHOUT creating one — `ay serve status` must be a pure
44
+ // read (creating a token as a side effect of asking "is it running?" is wrong).
45
+ async function loadTokenReadOnly(): Promise<string | null> {
46
+ try {
47
+ return (await readFile(tokenPath(), "utf-8")).trim();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ // The persisted WebRTC share link (mode 0600), written by a --share/--webrtc
54
+ // daemon so the secret-bearing link survives restarts. null if not sharing.
55
+ async function readShareLink(): Promise<string | null> {
56
+ try {
57
+ return (await readFile(path.join(agentYesHome(), ".share-link"), "utf-8")).trim();
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
43
63
  function tokenEqual(provided: string, expectedToken: string): boolean {
44
64
  // Constant-time compare; pad both to the same length first
45
65
  const maxLen = Math.max(provided.length, expectedToken.length);
@@ -133,32 +153,67 @@ function ayServeArgv(args: string[]): string[] {
133
153
  return [...launcher, "serve", ...args];
134
154
  }
135
155
 
156
+ // Per-user login auto-start entry on Windows. pm2 core has no Windows startup
157
+ // integration (`pm2 startup` errors "Init system not found"), so we register a
158
+ // HKCU Run value that runs `pm2 resurrect` at login — no admin, removed on
159
+ // uninstall. HKCU (not HKLM) keeps it user-scoped and admin-free.
160
+ const WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
161
+ const WIN_RUN_VALUE = DAEMON_NAME;
162
+
136
163
  // Register the daemon to come up automatically. The *scope* is per-platform by
137
- // design: Linux → at system boot (before login); Windows → at user login.
164
+ // design: Linux → at system boot (before login); macOS/Windows → at user login.
138
165
  // - Linux (oxmgr): `oxmgr service install` wires a systemd **--user** unit,
139
166
  // which on its own only starts after the user logs in. To make it start at
140
167
  // boot without requiring root (no system-scope unit, no sudo), we also
141
168
  // `loginctl enable-linger`, which keeps the user's systemd instance — and
142
169
  // thus our service — running from boot. Best-effort; linger failing just
143
170
  // downgrades us to login-scope.
144
- // - Windows (pm2): `pm2 save` persists the process list so the once-installed
145
- // `pm2 startup` logon hook resurrects it at user login.
171
+ // - Windows (pm2): pm2 core can't install a startup hook here, so we save the
172
+ // process list and add a HKCU Run entry that runs `pm2 resurrect` at login.
173
+ // - macOS (pm2): `pm2 startup` wires a launchd agent (best-effort).
146
174
  // Idempotent and best-effort: returns false on failure without aborting the
147
175
  // install — the process is still crash-managed, just not boot/login-persistent.
148
176
  async function ensureBootAutostart(mgr: DaemonManager): Promise<boolean> {
149
177
  try {
150
- if (mgr.id !== "oxmgr") {
151
- // pm2 (Windows): logon-scoped resurrect via the saved process list.
152
- return (await spawnExit([mgr.bin, "save"])) === 0;
178
+ if (mgr.id === "oxmgr") {
179
+ // Skip `service install` when the service is ALREADY registered: re-running
180
+ // it re-bootstraps the oxmgr daemon, which restarts every managed process —
181
+ // it once took down a VS Code serve-web session on each `ay serve install`.
182
+ // `oxmgr service status` exits 0 only when already installed.
183
+ // oxmgr's --system defaults to "auto" (launchd/systemd/Task Scheduler); it's
184
+ // a `service`-level flag, so it goes before the subcommand, not after.
185
+ const installed =
186
+ (await spawnExit([mgr.bin, "service", "status"])) === 0 ||
187
+ (await spawnExit([mgr.bin, "service", "install"])) === 0;
188
+ if (installed && process.platform === "linux") {
189
+ // Upgrade login-scope → boot-scope: linger starts the user manager at boot.
190
+ await spawnExit(["loginctl", "enable-linger", userInfo().username]);
191
+ }
192
+ return installed;
153
193
  }
154
- // oxmgr's --system defaults to "auto" (launchd/systemd/Task Scheduler); it's
155
- // a `service`-level flag, so it goes before the subcommand, not after.
156
- const installed = (await spawnExit([mgr.bin, "service", "install"])) === 0;
157
- if (installed && process.platform === "linux") {
158
- // Upgrade login-scope boot-scope: linger starts the user manager at boot.
159
- await spawnExit(["loginctl", "enable-linger", userInfo().username]);
194
+ // pm2: persist the current process list first — boot/login resurrect reads it.
195
+ if ((await spawnExit([mgr.bin, "save"])) !== 0) return false;
196
+ if (process.platform === "win32") {
197
+ // pm2 has no Windows startup integration — add a HKCU Run entry ourselves.
198
+ const data = mgr.bin.includes(" ") ? `"${mgr.bin}" resurrect` : `${mgr.bin} resurrect`;
199
+ return (
200
+ (await spawnExit([
201
+ "reg",
202
+ "add",
203
+ WIN_RUN_KEY,
204
+ "/v",
205
+ WIN_RUN_VALUE,
206
+ "/t",
207
+ "REG_SZ",
208
+ "/d",
209
+ data,
210
+ "/f",
211
+ ])) === 0
212
+ );
160
213
  }
161
- return installed;
214
+ // macOS (and any non-Windows pm2 install): pm2 startup wires the init script
215
+ // (may need sudo; best-effort).
216
+ return (await spawnExit([mgr.bin, "startup"])) === 0;
162
217
  } catch {
163
218
  return false;
164
219
  }
@@ -298,11 +353,17 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
298
353
  ? `start-on-boot: enabled (systemd --user + linger, starts at boot)\n`
299
354
  : `start-on-boot: not registered — needs a user systemd session; run \`oxmgr service install\` to enable\n`,
300
355
  );
356
+ else if (process.platform === "win32")
357
+ process.stdout.write(
358
+ onBoot
359
+ ? `start-on-login: enabled (a HKCU Run entry runs \`pm2 resurrect\`)\n`
360
+ : `start-on-login: not registered — \`pm2 save\` or the registry write failed\n`,
361
+ );
301
362
  else
302
363
  process.stdout.write(
303
364
  onBoot
304
- ? `start-on-login: enabled (pm2 list saved; run \`pm2 startup\` once if logon resurrect is not yet installed)\n`
305
- : `start-on-login: \`pm2 save\` failed — run it manually to persist across logins\n`,
365
+ ? `start-on-boot: enabled (pm2 startup registered with the system init)\n`
366
+ : `start-on-boot: not registered — run \`pm2 startup\` (may need sudo) to enable\n`,
306
367
  );
307
368
  process.stdout.write(`token: ${token}\n\n`);
308
369
  if (httpish) {
@@ -327,9 +388,13 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
327
388
  stdio: ["ignore", "inherit", "inherit"],
328
389
  });
329
390
  const code = (await proc.exited) ?? 1;
330
- // Drop it from the persisted pm2 list too, so `pm2 resurrect` won't revive it.
331
- if (mgr.id === "pm2" && code === 0)
332
- await Bun.spawn([mgr.bin, "save"], { stdio: ["ignore", "ignore", "ignore"] }).exited;
391
+ if (mgr.id === "pm2" && code === 0) {
392
+ // Drop it from the persisted pm2 list too, so `pm2 resurrect` won't revive it.
393
+ await spawnExit([mgr.bin, "save"]);
394
+ // Remove the Windows login auto-start entry we added at install time.
395
+ if (process.platform === "win32")
396
+ await spawnExit(["reg", "delete", WIN_RUN_KEY, "/v", WIN_RUN_VALUE, "/f"]);
397
+ }
333
398
  return code;
334
399
  }
335
400
 
@@ -343,6 +408,84 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
343
408
  return 1;
344
409
  }
345
410
 
411
+ // ay serve status — report whether the server is installed as a daemon and/or
412
+ // currently reachable, plus its mode, port, version, token, and share link.
413
+ // Read-only: never mints a token or disturbs the daemon. `--json` for scripts.
414
+ async function cmdServeStatus(args: string[]): Promise<number> {
415
+ const json = args.includes("--json");
416
+ const mgr = resolveDaemonManager();
417
+ const token = await loadTokenReadOnly();
418
+ const shareLink = await readShareLink();
419
+
420
+ // A non-null arg list means the daemon is registered with the manager.
421
+ const daemonArgs = mgr ? await readDaemonServeArgs(mgr) : null;
422
+ const installed = daemonArgs !== null;
423
+ const a = daemonArgs ?? [];
424
+ const port = portFromArgs(a);
425
+ // Mirror cmdServe/install mode resolution: webrtc when --webrtc/--share; http
426
+ // when --http/--share OR no --webrtc at all (http is the implicit default).
427
+ const webrtcish = a.some((x) => x.startsWith("--webrtc") || x.startsWith("--share"));
428
+ const httpish =
429
+ a.some((x) => x.startsWith("--http") || x.startsWith("--share")) ||
430
+ !a.some((x) => x.startsWith("--webrtc"));
431
+ const mode = httpish && webrtcish ? "http+webrtc" : webrtcish ? "webrtc" : "http";
432
+
433
+ // Probe the local HTTP API — catches both a daemon and a foreground `ay serve`.
434
+ // Webrtc-only servers open no port, so a null probe there is expected, not down.
435
+ const runningVersion = httpish && token ? await fetchDaemonVersion(port, token) : null;
436
+ const current = getInstalledPackage().version;
437
+
438
+ if (json) {
439
+ process.stdout.write(
440
+ JSON.stringify(
441
+ {
442
+ manager: mgr?.id ?? null,
443
+ installed,
444
+ mode,
445
+ port: httpish ? port : null,
446
+ reachable: runningVersion !== null,
447
+ runningVersion,
448
+ currentVersion: current,
449
+ upToDate: runningVersion !== null && runningVersion === current,
450
+ args: a,
451
+ hasToken: !!token,
452
+ shareLink,
453
+ },
454
+ null,
455
+ 2,
456
+ ) + "\n",
457
+ );
458
+ return 0;
459
+ }
460
+
461
+ const w = (s = "") => process.stdout.write(s + "\n");
462
+ w(`daemon name: ${DAEMON_NAME}`);
463
+ w(`manager: ${mgr ? mgr.id : "none — install pm2 or oxmgr to daemonize"}`);
464
+ if (installed) {
465
+ w(`installed: yes (via ${mgr!.id})`);
466
+ w(`mode: ${mode}${httpish ? ` (port ${port})` : ""}`);
467
+ if (a.length) w(`args: ${a.join(" ")}`);
468
+ } else {
469
+ w(`installed: no — start a daemon with: ay serve install [--share]`);
470
+ }
471
+ if (runningVersion !== null) {
472
+ const tag = runningVersion === current ? "up to date" : `outdated (current v${current})`;
473
+ w(`http api: reachable on 127.0.0.1:${port} — v${runningVersion} (${tag})`);
474
+ } else if (mode === "webrtc") {
475
+ w(`http api: none (webrtc-only)`);
476
+ } else {
477
+ w(`http api: not reachable on 127.0.0.1:${port} (not running)`);
478
+ }
479
+ w(`token: ${token ?? "(none yet — created on first serve)"}`);
480
+ if (shareLink) w(`share link: ${shareLink}`);
481
+ if (token && httpish) {
482
+ w();
483
+ w(`connect: ay ls ${token}@<host>:${port}`);
484
+ w(` ay remote add <alias> http://${token}@<host>:${port}`);
485
+ }
486
+ return 0;
487
+ }
488
+
346
489
  // ---------------------------------------------------------------------------
347
490
  // ay serve
348
491
  // ---------------------------------------------------------------------------
@@ -372,6 +515,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
372
515
  ` --tls-key FILE TLS private key PEM\n\n` +
373
516
  `Subcommands:\n` +
374
517
  ` ay serve install install as background daemon (pm2 on Windows, else oxmgr)\n` +
518
+ ` ay serve status show daemon/server status (add --json for scripts)\n` +
375
519
  ` ay serve uninstall remove daemon\n` +
376
520
  ` ay serve logs view daemon logs\n\n` +
377
521
  `Once running, connect from another machine:\n` +
@@ -383,6 +527,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
383
527
 
384
528
  // Daemon subcommands
385
529
  const sub = rest[0];
530
+ if (sub === "status") return cmdServeStatus(rest.slice(1));
386
531
  if (sub === "install" || sub === "uninstall" || sub === "logs") {
387
532
  return cmdServeDaemon(sub, rest.slice(1));
388
533
  }
@@ -962,8 +1107,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
962
1107
  process.stderr.write(
963
1108
  `→ console spawned: ay ${cli}${prompt ? ` -- "${prompt.slice(0, 60)}"` : ""} (cwd: ${cwd})\n`,
964
1109
  );
1110
+ // Resolve `ay` to an absolute command. The detached daemon (oxmgr/launchd/
1111
+ // pm2) usually has a PATH WITHOUT ~/.bun/bin, so a bare "ay" fails with
1112
+ // "Executable not found in $PATH: ay". Prefer PATH; fall back to re-running
1113
+ // THIS process's own ay entry (process.argv[1]) — always present, since the
1114
+ // daemon is itself an `ay serve`.
1115
+ const ayBin = Bun.which("ay") ?? process.argv[1];
1116
+ const ayCmd =
1117
+ process.platform === "win32" && ayBin.toLowerCase().endsWith(".exe")
1118
+ ? [ayBin]
1119
+ : [process.execPath, ayBin];
965
1120
  try {
966
- const child = Bun.spawn(["ay", cli, ...(prompt ? ["--", prompt] : [])], {
1121
+ const child = Bun.spawn([...ayCmd, cli, ...(prompt ? ["--", prompt] : [])], {
967
1122
  cwd,
968
1123
  env: freshAgentEnv(), // don't leak our Claude Code session into the agent
969
1124
  stdin: "ignore",
package/ts/share.ts CHANGED
@@ -25,8 +25,47 @@ import {
25
25
  } from "../lab/ui/e2e.js";
26
26
 
27
27
  const SUB = "ay-signal-1";
28
- const ICE = [{ urls: "stun:stun.l.google.com:19302" }];
28
+ // MAX_CHUNK is imported from e2e.js; ICE is replaced by STUN + getIceServers (TURN) below.
29
29
  const DEFAULT_SIGHOST = "s.agent-yes.com";
30
+ const HOST_HEARTBEAT_MS = 20000; // keepalive ping to the rendezvous + silent-drop detection
31
+
32
+ type IceServer = { urls: string | string[]; username?: string; credential?: string };
33
+ const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
34
+
35
+ // Short-lived Cloudflare TURN credentials, minted from a long-term TURN key, so
36
+ // browsers can RELAY when a direct P2P path is impossible (symmetric NAT /
37
+ // CGNAT — the main cause of "rooms offline"). Set CF_TURN_KEY_ID +
38
+ // CF_TURN_API_TOKEN (create a TURN key in the Cloudflare dashboard: Realtime →
39
+ // TURN) to enable; without them we use STUN only, exactly as before. Cached
40
+ // until just before expiry; STUN-only fallback on any error so sharing never
41
+ // breaks because TURN is misconfigured or unreachable.
42
+ let iceCache: { servers: IceServer[]; exp: number } | null = null;
43
+ async function getIceServers(): Promise<IceServer[]> {
44
+ const keyId = process.env.CF_TURN_KEY_ID;
45
+ const apiToken = process.env.CF_TURN_API_TOKEN;
46
+ if (!keyId || !apiToken) return STUN;
47
+ if (iceCache && iceCache.exp > Date.now()) return iceCache.servers;
48
+ const ttl = 3600; // credential lifetime, seconds
49
+ try {
50
+ const r = await fetch(
51
+ `https://rtc.live.cloudflare.com/v1/turn/keys/${keyId}/credentials/generate-ice-servers`,
52
+ {
53
+ method: "POST",
54
+ headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" },
55
+ body: JSON.stringify({ ttl }),
56
+ signal: AbortSignal.timeout(5000),
57
+ },
58
+ );
59
+ if (!r.ok) throw new Error(`Cloudflare TURN ${r.status}`);
60
+ const j = (await r.json()) as { iceServers?: IceServer[] };
61
+ const servers = j.iceServers?.length ? j.iceServers : STUN;
62
+ iceCache = { servers, exp: Date.now() + (ttl - 300) * 1000 }; // refresh ~5min early
63
+ return servers;
64
+ } catch (e) {
65
+ console.error(`[share] Cloudflare TURN credential fetch failed; using STUN only: ${e}`);
66
+ return STUN;
67
+ }
68
+ }
30
69
 
31
70
  export interface ShareOpts {
32
71
  /** webrtc://room:token@host, or undefined to mint a fresh (unpersisted)
@@ -216,15 +255,43 @@ export async function startShare(
216
255
  const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
217
256
  currentWs = ws;
218
257
  let ready = false;
258
+ let lastRecv = Date.now();
259
+ let hb: ReturnType<typeof setInterval> | undefined;
260
+ const stopHb = () => {
261
+ if (hb) {
262
+ clearInterval(hb);
263
+ hb = undefined;
264
+ }
265
+ };
219
266
  ws.onopen = () => {
220
267
  ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
221
268
  ready = true;
269
+ lastRecv = Date.now();
270
+ // Keepalive + dead-link detection: ping the rendezvous and expect a pong.
271
+ // A silently dropped ws (idle DO timeout, network flap) never fires
272
+ // onclose, so if the server goes quiet for ~2 intervals, close+reconnect
273
+ // ourselves — otherwise new browsers can't join until the process restarts.
274
+ stopHb();
275
+ hb = setInterval(() => {
276
+ if (Date.now() - lastRecv > HOST_HEARTBEAT_MS * 2 + 5000) {
277
+ stopHb();
278
+ try {
279
+ ws.close();
280
+ } catch {}
281
+ return;
282
+ }
283
+ try {
284
+ ws.send(JSON.stringify({ type: "ping" }));
285
+ } catch {}
286
+ }, HOST_HEARTBEAT_MS);
222
287
  onReady();
223
288
  };
224
289
  ws.onmessage = async (ev) => {
225
290
  if (closed) return;
291
+ lastRecv = Date.now();
226
292
  const m = JSON.parse(ev.data as string);
227
- if (m.type === "peer-join") startPeer(ws, m.peer);
293
+ if (m.type === "pong") return; // heartbeat ack — liveness already recorded
294
+ if (m.type === "peer-join") startPeer(ws, m.peer).catch(() => {});
228
295
  else if (m.type === "answer") {
229
296
  const peer = peers.get(m.from);
230
297
  if (!peer) return;
@@ -252,6 +319,7 @@ export async function startShare(
252
319
  else if (m.type === "peer-leave") closePeer(m.peer);
253
320
  };
254
321
  ws.onclose = (ev: any) => {
322
+ stopHb();
255
323
  if (closed) return; // shutting down — don't resurrect the rendezvous
256
324
  // The signaling server pins a room to its first host's authToken. A 1008
257
325
  // means a different generation already owns this room — don't hot-loop;
@@ -265,14 +333,15 @@ export async function startShare(
265
333
  }
266
334
  // Keep established WebRTC peers; just re-establish the rendezvous so new
267
335
  // browsers can still join. Backoff a little to avoid hot-looping.
268
- setTimeout(() => connectSignaling(() => {}), ready ? 1500 : 4000);
336
+ setTimeout(() => connectSignaling(() => {}), ready ? 1000 : 2000);
269
337
  };
270
338
  ws.onerror = () => {};
271
339
  return ws;
272
340
  };
273
341
 
274
- function startPeer(ws: WebSocket, peerId: string) {
275
- const pc = new RTCPeerConnection({ iceServers: ICE });
342
+ async function startPeer(ws: WebSocket, peerId: string) {
343
+ const iceServers = await getIceServers();
344
+ const pc = new RTCPeerConnection({ iceServers });
276
345
  let resolveKeys!: () => void;
277
346
  const keysReady = new Promise<void>((r) => (resolveKeys = r));
278
347
  const peer: Peer = {
@@ -318,11 +387,13 @@ export async function startShare(
318
387
  dc.onmessage = (e: any) => {
319
388
  peer.recvChain = peer.recvChain.then(() => onFrame(peerId, dc, peer, e.data)).catch(() => {});
320
389
  };
321
- pc.createOffer()
322
- .then((o: any) => pc.setLocalDescription(o))
323
- .then(() =>
324
- ws.send(JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp })),
325
- );
390
+ const offer = await pc.createOffer();
391
+ await pc.setLocalDescription(offer);
392
+ // Hand the browser the same ICE servers (incl. the short-lived TURN creds)
393
+ // so it can relay too when there's no direct path.
394
+ ws.send(
395
+ JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp, iceServers }),
396
+ );
326
397
  }
327
398
 
328
399
  function closePeer(peerId: string) {
package/ts/subcommands.ts CHANGED
Binary file
package/ts/tray.spec.ts CHANGED
@@ -243,9 +243,12 @@ describe("tray", () => {
243
243
  const originalPlatform = process.platform;
244
244
  Object.defineProperty(process, "platform", { value: "darwin" });
245
245
 
246
- // Simulate existing tray PID file with a live process (our own PID)
246
+ // Simulate existing tray PID file with a live process (our own PID):
247
+ // the exclusive-create claim (writeFile flag "wx") fails, and the live
248
+ // owner check confirms a tray is already running, so we bail.
247
249
  mockFs.existsSync.mockReturnValue(true);
248
250
  mockFsPromises.readFile.mockResolvedValue(String(process.pid));
251
+ mockFsPromises.writeFile.mockRejectedValueOnce(new Error("EEXIST"));
249
252
 
250
253
  const { startTray } = await import("./tray.ts");
251
254
  await startTray();
@@ -295,6 +298,9 @@ describe("tray", () => {
295
298
  const originalPlatform = process.platform;
296
299
  Object.defineProperty(process, "platform", { value: "darwin" });
297
300
  mockFs.existsSync.mockReturnValue(false);
301
+ // Auto-spawn is opt-in (the systray2 helper busy-loops under Bun).
302
+ const prevTrayEnv = process.env.AGENT_YES_TRAY;
303
+ process.env.AGENT_YES_TRAY = "1";
298
304
 
299
305
  const { ensureTray } = await import("./tray.ts");
300
306
  await ensureTray();
@@ -306,6 +312,8 @@ describe("tray", () => {
306
312
  );
307
313
  expect(mockSpawn.child.unref).toHaveBeenCalled();
308
314
 
315
+ if (prevTrayEnv === undefined) delete process.env.AGENT_YES_TRAY;
316
+ else process.env.AGENT_YES_TRAY = prevTrayEnv;
309
317
  Object.defineProperty(process, "platform", { value: originalPlatform });
310
318
  });
311
319