agent-yes 1.203.1 → 1.205.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.
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-C7l18XR8.js";
1
+ import { t as CLIS_CONFIG } from "./ts-Dm6jjLwC.js";
2
2
 
3
3
  //#region ts/SUPPORTED_CLIS.ts
4
4
  const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
5
5
 
6
6
  //#endregion
7
7
  export { SUPPORTED_CLIS as t };
8
- //# sourceMappingURL=SUPPORTED_CLIS-Cu4aeSNQ.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-C8-qh98a.js.map
@@ -0,0 +1,9 @@
1
+ import "./ts-Dm6jjLwC.js";
2
+ import "./logger-CDIsZ-Pp.js";
3
+ import "./versionChecker-BomfUpSo.js";
4
+ import "./pidStore-BIvsBQ8X.js";
5
+ import "./globalPidIndex-CoNr7tS8.js";
6
+ import "./messageLog-CxrKJj77.js";
7
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
8
+
9
+ export { SUPPORTED_CLIS };
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { M as resolveOne } from "./subcommands-B0s3sP4A.js";
5
+ import { M as resolveOne } from "./subcommands-Bs7wJRBB.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -229,4 +229,4 @@ function transformEvent(rawEvent, agentId, forwarded) {
229
229
 
230
230
  //#endregion
231
231
  export { createScopedShare, listShares, revokeAllShares, revokeShare };
232
- //# sourceMappingURL=agentShare-D6AxUbf4.js.map
232
+ //# sourceMappingURL=agentShare-99IGGOkv.js.map
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
3
3
  import { n as logger } from "./logger-CDIsZ-Pp.js";
4
- import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-CHLdtpqI.js";
5
- import { n as getRustBinary } from "./rustBinary-Cb0U4_4s.js";
4
+ import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-BomfUpSo.js";
5
+ import { n as getRustBinary } from "./rustBinary-Tgn948GR.js";
6
6
  import { argv } from "process";
7
7
  import { spawn } from "child_process";
8
8
  import ms from "ms";
@@ -285,7 +285,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
285
285
  const rawArg = process.argv[2];
286
286
  const managerCommands = !invokedCliName(process.argv);
287
287
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
288
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DdAdp0Sp.js");
288
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DyDHZ5YI.js");
289
289
  if (isHelpFlag && process.argv.length === 3) {
290
290
  await cmdHelp(managerCommands);
291
291
  process.exit(0);
@@ -327,7 +327,7 @@ if (config.useRust) {
327
327
  }
328
328
  }
