agent-yes 1.121.0 → 1.122.1

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-DcWAr8NI.js +8 -0
  3. package/dist/{SUPPORTED_CLIS-O57LGUEG.js → SUPPORTED_CLIS-f50t1rrA.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-OJeQo0Da.js +144 -0
  12. package/dist/{serve-D2czcYNC.js → serve-O3e2YFfp.js} +137 -36
  13. package/dist/{setup-f1FIFcZm.js → setup-yKMfadhq.js} +5 -42
  14. package/dist/{share-B6QVr5D1.js → share-CksllWW-.js} +122 -16
  15. package/dist/{subcommands-DobVXouH.js → subcommands-BkR-nSAB.js} +2 -2
  16. package/dist/{subcommands-CzpZQHO6.js → subcommands-CT1z9Jl4.js} +15 -6
  17. package/dist/{tray-B8_rx1iu.js → tray-DjCIyakK.js} +22 -10
  18. package/dist/{ts-D91dm1E0.js → ts-DyDU_Dae.js} +76 -7
  19. package/dist/{versionChecker-CAtpgnoQ.js → versionChecker-DmCadDPY.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 +207 -44
  34. package/ts/share.ts +171 -22
  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",
@@ -1064,39 +1219,47 @@ export async function cmdServe(rest: string[]): Promise<number> {
1064
1219
  typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : undefined;
1065
1220
  try {
1066
1221
  const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
1222
+ const linkFile = path.join(
1223
+ process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"),
1224
+ ".share-link",
1225
+ );
1226
+ // Announce the link — reused for the initial share and for any auto-rotation
1227
+ // (when the signaling server rejects a stale persisted room).
1228
+ const announce = async (room: string, link: string, rotated: boolean) => {
1229
+ const lead = rotated
1230
+ ? "the room was rejected by signaling (stale generation) — rotated to a fresh link"
1231
+ : "shared over WebRTC — open this link (the token is eaten from the URL on open)";
1232
+ if (process.stdout.isTTY) {
1233
+ const persistNote = explicitUrl
1234
+ ? "\n"
1235
+ : ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`;
1236
+ process.stdout.write(`${wantHttp ? "\n" : ""}${lead}:\n ${link}\n` + persistNote);
1237
+ } else {
1238
+ // Non-TTY (daemon/journal/CI): the link embeds the room secret S, so never
1239
+ // write it to a log stream. Stash it in a 0600 file and point there instead.
1240
+ try {
1241
+ await writeFile(linkFile, link + "\n", { mode: 0o600 });
1242
+ } catch {
1243
+ /* best effort */
1244
+ }
1245
+ process.stdout.write(
1246
+ `${wantHttp ? "\n" : ""}${rotated ? "rotated WebRTC room" : "shared over WebRTC"} · room ${room} — the link carries a secret, so it is NOT logged.\n` +
1247
+ ` read it from ${linkFile} (mode 0600); delete ~/.agent-yes/.share-room to rotate\n\n`,
1248
+ );
1249
+ }
1250
+ };
1067
1251
  // No explicit webrtc:// URL → reuse the persisted room (minted once and
1068
1252
  // saved like the serve token), so the link is stable across restarts.
1253
+ // Only the persisted path may auto-rotate (onRotate set); an explicit URL
1254
+ // is the operator's choice and must not be silently changed.
1069
1255
  const { room, link, close } = await startShare({
1070
1256
  url: explicitUrl ?? (await loadOrCreateShareRoom()),
1071
1257
  localFetch: apiFetch,
1072
1258
  apiToken: token,
1259
+ onRotate: explicitUrl ? undefined : (info) => announce(info.room, info.link, true),
1073
1260
  });
1074
1261
  closeShare = close;
1075
- const persistNote = explicitUrl
1076
- ? "\n"
1077
- : ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`;
1078
- if (process.stdout.isTTY) {
1079
- process.stdout.write(
1080
- `${wantHttp ? "\n" : ""}shared over WebRTC — open this link (the token is eaten from the URL on open):\n ${link}\n` +
1081
- persistNote,
1082
- );
1083
- } else {
1084
- // Non-TTY (daemon/journal/CI): the link embeds the room secret S, so never
1085
- // write it to a log stream. Stash it in a 0600 file and point there instead.
1086
- const linkFile = path.join(
1087
- process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"),
1088
- ".share-link",
1089
- );
1090
- try {
1091
- await writeFile(linkFile, link + "\n", { mode: 0o600 });
1092
- } catch {
1093
- /* best effort */
1094
- }
1095
- process.stdout.write(
1096
- `${wantHttp ? "\n" : ""}shared over WebRTC · room ${room} — the link carries a secret, so it is NOT logged.\n` +
1097
- ` read it from ${linkFile} (mode 0600); delete ~/.agent-yes/.share-room to rotate\n\n`,
1098
- );
1099
- }
1262
+ await announce(room, link, false);
1100
1263
  } catch (e) {
1101
1264
  process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
1102
1265
  if (!wantHttp) return 1; // nothing else is running