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/README.md +2 -0
- package/dist/SUPPORTED_CLIS-CQ6e94ln.js +9 -0
- package/dist/{SUPPORTED_CLIS-DqsPHFU9.js → SUPPORTED_CLIS-DQSgURGW.js} +2 -2
- package/dist/{agentShare-TJXTia4N.js → agentShare-k8224qiZ.js} +3 -3
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DAD9Dt9H.js → notifyDaemon-BbVnNvlo.js} +2 -2
- package/dist/{rustBinary-DMx8ljD0.js → rustBinary-BjRvvEs8.js} +2 -2
- package/dist/{schedule-DGxbss_D.js → schedule-DJ-v4yVa.js} +4 -4
- package/dist/{serve-eqRnhz8Z.js → serve-crLypY_c.js} +328 -42
- package/dist/{setup-BgD5RFXj.js → setup-BPNkeSmY.js} +2 -2
- package/dist/{share-Ciw1mWVN.js → share-_QYjPN7q.js} +1 -1
- package/dist/{share-BfIU8t_h.js → share-vchIyVd8.js} +98 -5
- package/dist/{subcommands-3GIzND1f.js → subcommands-DCGZjr4t.js} +1 -1
- package/dist/{subcommands-B7NvTmdk.js → subcommands-DkrMRVNh.js} +8 -8
- package/dist/{ts-D9LzuGpU.js → ts-DnqRreF4.js} +2 -2
- package/dist/{versionChecker-C8ScoMqV.js → versionChecker-d5c6_vgS.js} +2 -2
- package/dist/{ws-BfqEcKyO.js → ws-BmHpWaG_.js} +2 -2
- package/lab/ui/console-logic.js +3 -1
- package/lab/ui/index.html +39 -5
- package/package.json +1 -1
- package/ts/serve.spec.ts +13 -1
- package/ts/serve.ts +200 -29
- package/ts/serveLock.spec.ts +219 -0
- package/ts/serveLock.ts +172 -0
- package/ts/share.ts +80 -1
- package/ts/statusText.spec.ts +17 -0
- package/ts/statusText.ts +19 -0
- package/dist/SUPPORTED_CLIS-BUxPt2CD.js +0 -9
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 =
|
|
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]) :
|
|
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
|
|
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
|
-
|
|
1023
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(`
|
|
1116
|
-
|
|
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
|
|
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
|
|
1155
|
-
` ay remote add <alias> http://<token>@<host
|
|
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",
|
|
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
|
-
|
|
1274
|
-
|
|
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}:${
|
|
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(
|
|
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>:${
|
|
3187
|
-
process.stdout.write(` ay tail ${token}@<host>:${
|
|
3188
|
-
process.stdout.write(` ay send ${token}@<host>:${
|
|
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>:${
|
|
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)
|
|
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
|
+
});
|