agent-yes 1.204.0 → 1.206.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.
package/ts/serve.ts CHANGED
@@ -38,7 +38,9 @@ import {
38
38
  } from "./subcommands.ts";
39
39
  import { TYPING_BADGE } from "./badges.ts";
40
40
  import { isTerminalReply } from "./terminalReply.ts";
41
+ import { parseStatusText } from "./statusText.ts";
41
42
  import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
43
+ import { acquireWebrtcHostLock, type ServeLockOwner } from "./serveLock.ts";
42
44
  import { type MailParty, recordInbox } from "./messageLog.ts";
43
45
  import { updateGlobalPidStatus } from "./globalPidIndex.ts";
44
46
  import { spawnRejectionReason } from "./spawnGate.ts";
@@ -57,7 +59,56 @@ import {
57
59
  resolveSpawnCwd,
58
60
  } from "./workspaceConfig.ts";
59
61
 
60
- const DEFAULT_PORT = 7432;
62
+ const DEFAULT_PORT = 0;
63
+ const PORTLESS_APP_NAME = "agent-yes";
64
+ const PORTLESS_CHILD_ENV = "AGENT_YES_PORTLESS_CHILD";
65
+
66
+ export function portlessConsoleUrl(token?: string): string {
67
+ return portlessConsoleUrlForOrigin(
68
+ process.env.PORTLESS_URL?.replace(/\/$/, "") || `https://${PORTLESS_APP_NAME}.localhost`,
69
+ token,
70
+ );
71
+ }
72
+
73
+ function portlessConsoleUrlForOrigin(origin: string, token?: string): string {
74
+ const fragment = token ? `/#k=${encodeURIComponent(token)}` : "/";
75
+ return `${origin}${fragment}`;
76
+ }
77
+
78
+ async function resolvedPortlessConsoleUrl(token?: string): Promise<string> {
79
+ if (process.env.PORTLESS_URL) return portlessConsoleUrl(token);
80
+ try {
81
+ const port = Number(
82
+ (await readFile(path.join(homedir(), ".portless", "proxy.port"), "utf-8")).trim(),
83
+ );
84
+ if (Number.isInteger(port) && port > 0)
85
+ return portlessConsoleUrlForOrigin(
86
+ `https://${PORTLESS_APP_NAME}.localhost${port === 443 ? "" : `:${port}`}`,
87
+ token,
88
+ );
89
+ } catch {
90
+ /* Portless has not created state yet. */
91
+ }
92
+ return portlessConsoleUrl(token);
93
+ }
94
+
95
+ async function runWithPortless(rest: string[]): Promise<number> {
96
+ const portless = Bun.which("portless");
97
+ if (!portless) {
98
+ process.stderr.write(
99
+ "ay serve: Portless is required for local HTTPS. Install it with: npm install -g portless\n",
100
+ );
101
+ return 1;
102
+ }
103
+ const script = process.argv[1];
104
+ const self =
105
+ script && /\.[cm]?[jt]s$/.test(script) ? [process.execPath, script] : [process.execPath];
106
+ const proc = Bun.spawn([portless, PORTLESS_APP_NAME, ...self, "serve", ...rest], {
107
+ stdio: ["inherit", "inherit", "inherit"],
108
+ env: { ...liveEnv(), [PORTLESS_CHILD_ENV]: "1" },
109
+ });
110
+ return (await proc.exited) ?? 1;
111
+ }
61
112
 
