agent-yes 1.204.0 → 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-D9LzuGpU.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-DqsPHFU9.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-B7NvTmdk.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-TJXTia4N.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-C8ScoMqV.js";
5
- import { n as getRustBinary } from "./rustBinary-DMx8ljD0.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-3GIzND1f.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-BUxPt2CD.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-D9LzuGpU.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-C8ScoMqV.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-B7NvTmdk.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-DAD9Dt9H.js.map
592
+ //# sourceMappingURL=notifyDaemon-DK1HOMjj.js.map
@@ -1,4 +1,4 @@
1
- import { r as getInstalledPackage } from "./versionChecker-C8ScoMqV.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-DMx8ljD0.js.map
228
+ //# sourceMappingURL=rustBinary-Tgn948GR.js.map
@@ -1,10 +1,10 @@
1
- import "./ts-D9LzuGpU.js";
1
+ import "./ts-Dm6jjLwC.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-C8ScoMqV.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-DqsPHFU9.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-DGxbss_D.js.map
154
+ //# sourceMappingURL=schedule-CBoM67Vy.js.map
@@ -1,14 +1,15 @@
1
- import "./ts-D9LzuGpU.js";
1
+ import "./ts-Dm6jjLwC.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import { r as getInstalledPackage } from "./versionChecker-C8ScoMqV.js";
4
- import { t as findSpawnHiddenLauncher } from "./rustBinary-DMx8ljD0.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-DqsPHFU9.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-B7NvTmdk.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,150 @@ 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
+
45
190
  //#endregion
46
191
  //#region ts/strayServe.ts
47
192
  const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
@@ -908,6 +1053,10 @@ Options:
908
1053
  type: "boolean",
909
1054
  default: false,
910
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"
911
1060
  }).help(false).version(false).exitProcess(false).parseAsync();
912
1061
  if (argv.daemon) return cmdServeDaemon("install", rest.filter((a) => a !== "--daemon" && a !== "-d"));
913
1062
  const stray = argv._.map(String);
@@ -929,6 +1078,17 @@ Options:
929
1078
  const scheme = useHttps ? "https" : "http";
930
1079
  const wantWebrtc = argv.webrtc !== void 0 || argv.share !== void 0;
931
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
+ }
932
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");
933
1093
  const token = await loadOrCreateToken(tokenFlag);
934
1094
  const titleCache = /* @__PURE__ */ new Map();
@@ -1497,7 +1657,7 @@ Options:
1497
1657
  }
1498
1658
  });