329
329
  if (rustBinary) {
330
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-Bb6ATd-k.js");
330
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-D3XTFfS7.js");
331
331
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
332
332
  if (config.verbose) {
333
333
  console.log(`[rust] Using binary: ${rustBinary}`);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-C7l18XR8.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-Dm6jjLwC.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-CHLdtpqI.js";
3
+ import "./versionChecker-BomfUpSo.js";
4
4
  import "./pidStore-BIvsBQ8X.js";
5
5
  import "./globalPidIndex-CoNr7tS8.js";
6
6
  import "./messageLog-CxrKJj77.js";
@@ -2,7 +2,7 @@ import { n as logger } from "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { $ as notifyDir, G as gcInboxes, J as liveWatchers, K as hostId, Q as daemonLockOwnerPath, S as listRecords, W as appendEvent, X as shouldStealLock, Y as readInbox, Z as daemonLockDir, _ as isPidAlive, c as deriveLiveState, k as renderLogTailLines, q as listInboxParents } from "./subcommands-B0s3sP4A.js";
5
+ import { $ as daemonLockOwnerPath, G as gcInboxes, J as listInboxParents, K as hostId, Q as daemonLockDir, S as listRecords, W as appendEvent, X as readInbox, Y as liveWatchers, Z as shouldStealLock, _ as isPidAlive, c as deriveLiveState, et as notifyDir, k as renderLogTailLines } from "./subcommands-Bs7wJRBB.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -589,4 +589,4 @@ async function ensureDaemon() {
589
589
 
590
590
  //#endregion
591
591
  export { daemonStatus, ensureDaemon, requestDaemonStop, runDaemon };
592
- //# sourceMappingURL=notifyDaemon-KLsuGTfs.js.map
592
+ //# sourceMappingURL=notifyDaemon-DK1HOMjj.js.map
@@ -1,4 +1,4 @@
1
- import { r as getInstalledPackage } from "./versionChecker-CHLdtpqI.js";
1
+ import { r as getInstalledPackage } from "./versionChecker-BomfUpSo.js";
2
2
  import { execFileSync } from "child_process";
3
3
  import { existsSync, mkdirSync, unlinkSync } from "fs";
4
4
  import { chmod, copyFile } from "fs/promises";
@@ -225,4 +225,4 @@ async function getRustBinary(options = {}) {
225
225
 
226
226
  //#endregion
227
227
  export { getRustBinary as n, findSpawnHiddenLauncher as t };
228
- //# sourceMappingURL=rustBinary-Cb0U4_4s.js.map
228
+ //# sourceMappingURL=rustBinary-Tgn948GR.js.map
@@ -1,10 +1,10 @@
1
- import "./ts-C7l18XR8.js";
1
+ import "./ts-Dm6jjLwC.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-CHLdtpqI.js";
3
+ import "./versionChecker-BomfUpSo.js";
4
4
  import "./pidStore-BIvsBQ8X.js";
5
5
  import "./globalPidIndex-CoNr7tS8.js";
6
6
  import "./messageLog-CxrKJj77.js";
7
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Cu4aeSNQ.js";
7
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
8
8
  import { d as resolveSpawnCwd } from "./workspaceConfig-CjaRvTjf.js";
9
9
  import { n as liveEnv, t as ensureNodeRuntime } from "./nodeRuntime-CwNJuwH5.js";
10
10
  import { createHash } from "node:crypto";
@@ -151,4 +151,4 @@ async function cmdSchedule(rest) {
151
151
 
152
152
  //#endregion
153
153
  export { cmdSchedule };
154
- //# sourceMappingURL=schedule-hC-cPypM.js.map
154
+ //# sourceMappingURL=schedule-CBoM67Vy.js.map
@@ -1,14 +1,15 @@
1
- import "./ts-C7l18XR8.js";
1
+ import "./ts-Dm6jjLwC.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import { r as getInstalledPackage } from "./versionChecker-CHLdtpqI.js";
4
- import { t as findSpawnHiddenLauncher } from "./rustBinary-Cb0U4_4s.js";
3
+ import { r as getInstalledPackage } from "./versionChecker-BomfUpSo.js";
4
+ import { t as findSpawnHiddenLauncher } from "./rustBinary-Tgn948GR.js";
5
+ import { t as agentYesHome$1 } from "./agentYesHome-CtHb5b71.js";
5
6
  import "./pidStore-BIvsBQ8X.js";
6
7
  import { a as updateGlobalPidStatus } from "./globalPidIndex-CoNr7tS8.js";
7
8
  import { r as recordInbox } from "./messageLog-CxrKJj77.js";
8
9
  import { t as pgidForWrapper } from "./reaper-BUHCyxdF.js";
9
10
  import "./configShared-aKTg-sa5.js";
10
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Cu4aeSNQ.js";
11
- import { A as renderRawLog, D as recentMessageEdges, E as readPtysize, L as snapshotStatus, M as resolveOne, O as recentReadEdges, S as listRecords, T as readNotes, U as writeToIpc, b as isUserTyping, et as TYPING_BADGE, f as extractNeedsInput, k as renderLogTailLines, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, u as extractBadges } from "./subcommands-B0s3sP4A.js";
11
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
12
+ import { A as renderRawLog, D as recentMessageEdges, E as readPtysize, L as snapshotStatus, M as resolveOne, O as recentReadEdges, S as listRecords, T as readNotes, U as writeToIpc, b as isUserTyping, f as extractNeedsInput, k as renderLogTailLines, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, q as isTransientLockMkdirError, tt as TYPING_BADGE, u as extractBadges } from "./subcommands-Bs7wJRBB.js";
12
13
  import "./e2e-jb0Hp43q.js";
13
14
  import "./webrtcLink-B7REGtK2.js";
14
15
  import "./remotes-Cim0dBU7.js";
@@ -16,7 +17,7 @@ import { a as getSpawnHook, c as hasProvisionHook, d as resolveSpawnCwd, i as ge
16
17
  import { n as liveEnv, t as ensureNodeRuntime } from "./nodeRuntime-CwNJuwH5.js";
17
18
  import { r as spawnRejectionReason } from "./spawnGate-CXjEz6Pf.js";
18
19
  import yargs from "yargs";
19
- import { mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
20
+ import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
20
21
  import { arch, cpus, freemem, homedir, hostname, loadavg, platform, totalmem, uptime, userInfo } from "os";
21
22
  import path from "path";
22
23
  import { execFileSync } from "node:child_process";
@@ -42,6 +43,197 @@ import { existsSync, renameSync, watch, writeFileSync } from "node:fs";
42
43
  */
43
44
  const isTerminalReply = (s) => /^(?:\x1b\[(?:\??\d+(?:;\d+)*R|\?[\d;]*c|>[\d;]*c|\d*n))+$/.test(s);
44
45
 
46
+ //#endregion
47
+ //#region ts/serveLock.ts
48
+ /**
49
+ * Singleton lock for the WebRTC host role: one `ay serve` host per
50
+ * ~/.agent-yes. Two hosts sharing the persisted room (~/.agent-yes/.share-room)
51
+ * fight over every viewer connection — the exact outage seen live: an orphaned
52
+ * `ay serve --webrtc` from a previous manager era plus a manual run left the
53
+ * managed daemon crash-looping (12 watchdog restarts) and the share link
54
+ * unloadable. The lock makes the loser fail FAST with a pointer to the owner
55
+ * instead of silently contending.
56
+ *
57
+ * mkdir-based (atomic on every platform), owner JSON alongside, heartbeat
58
+ * refresh while held. A stale owner — dead pid, or a heartbeat older than
59
+ * SERVE_LOCK_STALE_MS (a SIGKILLed host can't clean up) — is stolen, so a
60
+ * crashed host never wedges the next start.
61
+ */
62
+ const SERVE_LOCK_STALE_MS = 2e4;
63
+ const SERVE_LOCK_BEAT_MS = 5e3;
64
+ const SERVE_LOCK_GRACE_MS = 12e3;
65
+ function lockDir() {
66
+ return path.join(agentYesHome$1(), "webrtc-host.lock");
67
+ }
68
+ function ownerPath() {
69
+ return path.join(lockDir(), "owner.json");
70
+ }
71
+ function pidAlive(pid) {
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ } catch (e) {
76
+ return e.code === "EPERM";
77
+ }
78
+ }
79
+ async function readOwner() {
80
+ try {
81
+ const o = JSON.parse(await readFile(ownerPath(), "utf-8"));
82
+ return typeof o?.pid === "number" ? o : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+ /** Pure staleness decision, exported for tests. */
88
+ function isOwnerStale(owner, now, alive, staleMs = SERVE_LOCK_STALE_MS) {
89
+ if (!owner) return true;
90
+ if (!alive(owner.pid)) return true;
91
+ return now - owner.beat_at > staleMs;
92
+ }
93
+ async function stampOwner(startedAt) {
94
+ const tmp = `${ownerPath()}.${process.pid}.tmp`;
95
+ try {
96
+ await writeFile(tmp, JSON.stringify({
97
+ pid: process.pid,
98
+ started_at: startedAt,
99
+ beat_at: Date.now()
100
+ }));
101
+ await rename(tmp, ownerPath());
102
+ return true;
103
+ } catch {
104
+ await rm(tmp, { force: true }).catch(() => {});
105
+ return false;
106
+ }
107
+ }
108
+ /**
109
+ * Acquire the host lock, retrying for up to `graceMs`. Returns fast on a live
110
+ * owner once the grace expires (never blocks a daemon boot forever). With
111
+ * `takeover`, a live owner is SIGTERM'd (then SIGKILL'd) and the lock stolen.
112
+ */
113
+ async function acquireWebrtcHostLock(opts) {
114
+ const graceMs = opts?.graceMs ?? SERVE_LOCK_GRACE_MS;
115
+ const staleMs = opts?.staleMs ?? SERVE_LOCK_STALE_MS;
116
+ const beatMs = opts?.beatMs ?? SERVE_LOCK_BEAT_MS;
117
+ const startedAt = Date.now();
118
+ const deadline = startedAt + graceMs;
119
+ let tookOver = false;
120
+ for (;;) try {
121
+ await mkdir(lockDir(), { recursive: false });
122
+ if (!await stampOwner(startedAt)) {
123
+ await rm(lockDir(), {
124
+ recursive: true,
125
+ force: true
126
+ }).catch(() => {});
127
+ return {
128
+ ok: false,
129
+ owner: null
130
+ };
131
+ }
132
+ const beat = setInterval(() => {
133
+ (async () => {
134
+ if ((await readOwner())?.pid !== process.pid) {
135
+ clearInterval(beat);
136
+ return;
137
+ }
138
+ await stampOwner(startedAt);
139
+ })();
140
+ }, beatMs);
141
+ if (typeof beat.unref === "function") beat.unref();
142
+ return {
143
+ ok: true,
144
+ release: async () => {
145
+ clearInterval(beat);
146
+ if ((await readOwner())?.pid === process.pid) await rm(lockDir(), {
147
+ recursive: true,
148
+ force: true
149
+ }).catch(() => {});
150
+ }
151
+ };
152
+ } catch (e) {
153
+ const code = e.code;
154
+ if (!isTransientLockMkdirError(code)) throw e;
155
+ if (code !== "EEXIST") {
156
+ await new Promise((r) => setTimeout(r, 25));
157
+ continue;
158
+ }
159
+ const owner = await readOwner();
160
+ if (isOwnerStale(owner, Date.now(), pidAlive, staleMs)) {
161
+ await rm(lockDir(), {
162
+ recursive: true,
163
+ force: true
164
+ }).catch(() => {});
165
+ continue;
166
+ }
167
+ if (opts?.takeover && owner && !tookOver) {
168
+ tookOver = true;
169
+ try {
170
+ process.kill(owner.pid, "SIGTERM");
171
+ } catch {}
172
+ await new Promise((r) => setTimeout(r, opts?.takeoverWaitMs ?? 2e3));
173
+ if (pidAlive(owner.pid)) try {
174
+ process.kill(owner.pid, "SIGKILL");
175
+ } catch {}
176
+ await rm(lockDir(), {
177
+ recursive: true,
178
+ force: true
179
+ }).catch(() => {});
180
+ continue;
181
+ }
182
+ if (Date.now() >= deadline) return {
183
+ ok: false,
184
+ owner
185
+ };
186
+ await new Promise((r) => setTimeout(r, 250));
187
+ }
188
+ }
189
+
190
+ //#endregion
191
+ //#region ts/strayServe.ts
192
+ const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
193
+ function isServeCommand(command) {
194
+ return /\bay(?:\.js)?\s+serve\b/.test(command) || /\bagent-yes(?:\.js)?\s+serve\b/.test(command) || /\bagent-yes\.js\s+serve\b/.test(command);
195
+ }
196
+ function parseStrayServeProcesses(psOutput, opts) {
197
+ const ignoredPids = new Set([opts.selfPid, opts.parentPid]);
198
+ const processes = [];
199
+ for (const line of psOutput.split(/\r?\n/)) {
200
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
201
+ if (!match) continue;
202
+ const pid = Number(match[1]);
203
+ const command = match[2];
204
+ if (!Number.isFinite(pid) || ignoredPids.has(pid)) continue;
205
+ if (!isServeCommand(command)) continue;
206
+ if (MANAGEMENT_COMMAND_RE.test(command)) continue;
207
+ processes.push({
208
+ pid,
209
+ command
210
+ });
211
+ }
212
+ return processes;
213
+ }
214
+ async function listStrayServeProcesses() {
215
+ if (process.platform === "win32") return [];
216
+ try {
217
+ const ps = Bun.spawn([
218
+ "ps",
219
+ "-axo",
220
+ "pid=,command="
221
+ ], {
222
+ stdout: "pipe",
223
+ stderr: "ignore",
224
+ env: liveEnv()
225
+ });
226
+ const output = await new Response(ps.stdout).text();
227
+ if (await ps.exited !== 0) return [];
228
+ return parseStrayServeProcesses(output, {
229
+ selfPid: process.pid,
230
+ parentPid: process.ppid
231
+ });
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+
45
237
  //#endregion
46
238
  //#region ts/serve.ts
47
239
  const DEFAULT_PORT = 7432;
@@ -520,6 +712,12 @@ function portFromArgs(args) {
520
712
  const m = /--port[=\s](\d+)/.exec(args.join(" "));
521
713
  return m ? Number(m[1]) : DEFAULT_PORT;
522
714
  }
715
+ async function warnStrayServeProcesses() {
716
+ const stray = await listStrayServeProcesses();
717
+ if (stray.length === 0) return;
718
+ const pids = stray.map((p) => p.pid);
719
+ process.stderr.write(`warning: ${stray.length} unmanaged ay serve process(es) running (pid ${pids.join(", ")}) — they will fight the daemon for the WebRTC room; stop them: kill ${pids.join(" ")}\n`);
720
+ }
523
721
  function explicitWebrtcUrl(args) {
524
722
  for (let i = 0; i < args.length; i++) {
525
723
  const a = args[i];
@@ -598,6 +796,7 @@ async function cmdServeDaemon(sub, args) {
598
796
  }
599
797
  }
600
798
  if (removed === 0 && failed === 0) process.stdout.write(`no '${DAEMON_NAME}' daemon registered with any manager\n`);
799
+ await warnStrayServeProcesses();
601
800
  return failed ? 1 : 0;
602
801
  }
603
802
  let mgr = resolveDaemonManager();
@@ -653,6 +852,7 @@ async function cmdServeDaemon(sub, args) {
653
852
  DAEMON_NAME
654
853
  ]);
655
854
  }
855
+ await warnStrayServeProcesses();
656
856
  const serveArgv = ayServeArgv(effArgs);
657
857
  const spawnHidden = findSpawnHiddenLauncher();
658
858
  const managedArgv = spawnHidden ? [spawnHidden, ...serveArgv] : serveArgv;
@@ -853,6 +1053,10 @@ Options:
853
1053
  type: "boolean",
854
1054
  default: false,
855
1055
  description: "Deprecated no-op — the console can always spawn agents"
1056
+ }).option("takeover", {
1057
+ type: "boolean",
1058
+ default: false,
1059
+ description: "If another ay serve already hosts this home's WebRTC room, stop it and take the room"
856
1060
  }).help(false).version(false).exitProcess(false).parseAsync();
857
1061
  if (argv.daemon) return cmdServeDaemon("install", rest.filter((a) => a !== "--daemon" && a !== "-d"));
858
1062
  const stray = argv._.map(String);
@@ -874,6 +1078,17 @@ Options:
874
1078
  const scheme = useHttps ? "https" : "http";
875
1079
  const wantWebrtc = argv.webrtc !== void 0 || argv.share !== void 0;
876
1080
  const wantHttp = argv.http === true || argv.share !== void 0 || argv.webrtc === void 0;
1081
+ let releaseHostLock = null;
1082
+ if (wantWebrtc && process.env.AGENT_YES_NO_SERVE_LOCK !== "1") {
1083
+ const lock = await acquireWebrtcHostLock({ takeover: argv.takeover === true });
1084
+ if (!lock.ok) {
1085
+ const o = lock.owner;
1086
+ const since = o ? new Date(o.started_at).toLocaleString() : "unknown";
1087
+ process.stderr.write(`ay serve: another instance${o ? ` (pid ${o.pid}, started ${since})` : ""} already hosts this home's WebRTC room\n ay serve status # inspect it\n ay serve --webrtc --takeover # stop it and take the room\n AGENT_YES_NO_SERVE_LOCK=1 # opt out (multi-room setups only)\n`);
1088
+ return 1;
1089
+ }
1090
+ releaseHostLock = lock.release;
1091
+ }
877
1092
  if (wantHttp && host !== "127.0.0.1" && host !== "localhost") process.stderr.write("ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n");
878
1093
  const token = await loadOrCreateToken(tokenFlag);
879
1094
  const titleCache = /* @__PURE__ */ new Map();
@@ -1442,7 +1657,7 @@ Options:
1442
1657
  }
1443
1658
  });
1444
1659
  if (req.method === "GET" && p === "/api/ws") {
1445
- const ws = await import("./ws-B1eASck9.js");
1660
+ const ws = await import("./ws-CSkP2mkD.js");
1446
1661
  try {
1447
1662
  await ws.loadProvision();
1448
1663
  } catch (e) {
@@ -1462,7 +1677,7 @@ Options:
1462
1677
  if (req.method === "GET" && p === "/api/ws/status") {
1463
1678
  const dirRaw = url.searchParams.get("path");
1464
1679
  if (!dirRaw) return new Response("missing ?path=<workspace dir>", { status: 400 });
1465
- const ws = await import("./ws-B1eASck9.js");
1680
+ const ws = await import("./ws-CSkP2mkD.js");
1466
1681
  let prov;
1467
1682
  try {
1468
1683
  prov = await ws.loadProvision();
@@ -2018,7 +2233,7 @@ Options:
2018
2233
  const perm = body.perm ?? "r";
2019
2234
  if (perm !== "r" && perm !== "rw") return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
2020
2235
  try {
2021
- const { createScopedShare } = await import("./agentShare-D6AxUbf4.js");
2236
+ const { createScopedShare } = await import("./agentShare-99IGGOkv.js");
2022
2237
  const share = await createScopedShare({
2023
2238
  agent: body.agent,
2024
2239
  perm,
@@ -2033,12 +2248,12 @@ Options:
2033
2248
  }
2034
2249
  }
2035
2250
  if (req.method === "GET" && p === "/api/shares") {
2036
- const { listShares } = await import("./agentShare-D6AxUbf4.js");
2251
+ const { listShares } = await import("./agentShare-99IGGOkv.js");
2037
2252
  return Response.json(listShares());
2038
2253
  }
2039
2254
  const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
2040
2255
  if (req.method === "DELETE" && revokeM) {
2041
- const { revokeShare } = await import("./agentShare-D6AxUbf4.js");
2256
+ const { revokeShare } = await import("./agentShare-99IGGOkv.js");
2042
2257
  const ok = revokeShare(decodeURIComponent(revokeM[1]));
2043
2258
  return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
2044
2259
  }
@@ -2201,7 +2416,10 @@ Options:
2201
2416
  await announce(room, link, false);
2202
2417
  } catch (e) {
2203
2418
  process.stderr.write(`ay serve --webrtc failed: ${e.message}\n`);
2204
- if (!wantHttp) return 1;
2419
+ if (!wantHttp) {
2420
+ await releaseHostLock?.().catch(() => {});
2421
+ return 1;
2422
+ }
2205
2423
  }
2206
2424
  }
2207
2425
  let heartbeat;
@@ -2220,9 +2438,9 @@ Options:
2220
2438
  const shutdown = (resolve) => {
2221
2439
  if (heartbeat) clearInterval(heartbeat);
2222
2440
  closeShare?.();
2223
- import("./agentShare-D6AxUbf4.js").then((m) => m.revokeAllShares()).catch(() => {});
2441
+ import("./agentShare-99IGGOkv.js").then((m) => m.revokeAllShares()).catch(() => {});
2224
2442
  server?.stop();
2225
- resolve();
2443
+ Promise.resolve(releaseHostLock?.()).catch(() => {}).then(resolve);
2226
2444
  };
2227
2445
  await new Promise((resolve) => {
2228
2446
  process.on("SIGINT", () => shutdown(resolve));
@@ -2233,4 +2451,4 @@ Options:
2233
2451
 
2234
2452
  //#endregion
2235
2453
  export { cmdServe };
2236
- //# sourceMappingURL=serve-CFTBba3Y.js.map
2454
+ //# sourceMappingURL=serve-IvOlaL-7.js.map
@@ -32,7 +32,7 @@ async function cmdSetup(rest) {
32
32
  if (!existsSync(abs)) process.stderr.write(` note: that directory doesn't exist yet — create it, or agents spawned there will fail\n`);
33
33
  if (noShare) return 0;
34
34
  process.stdout.write(`\nsharing this machine to agent-yes.com…\n`);
35
- const { cmdServe } = await import("./serve-CFTBba3Y.js");
35
+ const { cmdServe } = await import("./serve-IvOlaL-7.js");
36
36
  return cmdServe([
37
37
  "install",
38
38
  "--share",
@@ -42,4 +42,4 @@ async function cmdSetup(rest) {
42
42
 
43
43
  //#endregion
44
44
  export { cmdSetup };
45
- //# sourceMappingURL=setup-CyFHjoSl.js.map
45
+ //# sourceMappingURL=setup-BaKdS-3i.js.map
@@ -1237,19 +1237,19 @@ async function runSubcommand(argv) {
1237
1237
  case "restart": return await cmdRestart(rest);
1238
1238
  case "note": return await cmdNote(rest);
1239
1239
  case "serve": {
1240
- const { cmdServe } = await import("./serve-CFTBba3Y.js");
1240
+ const { cmdServe } = await import("./serve-IvOlaL-7.js");
1241
1241
  return cmdServe(rest);
1242
1242
  }
1243
1243
  case "setup": {
1244
- const { cmdSetup } = await import("./setup-CyFHjoSl.js");
1244
+ const { cmdSetup } = await import("./setup-BaKdS-3i.js");
1245
1245
  return cmdSetup(rest);
1246
1246
  }
1247
1247
  case "ws": {
1248
- const { cmdWs } = await import("./ws-B1eASck9.js");
1248
+ const { cmdWs } = await import("./ws-CSkP2mkD.js");
1249
1249
  return cmdWs(rest);
1250
1250
  }
1251
1251
  case "schedule": {
1252
- const { cmdSchedule } = await import("./schedule-hC-cPypM.js");
1252
+ const { cmdSchedule } = await import("./schedule-CBoM67Vy.js");
1253
1253
  return cmdSchedule(rest);
1254
1254
  }
1255
1255
  case "remote": {
@@ -4269,7 +4269,7 @@ async function cmdNotify(rest) {
4269
4269
  }
4270
4270
  const ensure = async () => {
4271
4271
  if (!argv["ensure-daemon"]) return;
4272
- const { ensureDaemon } = await import("./notifyDaemon-KLsuGTfs.js");
4272
+ const { ensureDaemon } = await import("./notifyDaemon-DK1HOMjj.js");
4273
4273
  await ensureDaemon().catch(() => null);
4274
4274
  };
4275
4275
  await heartbeatWatcher(parent, selfStartedAt);
@@ -4343,7 +4343,7 @@ async function cmdNotifyCursor(args) {
4343
4343
  }
4344
4344
  async function cmdNotifyd(rest) {
4345
4345
  const sub = rest[0] ?? "status";
4346
- const daemon = await import("./notifyDaemon-KLsuGTfs.js");
4346
+ const daemon = await import("./notifyDaemon-DK1HOMjj.js");
4347
4347
  switch (sub) {
4348
4348
  case "run": return daemon.runDaemon();
4349
4349
  case "once": return daemon.runDaemon({ once: true });
@@ -4369,5 +4369,5 @@ async function cmdNotifyd(rest) {
4369
4369
  }
4370
4370
 
4371
4371
  //#endregion
4372
- export { notifyDir as $, renderRawLog as A, submitAndConfirm as B, matchKeyword as C, recentMessageEdges as D, readPtysize as E, restartHintLines as F, gcInboxes as G, writeKeysPaced as H, runSubcommand as I, liveWatchers as J, hostId as K, snapshotStatus as L, resolveOne as M, resolveReadWindow as N, recentReadEdges as O, resolveResumeArgs as P, daemonLockOwnerPath as Q, stdinActivityPath as R, listRecords as S, readNotes as T, writeToIpc as U, waitForLogQuiet as V, appendEvent as W, shouldStealLock as X, readInbox as Y, daemonLockDir as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, TYPING_BADGE as et, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, renderRawLogLines as j, renderLogTailLines as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, listInboxParents as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, stopTipForCli as z };
4373
- //# sourceMappingURL=subcommands-B0s3sP4A.js.map
4372
+ export { daemonLockOwnerPath as $, renderRawLog as A, submitAndConfirm as B, matchKeyword as C, recentMessageEdges as D, readPtysize as E, restartHintLines as F, gcInboxes as G, writeKeysPaced as H, runSubcommand as I, listInboxParents as J, hostId as K, snapshotStatus as L, resolveOne as M, resolveReadWindow as N, recentReadEdges as O, resolveResumeArgs as P, daemonLockDir as Q, stdinActivityPath as R, listRecords as S, readNotes as T, writeToIpc as U, waitForLogQuiet as V, appendEvent as W, readInbox as X, liveWatchers as Y, shouldStealLock as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, notifyDir as et, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, renderRawLogLines as j, renderLogTailLines as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, isTransientLockMkdirError as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, TYPING_BADGE as tt, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, stopTipForCli as z };
4373
+ //# sourceMappingURL=subcommands-Bs7wJRBB.js.map
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { A as renderRawLog, B as submitAndConfirm, C as matchKeyword, D as recentMessageEdges, E as readPtysize, F as restartHintLines, H as writeKeysPaced, I as runSubcommand, L as snapshotStatus, M as resolveOne, N as resolveReadWindow, O as recentReadEdges, P as resolveResumeArgs, R as stdinActivityPath, S as listRecords, T as readNotes, U as writeToIpc, V as waitForLogQuiet, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as renderRawLogLines, k as renderLogTailLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as stopTipForCli } from "./subcommands-B0s3sP4A.js";
5
+ import { A as renderRawLog, B as submitAndConfirm, C as matchKeyword, D as recentMessageEdges, E as readPtysize, F as restartHintLines, H as writeKeysPaced, I as runSubcommand, L as snapshotStatus, M as resolveOne, N as resolveReadWindow, O as recentReadEdges, P as resolveResumeArgs, R as stdinActivityPath, S as listRecords, T as readNotes, U as writeToIpc, V as waitForLogQuiet, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as renderRawLogLines, k as renderLogTailLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as stopTipForCli } from "./subcommands-Bs7wJRBB.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -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-CHLdtpqI.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-BomfUpSo.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CNMl13dC.js";
5
5
  import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
@@ -1901,4 +1901,4 @@ function sleep(ms) {
1901
1901
 
1902
1902
  //#endregion
1903
1903
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1904
- //# sourceMappingURL=ts-C7l18XR8.js.map
1904
+ //# sourceMappingURL=ts-Dm6jjLwC.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.203.1";
10
+ var version = "1.205.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-CHLdtpqI.js.map
218
+ //# sourceMappingURL=versionChecker-BomfUpSo.js.map
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { S as listRecords } from "./subcommands-B0s3sP4A.js";
5
+ import { S as listRecords } from "./subcommands-Bs7wJRBB.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -418,4 +418,4 @@ async function cmdWs(args) {
418
418
 
419
419
  //#endregion
420
420
  export { WS_JSON_SCHEMA, cmdWs, collectWorkspaces, isPathInside, loadProvision, workspaceStatus };
421
- //# sourceMappingURL=ws-B1eASck9.js.map
421
+ //# sourceMappingURL=ws-CSkP2mkD.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.203.1",
3
+ "version": "1.205.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/serve.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  import { TYPING_BADGE } from "./badges.ts";
40
40
  import { isTerminalReply } from "./terminalReply.ts";
41
41
  import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
42
+ import { acquireWebrtcHostLock, type ServeLockOwner } from "./serveLock.ts";
42
43
  import { type MailParty, recordInbox } from "./messageLog.ts";
43
44
  import { updateGlobalPidStatus } from "./globalPidIndex.ts";
44
45
  import { spawnRejectionReason } from "./spawnGate.ts";
@@ -46,6 +47,7 @@ import { findSpawnHiddenLauncher } from "./rustBinary.ts";
46
47
  import { pgidForWrapper } from "./reaper.ts";
47
48
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
48
49
  import { getInstalledPackage } from "./versionChecker.ts";
50
+ import { listStrayServeProcesses } from "./strayServe.ts";
49
51
  import {
50
52
  getProvisionHook,
51
53
  getProvisionRoot,
@@ -707,6 +709,17 @@ function portFromArgs(args: string[]): number {
707
709
  return m ? Number(m[1]) : DEFAULT_PORT;
708
710
  }
709
711
 
712
+ async function warnStrayServeProcesses(): Promise<void> {
713
+ const stray = await listStrayServeProcesses();
714
+ if (stray.length === 0) return;
715
+ const pids = stray.map((p) => p.pid);
716
+ process.stderr.write(
717
+ `warning: ${stray.length} unmanaged ay serve process(es) running (pid ${pids.join(
718
+ ", ",
719
+ )}) — they will fight the daemon for the WebRTC room; stop them: kill ${pids.join(" ")}\n`,
720
+ );
721
+ }
722
+
710
723
  // An explicit webrtc:// URL passed to --webrtc/--share in the daemon's serve args,
711
724
  // or undefined for a bare flag (which mints a persisted room instead). Mirrors how
712
725
  // cmdServe resolves argv.webrtc/argv.share, but over the raw arg list install holds
@@ -785,6 +798,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
785
798
  }
786
799
  if (removed === 0 && failed === 0)
787
800
  process.stdout.write(`no '${DAEMON_NAME}' daemon registered with any manager\n`);
801
+ await warnStrayServeProcesses();
788
802
  return failed ? 1 : 0;
789
803
  }
790
804
 
@@ -893,6 +907,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
893
907
  await spawnExit([mgr.bin, "stop", DAEMON_NAME]);
894
908
  await spawnExit([mgr.bin, "delete", DAEMON_NAME]);
895
909
  }
910
+ await warnStrayServeProcesses();
896
911
 
897
912
  // oxmgr takes the command as one string; pm2 takes the binary plus its
898
913
  // args after `--`. Both auto-restart on crash by default (pm2) / via the
@@ -1207,6 +1222,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1207
1222
  default: false,
1208
1223
  description: "Deprecated no-op — the console can always spawn agents",
1209
1224
  })
1225
+ .option("takeover", {
1226
+ type: "boolean",
1227
+ default: false,
1228
+ description: "If another ay serve already hosts this home's WebRTC room, stop it and take the room",
1229
+ })
1210
1230
  .help(false)
1211
1231
  .version(false)
1212
1232
  .exitProcess(false);
@@ -1259,6 +1279,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
1259
1279
  const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
1260
1280
  const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
1261
1281
 
1282
+ // Singleton WebRTC host per ~/.agent-yes: a second host joining the SAME
1283
+ // persisted room (~/.agent-yes/.share-room) fights the first over every
1284
+ // viewer connection — seen live as an unloadable share link plus the managed
1285
+ // daemon crash-looping under the health watchdog while an orphaned manual
1286
+ // `ay serve --webrtc` held the room. Fail fast with a pointer to the owner
1287
+ // instead of contending. HTTP-only serves skip this (port collisions already
1288
+ // fail naturally with EADDRINUSE). AGENT_YES_NO_SERVE_LOCK=1 opts out for
1289
+ // exotic multi-room setups (explicit webrtc:// URLs to different rooms).
1290
+ let releaseHostLock: (() => Promise<void>) | null = null;
1291
+ if (wantWebrtc && process.env.AGENT_YES_NO_SERVE_LOCK !== "1") {
1292
+ const lock = await acquireWebrtcHostLock({ takeover: argv.takeover === true });
1293
+ if (!lock.ok) {
1294
+ const o: ServeLockOwner | null = lock.owner;
1295
+ const since = o ? new Date(o.started_at).toLocaleString() : "unknown";
1296
+ process.stderr.write(
1297
+ `ay serve: another instance${o ? ` (pid ${o.pid}, started ${since})` : ""} already hosts this home's WebRTC room\n` +
1298
+ ` ay serve status # inspect it\n` +
1299
+ ` ay serve --webrtc --takeover # stop it and take the room\n` +
1300
+ ` AGENT_YES_NO_SERVE_LOCK=1 # opt out (multi-room setups only)\n`,
1301
+ );
1302
+ return 1;
1303
+ }
1304
+ releaseHostLock = lock.release;
1305
+ }
1306
+
1262
1307
  if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
1263
1308
  process.stderr.write(
1264
1309
  "ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
@@ -3243,7 +3288,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
3243
3288
  await announce(room, link, false);
3244
3289
  } catch (e) {
3245
3290
  process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
3246
- if (!wantHttp) return 1; // nothing else is running
3291
+ if (!wantHttp) {
3292
+ // Release promptly so the manager's instant respawn doesn't have to
3293
+ // wait out the stale window to reclaim the host lock.
3294
+ await releaseHostLock?.().catch(() => {});
3295
+ return 1; // nothing else is running
3296
+ }
3247
3297
  }
3248
3298
  }
3249
3299
 
@@ -3275,7 +3325,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
3275
3325
  // Close any scoped single-agent share rooms so viewers get an immediate drop.
3276
3326
  void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
3277
3327
  server?.stop();
3278
- resolve();
3328
+ // Gate exit on the lock release: resolve() ends cmdServe and the process,
3329
+ // and an un-awaited rm would race that exit — leaving a lock the next host
3330
+ // (a manager's instant respawn) must wait a full stale window to steal.
3331
+ void Promise.resolve(releaseHostLock?.())
3332
+ .catch(() => {})
3333
+ .then(resolve);
3279
3334
  };
3280
3335
  await new Promise<void>((resolve) => {
3281
3336
  process.on("SIGINT", () => shutdown(resolve));
@@ -0,0 +1,219 @@
1
+ import { mkdtemp, readFile, rm } from "fs/promises";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ acquireWebrtcHostLock,
7
+ isOwnerStale,
8
+ SERVE_LOCK_STALE_MS,
9
+ type ServeLockOwner,
10
+ } from "./serveLock.ts";
11
+
12
+ // Guards the single-WebRTC-host invariant: two `ay serve --webrtc` in one home
13
+ // fight over the persisted room (live outage: orphan host + crash-looping
14
+ // managed daemon + unloadable share link). The loser must fail FAST with the
15
+ // owner's identity, and a dead/stale owner must never wedge the next start.
16
+ describe("acquireWebrtcHostLock", () => {
17
+ let home: string;
18
+ let savedHome: string | undefined;
19
+ const releases: Array<() => Promise<void>> = [];
20
+
21
+ beforeEach(async () => {
22
+ home = await mkdtemp(path.join(tmpdir(), "ay-serve-lock-"));
23
+ savedHome = process.env.AGENT_YES_HOME;
24
+ process.env.AGENT_YES_HOME = home;
25
+ });
26
+
27
+ afterEach(async () => {
28
+ for (const r of releases.splice(0)) await r().catch(() => {});
29
+ if (savedHome === undefined) delete process.env.AGENT_YES_HOME;
30
+ else process.env.AGENT_YES_HOME = savedHome;
31
+ await rm(home, { recursive: true, force: true });
32
+ });
33
+
34
+ it("acquires, stamps an owner, and a second acquire fails fast with that owner", async () => {
35
+ const first = await acquireWebrtcHostLock({ graceMs: 0 });
36
+ expect(first.ok).toBe(true);
37
+ if (first.ok) releases.push(first.release);
38
+
39
+ const owner = JSON.parse(
40
+ await readFile(path.join(home, "webrtc-host.lock", "owner.json"), "utf-8"),
41
+ ) as ServeLockOwner;
42
+ expect(owner.pid).toBe(process.pid);
43
+
44
+ const second = await acquireWebrtcHostLock({ graceMs: 0 });
45
+ expect(second.ok).toBe(false);
46
+ if (!second.ok) expect(second.owner?.pid).toBe(process.pid);
47
+ });
48
+
49
+ it("release frees the lock for the next acquire", async () => {
50
+ const first = await acquireWebrtcHostLock({ graceMs: 0 });
51
+ expect(first.ok).toBe(true);
52
+ if (first.ok) await first.release();
53
+
54
+ const second = await acquireWebrtcHostLock({ graceMs: 0 });
55
+ expect(second.ok).toBe(true);
56
+ if (second.ok) releases.push(second.release);
57
+ });
58
+
59
+ it("steals a stale lock whose heartbeat went quiet (SIGKILLed host)", async () => {
60
+ // Forge an owner: alive pid but ancient beat — a host whose interval died.
61
+ const lockDir = path.join(home, "webrtc-host.lock");
62
+ const { mkdir, writeFile } = await import("fs/promises");
63
+ await mkdir(lockDir, { recursive: true });
64
+ await writeFile(
65
+ path.join(lockDir, "owner.json"),
66
+ JSON.stringify({
67
+ pid: process.pid,
68
+ started_at: Date.now() - 60_000,
69
+ beat_at: Date.now() - SERVE_LOCK_STALE_MS - 1_000,
70
+ }),
71
+ );
72
+ const got = await acquireWebrtcHostLock({ graceMs: 0 });
73
+ expect(got.ok).toBe(true);
74
+ if (got.ok) releases.push(got.release);
75
+ });
76
+
77
+ it("steals a lock whose owner pid is dead", async () => {
78
+ // A freshly-exited child pid is reliably dead. Spawn the current runtime
79
+ // (bun or node — both take -e) so this also runs on Windows, via
80
+ // node:child_process (not Bun.spawn) for the node vitest matrix.
81
+ const { spawnSync } = await import("node:child_process");
82
+ const deadPid = spawnSync(process.execPath, ["-e", "0"]).pid!;
83
+
84
+ const lockDir = path.join(home, "webrtc-host.lock");
85
+ const { mkdir, writeFile } = await import("fs/promises");
86
+ await mkdir(lockDir, { recursive: true });
87
+ await writeFile(
88
+ path.join(lockDir, "owner.json"),
89
+ JSON.stringify({ pid: deadPid, started_at: Date.now(), beat_at: Date.now() }),
90
+ );
91
+ const got = await acquireWebrtcHostLock({ graceMs: 0 });
92
+ expect(got.ok).toBe(true);
93
+ if (got.ok) releases.push(got.release);
94
+ });
95
+ });
96
+
97
+ describe("isOwnerStale", () => {
98
+ const now = 1_000_000;
99
+ const owner = (beatAgo: number): ServeLockOwner => ({
100
+ pid: 4242,
101
+ started_at: now - 60_000,
102
+ beat_at: now - beatAgo,
103
+ });
104
+
105
+ it("absent/torn owner is stale (mkdir won but the write died)", () => {
106
+ expect(isOwnerStale(null, now, () => true)).toBe(true);
107
+ });
108
+
109
+ it("dead pid is stale regardless of heartbeat", () => {
110
+ expect(isOwnerStale(owner(0), now, () => false)).toBe(true);
111
+ });
112
+
113
+ it("live pid with a fresh beat is NOT stale", () => {
114
+ expect(isOwnerStale(owner(SERVE_LOCK_STALE_MS - 1), now, () => true)).toBe(false);
115
+ });
116
+
117
+ it("live pid with a quiet heartbeat past the window is stale", () => {
118
+ expect(isOwnerStale(owner(SERVE_LOCK_STALE_MS + 1), now, () => true)).toBe(true);
119
+ });
120
+ });
121
+
122
+ // Coverage for the held-lock lifecycle: heartbeat refresh, thief detection,
123
+ // live-owner takeover, and the bounded grace wait.
124
+ describe("acquireWebrtcHostLock — held-lock lifecycle", () => {
125
+ let home: string;
126
+ let savedHome: string | undefined;
127
+ const releases: Array<() => Promise<void>> = [];
128
+
129
+ beforeEach(async () => {
130
+ home = await mkdtemp(path.join(tmpdir(), "ay-serve-lock2-"));
131
+ savedHome = process.env.AGENT_YES_HOME;
132
+ process.env.AGENT_YES_HOME = home;
133
+ });
134
+
135
+ afterEach(async () => {
136
+ for (const r of releases.splice(0)) await r().catch(() => {});
137
+ if (savedHome === undefined) delete process.env.AGENT_YES_HOME;
138
+ else process.env.AGENT_YES_HOME = savedHome;
139
+ await rm(home, { recursive: true, force: true });
140
+ });
141
+
142
+ const ownerFile = () => path.join(home, "webrtc-host.lock", "owner.json");
143
+ const readOwnerFile = async () => JSON.parse(await readFile(ownerFile(), "utf-8")) as ServeLockOwner;
144
+
145
+ it("heartbeats refresh beat_at while held", async () => {
146
+ const got = await acquireWebrtcHostLock({ graceMs: 0, beatMs: 40 });
147
+ expect(got.ok).toBe(true);
148
+ if (got.ok) releases.push(got.release);
149
+ const before = (await readOwnerFile()).beat_at;
150
+ await new Promise((r) => setTimeout(r, 150));
151
+ const after = (await readOwnerFile()).beat_at;
152
+ expect(after).toBeGreaterThan(before);
153
+ });
154
+
155
+ it("heartbeat stops itself once a thief owns the file (never clobbers the thief)", async () => {
156
+ const got = await acquireWebrtcHostLock({ graceMs: 0, beatMs: 40 });
157
+ expect(got.ok).toBe(true);
158
+ if (got.ok) releases.push(got.release);
159
+ const { writeFile: wf } = await import("fs/promises");
160
+ const thief: ServeLockOwner = { pid: process.pid + 1, started_at: Date.now(), beat_at: 42 };
161
+ await wf(ownerFile(), JSON.stringify(thief));
162
+ await new Promise((r) => setTimeout(r, 150));
163
+ expect((await readOwnerFile()).pid).toBe(thief.pid); // our beat backed off
164
+ expect((await readOwnerFile()).beat_at).toBe(42);
165
+ });
166
+
167
+ it("takeover stops a live owner and takes the room", async () => {
168
+ const { spawn } = await import("node:child_process");
169
+ // Long-lived victim via the current runtime — /bin/sh doesn't exist on win32.
170
+ const victim = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], {
171
+ stdio: "ignore",
172
+ });
173
+ const victimPid = victim.pid!;
174
+ const { mkdir: mkd, writeFile: wf } = await import("fs/promises");
175
+ await mkd(path.join(home, "webrtc-host.lock"), { recursive: true });
176
+ await wf(
177
+ ownerFile(),
178
+ JSON.stringify({ pid: victimPid, started_at: Date.now(), beat_at: Date.now() }),
179
+ );
180
+
181
+ const got = await acquireWebrtcHostLock({ takeover: true, graceMs: 0, takeoverWaitMs: 100 });
182
+ expect(got.ok).toBe(true);
183
+ if (got.ok) releases.push(got.release);
184
+ expect((await readOwnerFile()).pid).toBe(process.pid);
185
+ // The victim was killed (SIGTERM or the escalation SIGKILL).
186
+ await new Promise((r) => setTimeout(r, 100));
187
+ expect(victim.exitCode !== null || victim.signalCode !== null).toBe(true);
188
+ });
189
+
190
+ it.skipIf(process.platform === "win32")(
191
+ "escalates to SIGKILL when the owner ignores SIGTERM",
192
+ async () => {
193
+ const { spawn } = await import("node:child_process");
194
+ const stubborn = spawn("/bin/sh", ["-c", "trap '' TERM; sleep 30"], { stdio: "ignore" });
195
+ const pid = stubborn.pid!;
196
+ await new Promise((r) => setTimeout(r, 100)); // let the trap install
197
+ const { mkdir: mkd, writeFile: wf } = await import("fs/promises");
198
+ await mkd(path.join(home, "webrtc-host.lock"), { recursive: true });
199
+ await wf(ownerFile(), JSON.stringify({ pid, started_at: Date.now(), beat_at: Date.now() }));
200
+
201
+ const got = await acquireWebrtcHostLock({ takeover: true, graceMs: 0, takeoverWaitMs: 200 });
202
+ expect(got.ok).toBe(true);
203
+ if (got.ok) releases.push(got.release);
204
+ await new Promise((r) => setTimeout(r, 100));
205
+ expect(stubborn.signalCode).toBe("SIGKILL");
206
+ },
207
+ );
208
+
209
+ it("waits out the grace window against a live owner, then reports it", async () => {
210
+ const holder = await acquireWebrtcHostLock({ graceMs: 0 });
211
+ expect(holder.ok).toBe(true);
212
+ if (holder.ok) releases.push(holder.release);
213
+ const t0 = Date.now();
214
+ const got = await acquireWebrtcHostLock({ graceMs: 300 });
215
+ expect(got.ok).toBe(false);
216
+ if (!got.ok) expect(got.owner?.pid).toBe(process.pid);
217
+ expect(Date.now() - t0).toBeGreaterThanOrEqual(250); // actually waited a beat
218
+ });
219
+ });
@@ -0,0 +1,172 @@
1
+ import { mkdir, readFile, rm, writeFile, rename } from "fs/promises";
2
+ import path from "path";
3
+ import { agentYesHome } from "./agentYesHome.ts";
4
+ import { isTransientLockMkdirError } from "./notifyStore.ts";
5
+
6
+ /**
7
+ * Singleton lock for the WebRTC host role: one `ay serve` host per
8
+ * ~/.agent-yes. Two hosts sharing the persisted room (~/.agent-yes/.share-room)
9
+ * fight over every viewer connection — the exact outage seen live: an orphaned
10
+ * `ay serve --webrtc` from a previous manager era plus a manual run left the
11
+ * managed daemon crash-looping (12 watchdog restarts) and the share link
12
+ * unloadable. The lock makes the loser fail FAST with a pointer to the owner
13
+ * instead of silently contending.
14
+ *
15
+ * mkdir-based (atomic on every platform), owner JSON alongside, heartbeat
16
+ * refresh while held. A stale owner — dead pid, or a heartbeat older than
17
+ * SERVE_LOCK_STALE_MS (a SIGKILLed host can't clean up) — is stolen, so a
18
+ * crashed host never wedges the next start.
19
+ */
20
+
21
+ export const SERVE_LOCK_STALE_MS = 20_000;
22
+ export const SERVE_LOCK_BEAT_MS = 5_000;
23
+ // Riding out a manager roll-forward: stop-old/start-new overlap briefly, so a
24
+ // new host retries for a grace window before declaring the lock busy.
25
+ export const SERVE_LOCK_GRACE_MS = 12_000;
26
+
27
+ export type ServeLockOwner = { pid: number; started_at: number; beat_at: number };
28
+
29
+ export type ServeLockResult =
30
+ | { ok: true; release: () => Promise<void> }
31
+ | { ok: false; owner: ServeLockOwner | null };
32
+
33
+ function lockDir(): string {
34
+ return path.join(agentYesHome(), "webrtc-host.lock");
35
+ }
36
+ function ownerPath(): string {
37
+ return path.join(lockDir(), "owner.json");
38
+ }
39
+
40
+ function pidAlive(pid: number): boolean {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ } catch (e) {
45
+ // EPERM = alive but not ours; only ESRCH means gone.
46
+ return (e as NodeJS.ErrnoException).code === "EPERM";
47
+ }
48
+ }
49
+
50
+ async function readOwner(): Promise<ServeLockOwner | null> {
51
+ try {
52
+ const o = JSON.parse(await readFile(ownerPath(), "utf-8")) as ServeLockOwner;
53
+ return typeof o?.pid === "number" ? o : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Pure staleness decision, exported for tests. */
60
+ export function isOwnerStale(
61
+ owner: ServeLockOwner | null,
62
+ now: number,
63
+ alive: (pid: number) => boolean,
64
+ staleMs = SERVE_LOCK_STALE_MS,
65
+ ): boolean {
66
+ if (!owner) return true; // torn/absent owner: mkdir won but write died
67
+ if (!alive(owner.pid)) return true;
68
+ return now - owner.beat_at > staleMs;
69
+ }
70
+
71
+ // Atomic owner stamp (temp + rename) so a concurrent reader never parses a
72
+ // torn file as "no owner" while we in fact hold the lock.
73
+ async function stampOwner(startedAt: number): Promise<boolean> {
74
+ const tmp = `${ownerPath()}.${process.pid}.tmp`;
75
+ try {
76
+ await writeFile(
77
+ tmp,
78
+ JSON.stringify({ pid: process.pid, started_at: startedAt, beat_at: Date.now() }),
79
+ );
80
+ await rename(tmp, ownerPath());
81
+ return true;
82
+ } catch {
83
+ await rm(tmp, { force: true }).catch(() => {});
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Acquire the host lock, retrying for up to `graceMs`. Returns fast on a live
90
+ * owner once the grace expires (never blocks a daemon boot forever). With
91
+ * `takeover`, a live owner is SIGTERM'd (then SIGKILL'd) and the lock stolen.
92
+ */
93
+ export async function acquireWebrtcHostLock(opts?: {
94
+ takeover?: boolean;
95
+ graceMs?: number;
96
+ staleMs?: number;
97
+ /** Heartbeat cadence override — tests only. */
98
+ beatMs?: number;
99
+ /** SIGTERM→SIGKILL escalation wait for --takeover — tests only. */
100
+ takeoverWaitMs?: number;
101
+ }): Promise<ServeLockResult> {
102
+ const graceMs = opts?.graceMs ?? SERVE_LOCK_GRACE_MS;
103
+ const staleMs = opts?.staleMs ?? SERVE_LOCK_STALE_MS;
104
+ const beatMs = opts?.beatMs ?? SERVE_LOCK_BEAT_MS;
105
+ const startedAt = Date.now();
106
+ const deadline = startedAt + graceMs;
107
+ let tookOver = false;
108
+ for (;;) {
109
+ try {
110
+ await mkdir(lockDir(), { recursive: false });
111
+ if (!(await stampOwner(startedAt))) {
112
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
113
+ return { ok: false, owner: null };
114
+ }
115
+ // Heartbeat while held; stops itself if a thief replaced our owner file.
116
+ const beat = setInterval(() => {
117
+ void (async () => {
118
+ const o = await readOwner();
119
+ if (o?.pid !== process.pid) {
120
+ clearInterval(beat);
121
+ return;
122
+ }
123
+ await stampOwner(startedAt);
124
+ })();
125
+ }, beatMs);
126
+ if (typeof beat.unref === "function") beat.unref();
127
+ return {
128
+ ok: true,
129
+ release: async () => {
130
+ clearInterval(beat);
131
+ if ((await readOwner())?.pid === process.pid)
132
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
133
+ },
134
+ };
135
+ } catch (e) {
136
+ const code = (e as NodeJS.ErrnoException).code;
137
+ if (!isTransientLockMkdirError(code)) throw e;
138
+ if (code !== "EEXIST") {
139
+ // win32 mkdir-vs-rm race: dir mid-delete, just retry.
140
+ await new Promise((r) => setTimeout(r, 25));
141
+ continue;
142
+ }
143
+ const owner = await readOwner();
144
+ if (isOwnerStale(owner, Date.now(), pidAlive, staleMs)) {
145
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
146
+ continue; // re-prove via mkdir — two stealers can't both win
147
+ }
148
+ if (opts?.takeover && owner && !tookOver) {
149
+ tookOver = true; // one shot: never kill a second, newer owner
150
+ try {
151
+ process.kill(owner.pid, "SIGTERM");
152
+ } catch {
153
+ /* already gone */
154
+ }
155
+ // Give it a moment to shut down cleanly (it releases the lock), then
156
+ // escalate; the loop's stale check mops up whatever remains.
157
+ await new Promise((r) => setTimeout(r, opts?.takeoverWaitMs ?? 2_000));
158
+ if (pidAlive(owner.pid)) {
159
+ try {
160
+ process.kill(owner.pid, "SIGKILL");
161
+ } catch {
162
+ /* gone */
163
+ }
164
+ }
165
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
166
+ continue;
167
+ }
168
+ if (Date.now() >= deadline) return { ok: false, owner };
169
+ await new Promise((r) => setTimeout(r, 250));
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseStrayServeProcesses } from "./strayServe.ts";
3
+
4
+ describe("parseStrayServeProcesses", () => {
5
+ it("finds foreground ay serve processes", () => {
6
+ const rows = parseStrayServeProcesses(
7
+ [
8
+ " 101 /Users/me/.bun/bin/bun /Users/me/.bun/bin/ay serve --webrtc",
9
+ " 102 bun /repo/dist/agent-yes.js serve --share",
10
+ ].join("\n"),
11
+ { selfPid: 999, parentPid: 998 },
12
+ );
13
+
14
+ expect(rows).toEqual([
15
+ { pid: 101, command: "/Users/me/.bun/bin/bun /Users/me/.bun/bin/ay serve --webrtc" },
16
+ { pid: 102, command: "bun /repo/dist/agent-yes.js serve --share" },
17
+ ]);
18
+ });
19
+
20
+ it("excludes management commands and this process family", () => {
21
+ const rows = parseStrayServeProcesses(
22
+ [
23
+ " 200 ay serve install --webrtc",
24
+ " 201 ay serve uninstall",
25
+ " 202 ay serve status",
26
+ " 203 ay serve logs",
27
+ " 204 ay serve restart",
28
+ " 300 ay serve --webrtc",
29
+ " 301 ay serve --webrtc",
30
+ " 302 ay serve --webrtc",
31
+ ].join("\n"),
32
+ { selfPid: 300, parentPid: 301 },
33
+ );
34
+
35
+ expect(rows).toEqual([{ pid: 302, command: "ay serve --webrtc" }]);
36
+ });
37
+
38
+ it("ignores unrelated commands", () => {
39
+ const rows = parseStrayServeProcesses(
40
+ [
41
+ " 401 ay send serve --webrtc",
42
+ " 402 agent-yes status",
43
+ " 403 node ./dist/cli.js share",
44
+ ].join("\n"),
45
+ { selfPid: 999, parentPid: 998 },
46
+ );
47
+
48
+ expect(rows).toEqual([]);
49
+ });
50
+ });
@@ -0,0 +1,56 @@
1
+ import { liveEnv } from "./nodeRuntime.ts";
2
+
3
+ export type StrayServeProcess = { pid: number; command: string };
4
+
5
+ const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
6
+
7
+ function isServeCommand(command: string): boolean {
8
+ return (
9
+ /\bay(?:\.js)?\s+serve\b/.test(command) ||
10
+ /\bagent-yes(?:\.js)?\s+serve\b/.test(command) ||
11
+ /\bagent-yes\.js\s+serve\b/.test(command)
12
+ );
13
+ }
14
+
15
+ export function parseStrayServeProcesses(
16
+ psOutput: string,
17
+ opts: { selfPid: number; parentPid: number },
18
+ ): StrayServeProcess[] {
19
+ const ignoredPids = new Set([opts.selfPid, opts.parentPid]);
20
+ const processes: StrayServeProcess[] = [];
21
+
22
+ for (const line of psOutput.split(/\r?\n/)) {
23
+ const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
24
+ if (!match) continue;
25
+
26
+ const pid = Number(match[1]);
27
+ const command = match[2]!;
28
+ if (!Number.isFinite(pid) || ignoredPids.has(pid)) continue;
29
+ if (!isServeCommand(command)) continue;
30
+ if (MANAGEMENT_COMMAND_RE.test(command)) continue;
31
+
32
+ processes.push({ pid, command });
33
+ }
34
+
35
+ return processes;
36
+ }
37
+
38
+ export async function listStrayServeProcesses(): Promise<StrayServeProcess[]> {
39
+ if (process.platform === "win32") return [];
40
+
41
+ try {
42
+ const ps = Bun.spawn(["ps", "-axo", "pid=,command="], {
43
+ stdout: "pipe",
44
+ stderr: "ignore",
45
+ env: liveEnv(),
46
+ });
47
+ const output = await new Response(ps.stdout).text();
48
+ if ((await ps.exited) !== 0) return [];
49
+ return parseStrayServeProcesses(output, {
50
+ selfPid: process.pid,
51
+ parentPid: process.ppid,
52
+ });
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
@@ -1,9 +0,0 @@
1
- import "./ts-C7l18XR8.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-CHLdtpqI.js";
4
- import "./pidStore-BIvsBQ8X.js";
5
- import "./globalPidIndex-CoNr7tS8.js";
6
- import "./messageLog-CxrKJj77.js";
7
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Cu4aeSNQ.js";
8
-
9
- export { SUPPORTED_CLIS };