62
113
  /**
63
114
  * Normalize a user-supplied GitHub-ish source into the standard
@@ -703,9 +754,28 @@ async function readDaemonServeArgs(mgr: DaemonManager): Promise<string[] | null>
703
754
  }
704
755
  }
705
756
 
706
- function portFromArgs(args: string[]): number {
757
+ function portFromArgs(args: string[]): number | null {
707
758
  const m = /--port[=\s](\d+)/.exec(args.join(" "));
708
- return m ? Number(m[1]) : DEFAULT_PORT;
759
+ return m ? Number(m[1]) : null;
760
+ }
761
+
762
+ function argsUsePortless(args: string[]): boolean {
763
+ const wantsHttp =
764
+ args.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
765
+ !args.some((a) => a.startsWith("--webrtc"));
766
+ return wantsHttp && portFromArgs(args) === null;
767
+ }
768
+
769
+ async function portlessAppPort(): Promise<number | null> {
770
+ try {
771
+ const routes = JSON.parse(
772
+ await readFile(path.join(homedir(), ".portless", "routes.json"), "utf-8"),
773
+ ) as { hostname?: string; port?: number }[];
774
+ const route = routes.find((r) => r.hostname === `${PORTLESS_APP_NAME}.localhost`);
775
+ return typeof route?.port === "number" ? route.port : null;
776
+ } catch {
777
+ return null;
778
+ }
709
779
  }
710
780
 
711
781
  async function warnStrayServeProcesses(): Promise<void> {
@@ -740,7 +810,8 @@ function explicitWebrtcUrl(args: string[]): string | undefined {
740
810
  // Ask the live daemon its version over the local HTTP API. null if it's not
741
811
  // listening (webrtc-only) or too old to expose /api/version — both of which we
742
812
  // treat as "outdated" so a re-install rolls it forward.
743
- async function fetchDaemonVersion(port: number, token: string): Promise<string | null> {
813
+ async function fetchDaemonVersion(port: number | null, token: string): Promise<string | null> {
814
+ if (port === null) return null;
744
815
  try {
745
816
  const r = await fetch(`http://127.0.0.1:${port}/api/version`, {
746
817
  headers: { Authorization: `Bearer ${token}` },
@@ -888,7 +959,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
888
959
  // share link for a WebRTC bridge that isn't actually running. A bare re-run
889
960
  // passes no args, so effArgs === priorArgs and this stays a no-op as before.
890
961
  const sameConfig = JSON.stringify(effArgs) === JSON.stringify(priorArgs);
891
- const runningVer = await fetchDaemonVersion(portFromArgs(effArgs), token);
962
+ const daemonPort = argsUsePortless(effArgs) ? await portlessAppPort() : portFromArgs(effArgs);
963
+ const runningVer = await fetchDaemonVersion(daemonPort, token);
892
964
  if (runningVer === current && sameConfig) {
893
965
  await ensureBootAutostart(mgr);
894
966
  process.stdout.write(`'${DAEMON_NAME}' already running v${current} (up to date)\n`);
@@ -981,7 +1053,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
981
1053
  const code = await proc.exited;
982
1054
  if (code === 0) {
983
1055
  const onBoot = await ensureBootAutostart(mgr);
984
- const port = portFromArgs(effArgs);
1056
+ const port = argsUsePortless(effArgs) ? await portlessAppPort() : portFromArgs(effArgs);
1057
+ const localUrl = argsUsePortless(effArgs) ? await resolvedPortlessConsoleUrl(token) : null;
985
1058
  // Mirror cmdServe's mode resolution: webrtc-only daemons open no HTTP port.
986
1059
  const httpish =
987
1060
  effArgs.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
@@ -1019,8 +1092,12 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1019
1092
  );
1020
1093
  process.stdout.write(`token: ${token}\n\n`);
1021
1094
  if (httpish) {
1022
- process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
1023
- process.stdout.write(` ay remote add <alias> http://${token}@<host>:${port}\n`);
1095
+ if (argsUsePortless(effArgs)) {
1096
+ process.stdout.write(` web console: ${localUrl}\n`);
1097
+ } else if (port !== null) {
1098
+ process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
1099
+ process.stdout.write(` ay remote add <alias> http://${token}@<host>:${port}\n`);
1100
+ }
1024
1101
  }
1025
1102
  process.stdout.write(` ay serve logs # view server logs\n`);
1026
1103
  process.stdout.write(` ay serve uninstall # remove daemon\n`);
@@ -1053,7 +1130,9 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1053
1130
  const daemonArgs = mgr ? await readDaemonServeArgs(mgr) : null;
1054
1131
  const installed = daemonArgs !== null;
1055
1132
  const a = daemonArgs ?? [];
1056
- const port = portFromArgs(a);
1133
+ const local = argsUsePortless(a);
1134
+ const port = local ? await portlessAppPort() : portFromArgs(a);
1135
+ const localUrl = local ? await resolvedPortlessConsoleUrl(token ?? undefined) : null;
1057
1136
  // Mirror cmdServe/install mode resolution: webrtc when --webrtc/--share; http
1058
1137
  // when --http/--share OR no --webrtc at all (http is the implicit default).
1059
1138
  const webrtcish = a.some((x) => x.startsWith("--webrtc") || x.startsWith("--share"));
@@ -1075,6 +1154,7 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1075
1154
  installed,
1076
1155
  mode,
1077
1156
  port: httpish ? port : null,
1157
+ localUrl: httpish ? localUrl : null,
1078
1158
  reachable: runningVersion !== null,
1079
1159
  runningVersion,
1080
1160
  currentVersion: current,
@@ -1095,25 +1175,32 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1095
1175
  w(`manager: ${mgr ? mgr.id : "none — install pm2 or oxmgr to daemonize"}`);
1096
1176
  if (installed) {
1097
1177
  w(`installed: yes (via ${mgr!.id})`);
1098
- w(`mode: ${mode}${httpish ? ` (port ${port})` : ""}`);
1178
+ w(`mode: ${mode}${httpish ? (local ? ` (${localUrl})` : ` (port ${port})`) : ""}`);
1099
1179
  if (a.length) w(`args: ${a.join(" ")}`);
1100
1180
  } else {
1101
1181
  w(`installed: no — start a daemon with: ay serve install [--share]`);
1102
1182
  }
1103
1183
  if (runningVersion !== null) {
1104
1184
  const tag = runningVersion === current ? "up to date" : `outdated (current v${current})`;
1105
- w(`http api: reachable on 127.0.0.1:${port} — v${runningVersion} (${tag})`);
1185
+ w(
1186
+ `http api: reachable ${local ? `via ${localUrl}` : `on 127.0.0.1:${port}`} — v${runningVersion} (${tag})`,
1187
+ );
1106
1188
  } else if (mode === "webrtc") {
1107
1189
  w(`http api: none (webrtc-only)`);
1108
1190
  } else {
1109
- w(`http api: not reachable on 127.0.0.1:${port} (not running)`);
1191
+ w(
1192
+ `http api: not reachable ${local ? `via ${localUrl}` : `on 127.0.0.1:${port}`} (not running)`,
1193
+ );
1110
1194
  }
1111
1195
  w(`token: ${token ?? "(none yet — created on first serve)"}`);
1112
1196
  if (shareLink) w(`share link: ${shareLink}`);
1113
1197
  if (token && httpish) {
1114
1198
  w();
1115
- w(`connect: ay ls ${token}@<host>:${port}`);
1116
- w(` ay remote add <alias> http://${token}@<host>:${port}`);
1199
+ if (local) w(`console: ${localUrl}`);
1200
+ else if (port !== null) {
1201
+ w(`connect: ay ls ${token}@<host>:${port}`);
1202
+ w(` ay remote add <alias> http://${token}@<host>:${port}`);
1203
+ }
1117
1204
  }
1118
1205
  return 0;
1119
1206
  }
@@ -1137,11 +1224,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
1137
1224
  ` (stable link across restarts; delete the file to rotate).\n` +
1138
1225
  ` --share [URL] Legacy alias for --http --webrtc\n\n` +
1139
1226
  `Options:\n` +
1140
- ` --port N Port to listen on (default: ${DEFAULT_PORT})\n` +
1227
+ ` --port N Bypass Portless and listen on a fixed HTTP port\n` +
1141
1228
  ` --host HOST Interface to bind (default: 127.0.0.1; use 0.0.0.0 to expose)\n` +
1142
1229
  ` --token TOKEN Auth token (auto-generated and saved if omitted)\n` +
1143
1230
  ` -d, --daemon Install these flags as a background daemon (pm2/oxmgr)\n` +
1144
1231
  ` (same as: ay serve install <flags>)\n` +
1232
+ ` --local Explicitly use Portless (the default for HTTP mode)\n` +
1233
+ ` ${portlessConsoleUrl()}\n` +
1145
1234
  ` --allow-spawn Deprecated no-op — the console can always spawn agents\n` +
1146
1235
  ` --tls-cert FILE TLS certificate PEM\n` +
1147
1236
  ` --tls-key FILE TLS private key PEM\n\n` +
@@ -1151,8 +1240,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
1151
1240
  ` ay serve uninstall remove daemon\n` +
1152
1241
  ` ay serve logs view daemon logs\n\n` +
1153
1242
  `Once running, connect from another machine:\n` +
1154
- ` ay ls <token>@<host>:${DEFAULT_PORT}\n` +
1155
- ` ay remote add <alias> http://<token>@<host>:${DEFAULT_PORT}\n`,
1243
+ ` ay ls <token>@<host>:<port>\n` +
1244
+ ` ay remote add <alias> http://<token>@<host>:<port>\n`,
1156
1245
  );
1157
1246
  return 0;
1158
1247
  }
@@ -1188,7 +1277,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1188
1277
 
1189
1278
  const y = yargs(rest)
1190
1279
  .usage("Usage: ay serve [options]")
1191
- .option("port", { type: "number", default: DEFAULT_PORT, description: "Port to listen on" })
1280
+ .option("port", { type: "number", description: "Bypass Portless and listen on a fixed port" })
1192
1281
  .option("host", {
1193
1282
  type: "string",
1194
1283
  default: "127.0.0.1",
@@ -1216,11 +1305,22 @@ export async function cmdServe(rest: string[]): Promise<number> {
1216
1305
  default: false,
1217
1306
  description: "Install as a background daemon (same as: ay serve install <flags>)",
1218
1307
  })
1308
+ .option("local", {
1309
+ type: "boolean",
1310
+ default: false,
1311
+ description: `Use Portless at ${portlessConsoleUrl()} (default for HTTP mode)`,
1312
+ })
1219
1313
  .option("allow-spawn", {
1220
1314
  type: "boolean",
1221
1315
  default: false,
1222
1316
  description: "Deprecated no-op — the console can always spawn agents",
1223
1317
  })
1318
+ .option("takeover", {
1319
+ type: "boolean",
1320
+ default: false,
1321
+ description:
1322
+ "If another ay serve already hosts this home's WebRTC room, stop it and take the room",
1323
+ })
1224
1324
  .help(false)
1225
1325
  .version(false)
1226
1326
  .exitProcess(false);
@@ -1234,6 +1334,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
1234
1334
  return cmdServeDaemon("install", fwd);
1235
1335
  }
1236
1336
 
1337
+ const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
1338
+ const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
1339
+ const fixedPort = typeof argv.port === "number";
1340
+ const usePortless = wantHttp && !fixedPort;
1341
+ if (argv.local === true && fixedPort) {
1342
+ process.stderr.write("ay serve: --local and --port cannot be used together\n");
1343
+ return 1;
1344
+ }
1345
+ if (usePortless && process.env[PORTLESS_CHILD_ENV] !== "1" && process.env.PORTLESS !== "0") {
1346
+ return runWithPortless(rest);
1347
+ }
1348
+
1237
1349
  // `ay serve` takes only flags (plus the install/uninstall/logs subcommands
1238
1350
  // handled above). A bare word like `ay serve share` is silently dropped into
1239
1351
  // argv._ by yargs and would otherwise start in the wrong mode — most often
@@ -1254,7 +1366,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1254
1366
  // top-level agents regardless of the spawn path.
1255
1367
  delete process.env.AGENT_YES_PID;
1256
1368
 
1257
- const port = (argv.port as number) ?? DEFAULT_PORT;
1369
+ const port = (argv.port as number | undefined) ?? (Number(process.env.PORT) || DEFAULT_PORT);
1258
1370
  const host = (argv.host as string) ?? "127.0.0.1";
1259
1371
  const tokenFlag = typeof argv.token === "string" ? argv.token : undefined;
1260
1372
  const certPath = typeof argv["tls-cert"] === "string" ? argv["tls-cert"] : undefined;
@@ -1270,8 +1382,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
1270
1382
  // Modes: --http (HTTP listener + web console), --webrtc (port-free WebRTC
1271
1383
  // share), or both. Bare `ay serve` stays HTTP-only; --share keeps its old
1272
1384
  // meaning (HTTP + WebRTC) for existing invocations.
1273
- const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
1274
- const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
1385
+
1386
+ // Singleton WebRTC host per ~/.agent-yes: a second host joining the SAME
1387
+ // persisted room (~/.agent-yes/.share-room) fights the first over every
1388
+ // viewer connection — seen live as an unloadable share link plus the managed
1389
+ // daemon crash-looping under the health watchdog while an orphaned manual
1390
+ // `ay serve --webrtc` held the room. Fail fast with a pointer to the owner
1391
+ // instead of contending. HTTP-only serves skip this (port collisions already
1392
+ // fail naturally with EADDRINUSE). AGENT_YES_NO_SERVE_LOCK=1 opts out for
1393
+ // exotic multi-room setups (explicit webrtc:// URLs to different rooms).
1394
+ let releaseHostLock: (() => Promise<void>) | null = null;
1395
+ if (wantWebrtc && process.env.AGENT_YES_NO_SERVE_LOCK !== "1") {
1396
+ const lock = await acquireWebrtcHostLock({ takeover: argv.takeover === true });
1397
+ if (!lock.ok) {
1398
+ const o: ServeLockOwner | null = lock.owner;
1399
+ const since = o ? new Date(o.started_at).toLocaleString() : "unknown";
1400
+ process.stderr.write(
1401
+ `ay serve: another instance${o ? ` (pid ${o.pid}, started ${since})` : ""} already hosts this home's WebRTC room\n` +
1402
+ ` ay serve status # inspect it\n` +
1403
+ ` ay serve --webrtc --takeover # stop it and take the room\n` +
1404
+ ` AGENT_YES_NO_SERVE_LOCK=1 # opt out (multi-room setups only)\n`,
1405
+ );
1406
+ return 1;
1407
+ }
1408
+ releaseHostLock = lock.release;
1409
+ }
1275
1410
 
1276
1411
  if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
1277
1412
  process.stderr.write(
@@ -1404,6 +1539,28 @@ export async function cmdServe(rest: string[]): Promise<number> {
1404
1539
  }
1405
1540
  };
1406
1541
 
1542
+ // Per-agent live status description from the rendered screen. Claude Code
1543
+ // paints a spinner/status line while working; surfacing it lets the left panel
1544
+ // show "what it's doing" without opening the terminal.
1545
+ const statusTextCache = new Map<
1546
+ string,
1547
+ { size: number; mtimeMs: number; statusText: string | null }
1548
+ >();
1549
+ const logStatusText = async (logFile: string | null | undefined): Promise<string | null> => {
1550
+ if (!logFile) return null;
1551
+ try {
1552
+ const { size, mtimeMs } = await stat(logFile);
1553
+ const hit = statusTextCache.get(logFile);
1554
+ if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.statusText;
1555
+ const lines = await renderLogTailLines(logFile, 40);
1556
+ const statusText = lines ? parseStatusText(lines) : null;
1557
+ statusTextCache.set(logFile, { size, mtimeMs, statusText });
1558
+ return statusText;
1559
+ } catch {
1560
+ return null;
1561
+ }
1562
+ };
1563
+
1407
1564
  // Per-agent "waiting on you" detection: the agent is parked on an interactive
1408
1565
  // menu it did NOT auto-resolve (config `needsInput` patterns). Same source and
1409
1566
  // classifier as `ay ls` / `ay status`, so the console's dot matches the CLI's
@@ -1670,6 +1827,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1670
1827
  // WHAT the agent is waiting on. Null otherwise.
1671
1828
  question,
1672
1829
  title: await logTitle(r.log_file),
1830
+ status_text: status === "exited" ? null : await logStatusText(r.log_file),
1673
1831
  git: status === "exited" ? null : await gitStatus(r.cwd),
1674
1832
  // Task progress from the rendered todo block (null when none detected → no
1675
1833
  // badge). Skipped for exited agents — their screen is no longer live.
@@ -3177,17 +3335,20 @@ export async function cmdServe(rest: string[]): Promise<number> {
3177
3335
  // server is null only when we degraded to WebRTC-only above (port wedged);
3178
3336
  // skip the HTTP connection banner since nothing is listening on the port.
3179
3337
  if (server) {
3338
+ const listenPort = server.port;
3180
3339
  const uiHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
3181
- process.stdout.write(`ay serve ${scheme}://${host}:${port}\n`);
3340
+ process.stdout.write(`ay serve ${scheme}://${host}:${listenPort}\n`);
3182
3341
  process.stdout.write(`token: ${token}\n\n`);
3183
3342
  process.stdout.write(`web console (token in the # is eaten on open):\n`);
3184
- process.stdout.write(` ${scheme}://${uiHost}:${port}/#k=${token}\n\n`);
3343
+ process.stdout.write(
3344
+ ` ${usePortless ? portlessConsoleUrl(token) : `${scheme}://${uiHost}:${listenPort}/#k=${token}`}\n\n`,
3345
+ );
3185
3346
  process.stdout.write(`connect from another machine:\n`);
3186
- process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
3187
- process.stdout.write(` ay tail ${token}@<host>:${port}:<keyword>\n`);
3188
- process.stdout.write(` ay send ${token}@<host>:${port}:<keyword> "message"\n\n`);
3347
+ process.stdout.write(` ay ls ${token}@<host>:${listenPort}\n`);
3348
+ process.stdout.write(` ay tail ${token}@<host>:${listenPort}:<keyword>\n`);
3349
+ process.stdout.write(` ay send ${token}@<host>:${listenPort}:<keyword> "message"\n\n`);
3189
3350
  process.stdout.write(`save as alias:\n`);
3190
- process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${port}\n\n`);
3351
+ process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${listenPort}\n\n`);
3191
3352
  if (!useHttps) {
3192
3353
  process.stdout.write(
3193
3354
  `for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
@@ -3257,7 +3418,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
3257
3418
  await announce(room, link, false);
3258
3419
  } catch (e) {
3259
3420
  process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
3260
- if (!wantHttp) return 1; // nothing else is running
3421
+ if (!wantHttp) {
3422
+ // Release promptly so the manager's instant respawn doesn't have to
3423
+ // wait out the stale window to reclaim the host lock.
3424
+ await releaseHostLock?.().catch(() => {});
3425
+ return 1; // nothing else is running
3426
+ }
3261
3427
  }
3262
3428
  }
3263
3429
 
@@ -3289,7 +3455,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
3289
3455
  // Close any scoped single-agent share rooms so viewers get an immediate drop.
3290
3456
  void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
3291
3457
  server?.stop();
3292
- resolve();
3458
+ // Gate exit on the lock release: resolve() ends cmdServe and the process,
3459
+ // and an un-awaited rm would race that exit — leaving a lock the next host
3460
+ // (a manager's instant respawn) must wait a full stale window to steal.
3461
+ void Promise.resolve(releaseHostLock?.())
3462
+ .catch(() => {})
3463
+ .then(resolve);
3293
3464
  };
3294
3465
  await new Promise<void>((resolve) => {
3295
3466
  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
+ });