1499
1659
  if (req.method === "GET" && p === "/api/ws") {
1500
- const ws = await import("./ws-BfqEcKyO.js");
1660
+ const ws = await import("./ws-CSkP2mkD.js");
1501
1661
  try {
1502
1662
  await ws.loadProvision();
1503
1663
  } catch (e) {
@@ -1517,7 +1677,7 @@ Options:
1517
1677
  if (req.method === "GET" && p === "/api/ws/status") {
1518
1678
  const dirRaw = url.searchParams.get("path");
1519
1679
  if (!dirRaw) return new Response("missing ?path=<workspace dir>", { status: 400 });
1520
- const ws = await import("./ws-BfqEcKyO.js");
1680
+ const ws = await import("./ws-CSkP2mkD.js");
1521
1681
  let prov;
1522
1682
  try {
1523
1683
  prov = await ws.loadProvision();
@@ -2073,7 +2233,7 @@ Options:
2073
2233
  const perm = body.perm ?? "r";
2074
2234
  if (perm !== "r" && perm !== "rw") return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
2075
2235
  try {
2076
- const { createScopedShare } = await import("./agentShare-TJXTia4N.js");
2236
+ const { createScopedShare } = await import("./agentShare-99IGGOkv.js");
2077
2237
  const share = await createScopedShare({
2078
2238
  agent: body.agent,
2079
2239
  perm,
@@ -2088,12 +2248,12 @@ Options:
2088
2248
  }
2089
2249
  }
2090
2250
  if (req.method === "GET" && p === "/api/shares") {
2091
- const { listShares } = await import("./agentShare-TJXTia4N.js");
2251
+ const { listShares } = await import("./agentShare-99IGGOkv.js");
2092
2252
  return Response.json(listShares());
2093
2253
  }
2094
2254
  const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
2095
2255
  if (req.method === "DELETE" && revokeM) {
2096
- const { revokeShare } = await import("./agentShare-TJXTia4N.js");
2256
+ const { revokeShare } = await import("./agentShare-99IGGOkv.js");
2097
2257
  const ok = revokeShare(decodeURIComponent(revokeM[1]));
2098
2258
  return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
2099
2259
  }
@@ -2256,7 +2416,10 @@ Options:
2256
2416
  await announce(room, link, false);
2257
2417
  } catch (e) {
2258
2418
  process.stderr.write(`ay serve --webrtc failed: ${e.message}\n`);
2259
- if (!wantHttp) return 1;
2419
+ if (!wantHttp) {
2420
+ await releaseHostLock?.().catch(() => {});
2421
+ return 1;
2422
+ }
2260
2423
  }
2261
2424
  }
2262
2425
  let heartbeat;
@@ -2275,9 +2438,9 @@ Options:
2275
2438
  const shutdown = (resolve) => {
2276
2439
  if (heartbeat) clearInterval(heartbeat);
2277
2440
  closeShare?.();
2278
- import("./agentShare-TJXTia4N.js").then((m) => m.revokeAllShares()).catch(() => {});
2441
+ import("./agentShare-99IGGOkv.js").then((m) => m.revokeAllShares()).catch(() => {});
2279
2442
  server?.stop();
2280
- resolve();
2443
+ Promise.resolve(releaseHostLock?.()).catch(() => {}).then(resolve);
2281
2444
  };
2282
2445
  await new Promise((resolve) => {
2283
2446
  process.on("SIGINT", () => shutdown(resolve));
@@ -2288,4 +2451,4 @@ Options:
2288
2451
 
2289
2452
  //#endregion
2290
2453
  export { cmdServe };
2291
- //# sourceMappingURL=serve-eqRnhz8Z.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-eqRnhz8Z.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-BgD5RFXj.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-eqRnhz8Z.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-BgD5RFXj.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-BfqEcKyO.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-DGxbss_D.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-DAD9Dt9H.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-DAD9Dt9H.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-B7NvTmdk.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-B7NvTmdk.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-C8ScoMqV.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-D9LzuGpU.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.204.0";
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-C8ScoMqV.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-B7NvTmdk.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-BfqEcKyO.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.204.0",
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";
@@ -1221,6 +1222,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1221
1222
  default: false,
1222
1223
  description: "Deprecated no-op — the console can always spawn agents",
1223
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
+ })
1224
1230
  .help(false)
1225
1231
  .version(false)
1226
1232
  .exitProcess(false);
@@ -1273,6 +1279,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
1273
1279
  const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
1274
1280
  const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
1275
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
+
1276
1307
  if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
1277
1308
  process.stderr.write(
1278
1309
  "ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
@@ -3257,7 +3288,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
3257
3288
  await announce(room, link, false);
3258
3289
  } catch (e) {
3259
3290
  process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
3260
- 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
+ }
3261
3297
  }
3262
3298
  }
3263
3299
 
@@ -3289,7 +3325,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
3289
3325
  // Close any scoped single-agent share rooms so viewers get an immediate drop.
3290
3326
  void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
3291
3327
  server?.stop();
3292
- 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);
3293
3334
  };
3294
3335
  await new Promise<void>((resolve) => {
3295
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
+ }
@@ -1,9 +0,0 @@
1
- import "./ts-D9LzuGpU.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-C8ScoMqV.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-DqsPHFU9.js";
8
-
9
- export { SUPPORTED_CLIS };