agent-yes 1.205.0 → 1.207.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-C8-qh98a.js → SUPPORTED_CLIS-B1CDJwYn.js} +2 -2
- package/dist/SUPPORTED_CLIS-vH8hvcZr.js +9 -0
- package/dist/{agentShare-99IGGOkv.js → agentShare-DdEbHtAn.js} +3 -3
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DK1HOMjj.js → notifyDaemon-DivHVzZx.js} +2 -2
- package/dist/{rustBinary-Tgn948GR.js → rustBinary-CuBIr-zX.js} +2 -2
- package/dist/{schedule-CBoM67Vy.js → schedule-Bg_CDt2j.js} +4 -4
- package/dist/{serve-IvOlaL-7.js → serve-B-ZCy67S.js} +258 -41
- package/dist/{setup-BaKdS-3i.js → setup-D6UBzk4-.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-Bs7wJRBB.js → subcommands-6AQ8egRn.js} +7 -7
- package/dist/{subcommands-DyDHZ5YI.js → subcommands-CeRH9-_h.js} +1 -1
- package/dist/{ts-Dm6jjLwC.js → ts-DZWZvbLM.js} +2 -2
- package/dist/{versionChecker-BomfUpSo.js → versionChecker-C2CV9VOX.js} +2 -2
- package/dist/{ws-CSkP2mkD.js → ws-CGNmxCPD.js} +2 -2
- package/lab/ui/console-logic.js +23 -1
- package/lab/ui/index.html +188 -29
- package/package.json +1 -1
- package/ts/serve.spec.ts +13 -1
- package/ts/serve.ts +263 -34
- package/ts/serveLock.spec.ts +15 -14
- package/ts/share.ts +80 -1
- package/ts/sizeNego.spec.ts +61 -0
- package/ts/sizeNego.ts +59 -0
- package/ts/statusText.spec.ts +17 -0
- package/ts/statusText.ts +19 -0
- package/ts/ws.spec.ts +8 -10
- package/dist/SUPPORTED_CLIS-D3XTFfS7.js +0 -9
package/ts/serve.ts
CHANGED
|
@@ -38,6 +38,7 @@ 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";
|
|
42
43
|
import { acquireWebrtcHostLock, type ServeLockOwner } from "./serveLock.ts";
|
|
43
44
|
import { type MailParty, recordInbox } from "./messageLog.ts";
|
|
@@ -47,6 +48,7 @@ import { findSpawnHiddenLauncher } from "./rustBinary.ts";
|
|
|
47
48
|
import { pgidForWrapper } from "./reaper.ts";
|
|
48
49
|
import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
|
|
49
50
|
import { getInstalledPackage } from "./versionChecker.ts";
|
|
51
|
+
import { negotiateSize, sanitizeCap, type SizeCap } from "./sizeNego.ts";
|
|
50
52
|
import { listStrayServeProcesses } from "./strayServe.ts";
|
|
51
53
|
import {
|
|
52
54
|
getProvisionHook,
|
|
@@ -58,7 +60,56 @@ import {
|
|
|
58
60
|
resolveSpawnCwd,
|
|
59
61
|
} from "./workspaceConfig.ts";
|
|
60
62
|
|
|
61
|
-
const DEFAULT_PORT =
|
|
63
|
+
const DEFAULT_PORT = 0;
|
|
64
|
+
const PORTLESS_APP_NAME = "agent-yes";
|
|
65
|
+
const PORTLESS_CHILD_ENV = "AGENT_YES_PORTLESS_CHILD";
|
|
66
|
+
|
|
67
|
+
export function portlessConsoleUrl(token?: string): string {
|
|
68
|
+
return portlessConsoleUrlForOrigin(
|
|
69
|
+
process.env.PORTLESS_URL?.replace(/\/$/, "") || `https://${PORTLESS_APP_NAME}.localhost`,
|
|
70
|
+
token,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function portlessConsoleUrlForOrigin(origin: string, token?: string): string {
|
|
75
|
+
const fragment = token ? `/#k=${encodeURIComponent(token)}` : "/";
|
|
76
|
+
return `${origin}${fragment}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function resolvedPortlessConsoleUrl(token?: string): Promise<string> {
|
|
80
|
+
if (process.env.PORTLESS_URL) return portlessConsoleUrl(token);
|
|
81
|
+
try {
|
|
82
|
+
const port = Number(
|
|
83
|
+
(await readFile(path.join(homedir(), ".portless", "proxy.port"), "utf-8")).trim(),
|
|
84
|
+
);
|
|
85
|
+
if (Number.isInteger(port) && port > 0)
|
|
86
|
+
return portlessConsoleUrlForOrigin(
|
|
87
|
+
`https://${PORTLESS_APP_NAME}.localhost${port === 443 ? "" : `:${port}`}`,
|
|
88
|
+
token,
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
/* Portless has not created state yet. */
|
|
92
|
+
}
|
|
93
|
+
return portlessConsoleUrl(token);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function runWithPortless(rest: string[]): Promise<number> {
|
|
97
|
+
const portless = Bun.which("portless");
|
|
98
|
+
if (!portless) {
|
|
99
|
+
process.stderr.write(
|
|
100
|
+
"ay serve: Portless is required for local HTTPS. Install it with: npm install -g portless\n",
|
|
101
|
+
);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
const script = process.argv[1];
|
|
105
|
+
const self =
|
|
106
|
+
script && /\.[cm]?[jt]s$/.test(script) ? [process.execPath, script] : [process.execPath];
|
|
107
|
+
const proc = Bun.spawn([portless, PORTLESS_APP_NAME, ...self, "serve", ...rest], {
|
|
108
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
109
|
+
env: { ...liveEnv(), [PORTLESS_CHILD_ENV]: "1" },
|
|
110
|
+
});
|
|
111
|
+
return (await proc.exited) ?? 1;
|
|
112
|
+
}
|
|
62
113
|
|
|
63
114
|
/**
|
|
64
115
|
* Normalize a user-supplied GitHub-ish source into the standard
|
|
@@ -185,10 +236,19 @@ const SESSION_PIN_ENV = new Set([
|
|
|
185
236
|
// ~/.bun/bin, ~/.cargo/bin, Homebrew, nvm shims, etc. A console-spawned agent
|
|
186
237
|
// that inherits that env can't find `bun`/`bunx`/etc. (the user reported "bunx:
|
|
187
238
|
// command not found" when spawning a new agent from the web UI). We recover the
|
|
188
|
-
// real environment by running the user's
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
239
|
+
// real environment by running the user's LOGIN shell and dumping its env.
|
|
240
|
+
//
|
|
241
|
+
// IMPORTANT: NON-interactive (`-lc`, not `-ilc`). An interactive shell (`-i`)
|
|
242
|
+
// HANGS indefinitely when the daemon has no controlling TTY — exactly the case
|
|
243
|
+
// under any process manager (oxmgr/systemd/launchd/pm2) — and Bun's `spawnSync`
|
|
244
|
+
// `timeout` does NOT kill the hung interactive shell. Because this runs
|
|
245
|
+
// synchronously on the event loop from the /api/spawn handler, the interactive
|
|
246
|
+
// variant PERMANENTLY WEDGED the daemon on the first console spawn (heartbeat
|
|
247
|
+
// froze → healthcheck restart → cache lost → next spawn wedged again), making
|
|
248
|
+
// console spawns impossible on a headless daemon. `-l` still sources the profile
|
|
249
|
+
// chain (`~/.profile`/`~/.bash_profile` → `~/.bashrc`), so the tool PATHs are
|
|
250
|
+
// still recovered. Returns null on Windows (no rc-file model — the process env is
|
|
251
|
+
// already the right one) or on any failure, so callers fall back to process.env.
|
|
192
252
|
let loginShellEnvCache: Record<string, string> | null | undefined;
|
|
193
253
|
function loginShellEnv(): Record<string, string> | null {
|
|
194
254
|
if (loginShellEnvCache !== undefined) return loginShellEnvCache;
|
|
@@ -200,7 +260,7 @@ function loginShellEnv(): Record<string, string> | null {
|
|
|
200
260
|
// print to stdout; `env -0` is NUL-separated so values with newlines survive.
|
|
201
261
|
const delim = "_AY_SHELL_ENV_DELIM_";
|
|
202
262
|
const res = Bun.spawnSync(
|
|
203
|
-
[shell, "-
|
|
263
|
+
[shell, "-lc", `printf %s "${delim}"; env -0; printf %s "${delim}"`],
|
|
204
264
|
{
|
|
205
265
|
stdin: "ignore",
|
|
206
266
|
stderr: "ignore",
|
|
@@ -704,9 +764,28 @@ async function readDaemonServeArgs(mgr: DaemonManager): Promise<string[] | null>
|
|
|
704
764
|
}
|
|
705
765
|
}
|
|
706
766
|
|
|
707
|
-
function portFromArgs(args: string[]): number {
|
|
767
|
+
function portFromArgs(args: string[]): number | null {
|
|
708
768
|
const m = /--port[=\s](\d+)/.exec(args.join(" "));
|
|
709
|
-
return m ? Number(m[1]) :
|
|
769
|
+
return m ? Number(m[1]) : null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function argsUsePortless(args: string[]): boolean {
|
|
773
|
+
const wantsHttp =
|
|
774
|
+
args.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
|
|
775
|
+
!args.some((a) => a.startsWith("--webrtc"));
|
|
776
|
+
return wantsHttp && portFromArgs(args) === null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function portlessAppPort(): Promise<number | null> {
|
|
780
|
+
try {
|
|
781
|
+
const routes = JSON.parse(
|
|
782
|
+
await readFile(path.join(homedir(), ".portless", "routes.json"), "utf-8"),
|
|
783
|
+
) as { hostname?: string; port?: number }[];
|
|
784
|
+
const route = routes.find((r) => r.hostname === `${PORTLESS_APP_NAME}.localhost`);
|
|
785
|
+
return typeof route?.port === "number" ? route.port : null;
|
|
786
|
+
} catch {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
710
789
|
}
|
|
711
790
|
|
|
712
791
|
async function warnStrayServeProcesses(): Promise<void> {
|
|
@@ -741,7 +820,8 @@ function explicitWebrtcUrl(args: string[]): string | undefined {
|
|
|
741
820
|
// Ask the live daemon its version over the local HTTP API. null if it's not
|
|
742
821
|
// listening (webrtc-only) or too old to expose /api/version — both of which we
|
|
743
822
|
// treat as "outdated" so a re-install rolls it forward.
|
|
744
|
-
async function fetchDaemonVersion(port: number, token: string): Promise<string | null> {
|
|
823
|
+
async function fetchDaemonVersion(port: number | null, token: string): Promise<string | null> {
|
|
824
|
+
if (port === null) return null;
|
|
745
825
|
try {
|
|
746
826
|
const r = await fetch(`http://127.0.0.1:${port}/api/version`, {
|
|
747
827
|
headers: { Authorization: `Bearer ${token}` },
|
|
@@ -889,7 +969,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
889
969
|
// share link for a WebRTC bridge that isn't actually running. A bare re-run
|
|
890
970
|
// passes no args, so effArgs === priorArgs and this stays a no-op as before.
|
|
891
971
|
const sameConfig = JSON.stringify(effArgs) === JSON.stringify(priorArgs);
|
|
892
|
-
const
|
|
972
|
+
const daemonPort = argsUsePortless(effArgs) ? await portlessAppPort() : portFromArgs(effArgs);
|
|
973
|
+
const runningVer = await fetchDaemonVersion(daemonPort, token);
|
|
893
974
|
if (runningVer === current && sameConfig) {
|
|
894
975
|
await ensureBootAutostart(mgr);
|
|
895
976
|
process.stdout.write(`'${DAEMON_NAME}' already running v${current} (up to date)\n`);
|
|
@@ -982,7 +1063,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
982
1063
|
const code = await proc.exited;
|
|
983
1064
|
if (code === 0) {
|
|
984
1065
|
const onBoot = await ensureBootAutostart(mgr);
|
|
985
|
-
const port = portFromArgs(effArgs);
|
|
1066
|
+
const port = argsUsePortless(effArgs) ? await portlessAppPort() : portFromArgs(effArgs);
|
|
1067
|
+
const localUrl = argsUsePortless(effArgs) ? await resolvedPortlessConsoleUrl(token) : null;
|
|
986
1068
|
// Mirror cmdServe's mode resolution: webrtc-only daemons open no HTTP port.
|
|
987
1069
|
const httpish =
|
|
988
1070
|
effArgs.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
|
|
@@ -1020,8 +1102,12 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
1020
1102
|
);
|
|
1021
1103
|
process.stdout.write(`token: ${token}\n\n`);
|
|
1022
1104
|
if (httpish) {
|
|
1023
|
-
|
|
1024
|
-
|
|
1105
|
+
if (argsUsePortless(effArgs)) {
|
|
1106
|
+
process.stdout.write(` web console: ${localUrl}\n`);
|
|
1107
|
+
} else if (port !== null) {
|
|
1108
|
+
process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
|
|
1109
|
+
process.stdout.write(` ay remote add <alias> http://${token}@<host>:${port}\n`);
|
|
1110
|
+
}
|
|
1025
1111
|
}
|
|
1026
1112
|
process.stdout.write(` ay serve logs # view server logs\n`);
|
|
1027
1113
|
process.stdout.write(` ay serve uninstall # remove daemon\n`);
|
|
@@ -1054,7 +1140,9 @@ async function cmdServeStatus(args: string[]): Promise<number> {
|
|
|
1054
1140
|
const daemonArgs = mgr ? await readDaemonServeArgs(mgr) : null;
|
|
1055
1141
|
const installed = daemonArgs !== null;
|
|
1056
1142
|
const a = daemonArgs ?? [];
|
|
1057
|
-
const
|
|
1143
|
+
const local = argsUsePortless(a);
|
|
1144
|
+
const port = local ? await portlessAppPort() : portFromArgs(a);
|
|
1145
|
+
const localUrl = local ? await resolvedPortlessConsoleUrl(token ?? undefined) : null;
|
|
1058
1146
|
// Mirror cmdServe/install mode resolution: webrtc when --webrtc/--share; http
|
|
1059
1147
|
// when --http/--share OR no --webrtc at all (http is the implicit default).
|
|
1060
1148
|
const webrtcish = a.some((x) => x.startsWith("--webrtc") || x.startsWith("--share"));
|
|
@@ -1076,6 +1164,7 @@ async function cmdServeStatus(args: string[]): Promise<number> {
|
|
|
1076
1164
|
installed,
|
|
1077
1165
|
mode,
|
|
1078
1166
|
port: httpish ? port : null,
|
|
1167
|
+
localUrl: httpish ? localUrl : null,
|
|
1079
1168
|
reachable: runningVersion !== null,
|
|
1080
1169
|
runningVersion,
|
|
1081
1170
|
currentVersion: current,
|
|
@@ -1096,25 +1185,32 @@ async function cmdServeStatus(args: string[]): Promise<number> {
|
|
|
1096
1185
|
w(`manager: ${mgr ? mgr.id : "none — install pm2 or oxmgr to daemonize"}`);
|
|
1097
1186
|
if (installed) {
|
|
1098
1187
|
w(`installed: yes (via ${mgr!.id})`);
|
|
1099
|
-
w(`mode: ${mode}${httpish ? ` (port ${port})` : ""}`);
|
|
1188
|
+
w(`mode: ${mode}${httpish ? (local ? ` (${localUrl})` : ` (port ${port})`) : ""}`);
|
|
1100
1189
|
if (a.length) w(`args: ${a.join(" ")}`);
|
|
1101
1190
|
} else {
|
|
1102
1191
|
w(`installed: no — start a daemon with: ay serve install [--share]`);
|
|
1103
1192
|
}
|
|
1104
1193
|
if (runningVersion !== null) {
|
|
1105
1194
|
const tag = runningVersion === current ? "up to date" : `outdated (current v${current})`;
|
|
1106
|
-
w(
|
|
1195
|
+
w(
|
|
1196
|
+
`http api: reachable ${local ? `via ${localUrl}` : `on 127.0.0.1:${port}`} — v${runningVersion} (${tag})`,
|
|
1197
|
+
);
|
|
1107
1198
|
} else if (mode === "webrtc") {
|
|
1108
1199
|
w(`http api: none (webrtc-only)`);
|
|
1109
1200
|
} else {
|
|
1110
|
-
w(
|
|
1201
|
+
w(
|
|
1202
|
+
`http api: not reachable ${local ? `via ${localUrl}` : `on 127.0.0.1:${port}`} (not running)`,
|
|
1203
|
+
);
|
|
1111
1204
|
}
|
|
1112
1205
|
w(`token: ${token ?? "(none yet — created on first serve)"}`);
|
|
1113
1206
|
if (shareLink) w(`share link: ${shareLink}`);
|
|
1114
1207
|
if (token && httpish) {
|
|
1115
1208
|
w();
|
|
1116
|
-
w(`
|
|
1117
|
-
|
|
1209
|
+
if (local) w(`console: ${localUrl}`);
|
|
1210
|
+
else if (port !== null) {
|
|
1211
|
+
w(`connect: ay ls ${token}@<host>:${port}`);
|
|
1212
|
+
w(` ay remote add <alias> http://${token}@<host>:${port}`);
|
|
1213
|
+
}
|
|
1118
1214
|
}
|
|
1119
1215
|
return 0;
|
|
1120
1216
|
}
|
|
@@ -1138,11 +1234,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1138
1234
|
` (stable link across restarts; delete the file to rotate).\n` +
|
|
1139
1235
|
` --share [URL] Legacy alias for --http --webrtc\n\n` +
|
|
1140
1236
|
`Options:\n` +
|
|
1141
|
-
` --port N
|
|
1237
|
+
` --port N Bypass Portless and listen on a fixed HTTP port\n` +
|
|
1142
1238
|
` --host HOST Interface to bind (default: 127.0.0.1; use 0.0.0.0 to expose)\n` +
|
|
1143
1239
|
` --token TOKEN Auth token (auto-generated and saved if omitted)\n` +
|
|
1144
1240
|
` -d, --daemon Install these flags as a background daemon (pm2/oxmgr)\n` +
|
|
1145
1241
|
` (same as: ay serve install <flags>)\n` +
|
|
1242
|
+
` --local Explicitly use Portless (the default for HTTP mode)\n` +
|
|
1243
|
+
` ${portlessConsoleUrl()}\n` +
|
|
1146
1244
|
` --allow-spawn Deprecated no-op — the console can always spawn agents\n` +
|
|
1147
1245
|
` --tls-cert FILE TLS certificate PEM\n` +
|
|
1148
1246
|
` --tls-key FILE TLS private key PEM\n\n` +
|
|
@@ -1152,8 +1250,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1152
1250
|
` ay serve uninstall remove daemon\n` +
|
|
1153
1251
|
` ay serve logs view daemon logs\n\n` +
|
|
1154
1252
|
`Once running, connect from another machine:\n` +
|
|
1155
|
-
` ay ls <token>@<host
|
|
1156
|
-
` ay remote add <alias> http://<token>@<host
|
|
1253
|
+
` ay ls <token>@<host>:<port>\n` +
|
|
1254
|
+
` ay remote add <alias> http://<token>@<host>:<port>\n`,
|
|
1157
1255
|
);
|
|
1158
1256
|
return 0;
|
|
1159
1257
|
}
|
|
@@ -1189,7 +1287,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1189
1287
|
|
|
1190
1288
|
const y = yargs(rest)
|
|
1191
1289
|
.usage("Usage: ay serve [options]")
|
|
1192
|
-
.option("port", { type: "number",
|
|
1290
|
+
.option("port", { type: "number", description: "Bypass Portless and listen on a fixed port" })
|
|
1193
1291
|
.option("host", {
|
|
1194
1292
|
type: "string",
|
|
1195
1293
|
default: "127.0.0.1",
|
|
@@ -1217,6 +1315,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1217
1315
|
default: false,
|
|
1218
1316
|
description: "Install as a background daemon (same as: ay serve install <flags>)",
|
|
1219
1317
|
})
|
|
1318
|
+
.option("local", {
|
|
1319
|
+
type: "boolean",
|
|
1320
|
+
default: false,
|
|
1321
|
+
description: `Use Portless at ${portlessConsoleUrl()} (default for HTTP mode)`,
|
|
1322
|
+
})
|
|
1220
1323
|
.option("allow-spawn", {
|
|
1221
1324
|
type: "boolean",
|
|
1222
1325
|
default: false,
|
|
@@ -1225,7 +1328,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1225
1328
|
.option("takeover", {
|
|
1226
1329
|
type: "boolean",
|
|
1227
1330
|
default: false,
|
|
1228
|
-
description:
|
|
1331
|
+
description:
|
|
1332
|
+
"If another ay serve already hosts this home's WebRTC room, stop it and take the room",
|
|
1229
1333
|
})
|
|
1230
1334
|
.help(false)
|
|
1231
1335
|
.version(false)
|
|
@@ -1240,6 +1344,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1240
1344
|
return cmdServeDaemon("install", fwd);
|
|
1241
1345
|
}
|
|
1242
1346
|
|
|
1347
|
+
const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
|
|
1348
|
+
const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
|
|
1349
|
+
const fixedPort = typeof argv.port === "number";
|
|
1350
|
+
const usePortless = wantHttp && !fixedPort;
|
|
1351
|
+
if (argv.local === true && fixedPort) {
|
|
1352
|
+
process.stderr.write("ay serve: --local and --port cannot be used together\n");
|
|
1353
|
+
return 1;
|
|
1354
|
+
}
|
|
1355
|
+
if (usePortless && process.env[PORTLESS_CHILD_ENV] !== "1" && process.env.PORTLESS !== "0") {
|
|
1356
|
+
return runWithPortless(rest);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1243
1359
|
// `ay serve` takes only flags (plus the install/uninstall/logs subcommands
|
|
1244
1360
|
// handled above). A bare word like `ay serve share` is silently dropped into
|
|
1245
1361
|
// argv._ by yargs and would otherwise start in the wrong mode — most often
|
|
@@ -1260,7 +1376,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1260
1376
|
// top-level agents regardless of the spawn path.
|
|
1261
1377
|
delete process.env.AGENT_YES_PID;
|
|
1262
1378
|
|
|
1263
|
-
const port = (argv.port as number) ?? DEFAULT_PORT;
|
|
1379
|
+
const port = (argv.port as number | undefined) ?? (Number(process.env.PORT) || DEFAULT_PORT);
|
|
1264
1380
|
const host = (argv.host as string) ?? "127.0.0.1";
|
|
1265
1381
|
const tokenFlag = typeof argv.token === "string" ? argv.token : undefined;
|
|
1266
1382
|
const certPath = typeof argv["tls-cert"] === "string" ? argv["tls-cert"] : undefined;
|
|
@@ -1276,8 +1392,6 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1276
1392
|
// Modes: --http (HTTP listener + web console), --webrtc (port-free WebRTC
|
|
1277
1393
|
// share), or both. Bare `ay serve` stays HTTP-only; --share keeps its old
|
|
1278
1394
|
// meaning (HTTP + WebRTC) for existing invocations.
|
|
1279
|
-
const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
|
|
1280
|
-
const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
|
|
1281
1395
|
|
|
1282
1396
|
// Singleton WebRTC host per ~/.agent-yes: a second host joining the SAME
|
|
1283
1397
|
// persisted room (~/.agent-yes/.share-room) fights the first over every
|
|
@@ -1435,6 +1549,28 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1435
1549
|
}
|
|
1436
1550
|
};
|
|
1437
1551
|
|
|
1552
|
+
// Per-agent live status description from the rendered screen. Claude Code
|
|
1553
|
+
// paints a spinner/status line while working; surfacing it lets the left panel
|
|
1554
|
+
// show "what it's doing" without opening the terminal.
|
|
1555
|
+
const statusTextCache = new Map<
|
|
1556
|
+
string,
|
|
1557
|
+
{ size: number; mtimeMs: number; statusText: string | null }
|
|
1558
|
+
>();
|
|
1559
|
+
const logStatusText = async (logFile: string | null | undefined): Promise<string | null> => {
|
|
1560
|
+
if (!logFile) return null;
|
|
1561
|
+
try {
|
|
1562
|
+
const { size, mtimeMs } = await stat(logFile);
|
|
1563
|
+
const hit = statusTextCache.get(logFile);
|
|
1564
|
+
if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.statusText;
|
|
1565
|
+
const lines = await renderLogTailLines(logFile, 40);
|
|
1566
|
+
const statusText = lines ? parseStatusText(lines) : null;
|
|
1567
|
+
statusTextCache.set(logFile, { size, mtimeMs, statusText });
|
|
1568
|
+
return statusText;
|
|
1569
|
+
} catch {
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1438
1574
|
// Per-agent "waiting on you" detection: the agent is parked on an interactive
|
|
1439
1575
|
// menu it did NOT auto-resolve (config `needsInput` patterns). Same source and
|
|
1440
1576
|
// classifier as `ay ls` / `ay status`, so the console's dot matches the CLI's
|
|
@@ -1701,6 +1837,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1701
1837
|
// WHAT the agent is waiting on. Null otherwise.
|
|
1702
1838
|
question,
|
|
1703
1839
|
title: await logTitle(r.log_file),
|
|
1840
|
+
status_text: status === "exited" ? null : await logStatusText(r.log_file),
|
|
1704
1841
|
git: status === "exited" ? null : await gitStatus(r.cwd),
|
|
1705
1842
|
// Task progress from the rendered todo block (null when none detected → no
|
|
1706
1843
|
// badge). Skipped for exited agents — their screen is no longer live.
|
|
@@ -1723,10 +1860,87 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1723
1860
|
// never a security surface — viewers self-report. Pruned by TTL on read.
|
|
1724
1861
|
const presence = new Map<
|
|
1725
1862
|
string,
|
|
1726
|
-
{
|
|
1863
|
+
{
|
|
1864
|
+
viewer: string;
|
|
1865
|
+
agent: string;
|
|
1866
|
+
cols: number;
|
|
1867
|
+
rows: number;
|
|
1868
|
+
sel: string | null;
|
|
1869
|
+
cap: SizeCap | null;
|
|
1870
|
+
ts: number;
|
|
1871
|
+
}
|
|
1727
1872
|
>();
|
|
1728
1873
|
const PRESENCE_TTL_MS = 12_000;
|
|
1729
1874
|
|
|
1875
|
+
// ── PTY size negotiation — tmux's "smallest client wins" ──────────────────
|
|
1876
|
+
// Writable viewers report their readable capacity (`cap`) alongside their
|
|
1877
|
+
// presence; we resize the agent's PTY to the elementwise min across live caps
|
|
1878
|
+
// (see sizeNego.ts), so a phone gets a grid it can read while wider viewers
|
|
1879
|
+
// letterbox the shared canvas. Uses the same winsize-file + SIGWINCH path as
|
|
1880
|
+
// POST /api/resize. When the last cap leaves (viewer switched agent, tab
|
|
1881
|
+
// died → TTL), the size is withdrawn: the file is deleted ONLY if its content
|
|
1882
|
+
// is still our own last write (so `ay attach`'s pushed size is never
|
|
1883
|
+
// clobbered) and the wrapper falls back to the real tty size.
|
|
1884
|
+
const negoApplied = new Map<number, { cols: number; rows: number; content: string }>();
|
|
1885
|
+
const negoTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
1886
|
+
const NEGO_DEBOUNCE_MS = 350;
|
|
1887
|
+
const winsizePathFor = (pid: number) =>
|
|
1888
|
+
path.join(process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"), "winsize", String(pid));
|
|
1889
|
+
const liveCapsFor = (pid: number): SizeCap[] => {
|
|
1890
|
+
const now = Date.now();
|
|
1891
|
+
const caps: SizeCap[] = [];
|
|
1892
|
+
for (const v of presence.values())
|
|
1893
|
+
if (now - v.ts <= PRESENCE_TTL_MS && String(v.agent) === String(pid) && v.cap)
|
|
1894
|
+
caps.push(v.cap);
|
|
1895
|
+
return caps;
|
|
1896
|
+
};
|
|
1897
|
+
const applyNego = async (pid: number) => {
|
|
1898
|
+
negoTimers.delete(pid);
|
|
1899
|
+
const eff = negotiateSize(liveCapsFor(pid));
|
|
1900
|
+
const file = winsizePathFor(pid);
|
|
1901
|
+
const prev = negoApplied.get(pid);
|
|
1902
|
+
try {
|
|
1903
|
+
if (eff) {
|
|
1904
|
+
if (prev && prev.cols === eff.cols && prev.rows === eff.rows) return;
|
|
1905
|
+
const content = `${eff.cols} ${eff.rows} ${Date.now()}\n`;
|
|
1906
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1907
|
+
await writeFile(file, content);
|
|
1908
|
+
negoApplied.set(pid, { ...eff, content });
|
|
1909
|
+
} else {
|
|
1910
|
+
if (!prev) return;
|
|
1911
|
+
negoApplied.delete(pid);
|
|
1912
|
+
try {
|
|
1913
|
+
// withdraw only what we wrote — a different content means ay attach
|
|
1914
|
+
// (or a direct /api/resize) owns the size now; leave it alone.
|
|
1915
|
+
if ((await readFile(file, "utf-8")) !== prev.content) return;
|
|
1916
|
+
await unlink(file);
|
|
1917
|
+
} catch {
|
|
1918
|
+
return; // already gone — nothing to signal
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
try {
|
|
1922
|
+
process.kill(pid, "SIGWINCH");
|
|
1923
|
+
} catch {
|
|
1924
|
+
/* agent gone */
|
|
1925
|
+
}
|
|
1926
|
+
} catch {
|
|
1927
|
+
/* best-effort: a failed write just leaves the previous size in place */
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
const scheduleNego = (pid: number) => {
|
|
1931
|
+
if (!Number.isFinite(pid) || pid <= 0 || negoTimers.has(pid)) return;
|
|
1932
|
+
negoTimers.set(
|
|
1933
|
+
pid,
|
|
1934
|
+
setTimeout(() => void applyNego(pid), NEGO_DEBOUNCE_MS),
|
|
1935
|
+
);
|
|
1936
|
+
};
|
|
1937
|
+
// TTL grow-back sweep: a viewer that vanished without clearing its presence
|
|
1938
|
+
// (tab killed, phone locked) stops refreshing; once its entry expires the
|
|
1939
|
+
// negotiated size must be recomputed even though no presence POST arrives.
|
|
1940
|
+
setInterval(() => {
|
|
1941
|
+
for (const pid of negoApplied.keys()) scheduleNego(pid);
|
|
1942
|
+
}, 5_000);
|
|
1943
|
+
|
|
1730
1944
|
// The whole API as a plain handler: served over HTTP by Bun.serve (--http)
|
|
1731
1945
|
// and called in-process by the WebRTC bridge (--webrtc) — the latter needs
|
|
1732
1946
|
// no TCP port at all.
|
|
@@ -2203,6 +2417,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2203
2417
|
pid: record.pid,
|
|
2204
2418
|
cols: size?.cols ?? null,
|
|
2205
2419
|
rows: size?.rows ?? null,
|
|
2420
|
+
// This host negotiates the PTY size from viewer capacities (presence
|
|
2421
|
+
// `cap`) — a capable console should report its cap instead of pushing
|
|
2422
|
+
// /api/resize directly, so viewers stop fighting last-writer-wins.
|
|
2423
|
+
nego: true,
|
|
2206
2424
|
});
|
|
2207
2425
|
} catch (e) {
|
|
2208
2426
|
return new Response((e as Error).message, { status: 404 });
|
|
@@ -2608,6 +2826,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2608
2826
|
cols?: number;
|
|
2609
2827
|
rows?: number;
|
|
2610
2828
|
sel?: string;
|
|
2829
|
+
cap?: { cols?: number; rows?: number };
|
|
2611
2830
|
};
|
|
2612
2831
|
try {
|
|
2613
2832
|
b = (await req.json()) as typeof b;
|
|
@@ -2616,6 +2835,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2616
2835
|
}
|
|
2617
2836
|
const viewer = String(b.viewer ?? "").slice(0, 64);
|
|
2618
2837
|
if (!viewer) return new Response("missing viewer", { status: 400 });
|
|
2838
|
+
// The agent this viewer WAS on — if the entry moves (agent switch) or
|
|
2839
|
+
// clears, that agent's negotiated size must be recomputed (grow-back).
|
|
2840
|
+
const prevAgent = presence.get(viewer)?.agent;
|
|
2619
2841
|
if (b.agent == null) presence.delete(viewer);
|
|
2620
2842
|
else
|
|
2621
2843
|
presence.set(viewer, {
|
|
@@ -2624,8 +2846,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2624
2846
|
cols: Math.max(0, Math.floor(Number(b.cols) || 0)),
|
|
2625
2847
|
rows: Math.max(0, Math.floor(Number(b.rows) || 0)),
|
|
2626
2848
|
sel: typeof b.sel === "string" ? b.sel.slice(0, 200) : null,
|
|
2849
|
+
cap: sanitizeCap(b.cap),
|
|
2627
2850
|
ts: Date.now(),
|
|
2628
2851
|
});
|
|
2852
|
+
if (b.agent != null) scheduleNego(Number(b.agent));
|
|
2853
|
+
if (prevAgent != null && String(prevAgent) !== String(b.agent ?? ""))
|
|
2854
|
+
scheduleNego(Number(prevAgent));
|
|
2629
2855
|
return new Response(null, { status: 204 });
|
|
2630
2856
|
}
|
|
2631
2857
|
// GET /api/presence — all live viewers (TTL-pruned), for "who's watching".
|
|
@@ -3208,17 +3434,20 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3208
3434
|
// server is null only when we degraded to WebRTC-only above (port wedged);
|
|
3209
3435
|
// skip the HTTP connection banner since nothing is listening on the port.
|
|
3210
3436
|
if (server) {
|
|
3437
|
+
const listenPort = server.port;
|
|
3211
3438
|
const uiHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
3212
|
-
process.stdout.write(`ay serve ${scheme}://${host}:${
|
|
3439
|
+
process.stdout.write(`ay serve ${scheme}://${host}:${listenPort}\n`);
|
|
3213
3440
|
process.stdout.write(`token: ${token}\n\n`);
|
|
3214
3441
|
process.stdout.write(`web console (token in the # is eaten on open):\n`);
|
|
3215
|
-
process.stdout.write(
|
|
3442
|
+
process.stdout.write(
|
|
3443
|
+
` ${usePortless ? portlessConsoleUrl(token) : `${scheme}://${uiHost}:${listenPort}/#k=${token}`}\n\n`,
|
|
3444
|
+
);
|
|
3216
3445
|
process.stdout.write(`connect from another machine:\n`);
|
|
3217
|
-
process.stdout.write(` ay ls ${token}@<host>:${
|
|
3218
|
-
process.stdout.write(` ay tail ${token}@<host>:${
|
|
3219
|
-
process.stdout.write(` ay send ${token}@<host>:${
|
|
3446
|
+
process.stdout.write(` ay ls ${token}@<host>:${listenPort}\n`);
|
|
3447
|
+
process.stdout.write(` ay tail ${token}@<host>:${listenPort}:<keyword>\n`);
|
|
3448
|
+
process.stdout.write(` ay send ${token}@<host>:${listenPort}:<keyword> "message"\n\n`);
|
|
3220
3449
|
process.stdout.write(`save as alias:\n`);
|
|
3221
|
-
process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${
|
|
3450
|
+
process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${listenPort}\n\n`);
|
|
3222
3451
|
if (!useHttps) {
|
|
3223
3452
|
process.stdout.write(
|
|
3224
3453
|
`for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
|
package/ts/serveLock.spec.ts
CHANGED
|
@@ -140,7 +140,8 @@ describe("acquireWebrtcHostLock — held-lock lifecycle", () => {
|
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
const ownerFile = () => path.join(home, "webrtc-host.lock", "owner.json");
|
|
143
|
-
const readOwnerFile = async () =>
|
|
143
|
+
const readOwnerFile = async () =>
|
|
144
|
+
JSON.parse(await readFile(ownerFile(), "utf-8")) as ServeLockOwner;
|
|
144
145
|
|
|
145
146
|
it("heartbeats refresh beat_at while held", async () => {
|
|
146
147
|
const got = await acquireWebrtcHostLock({ graceMs: 0, beatMs: 40 });
|
|
@@ -190,19 +191,19 @@ describe("acquireWebrtcHostLock — held-lock lifecycle", () => {
|
|
|
190
191
|
it.skipIf(process.platform === "win32")(
|
|
191
192
|
"escalates to SIGKILL when the owner ignores SIGTERM",
|
|
192
193
|
async () => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
194
|
+
const { spawn } = await import("node:child_process");
|
|
195
|
+
const stubborn = spawn("/bin/sh", ["-c", "trap '' TERM; sleep 30"], { stdio: "ignore" });
|
|
196
|
+
const pid = stubborn.pid!;
|
|
197
|
+
await new Promise((r) => setTimeout(r, 100)); // let the trap install
|
|
198
|
+
const { mkdir: mkd, writeFile: wf } = await import("fs/promises");
|
|
199
|
+
await mkd(path.join(home, "webrtc-host.lock"), { recursive: true });
|
|
200
|
+
await wf(ownerFile(), JSON.stringify({ pid, started_at: Date.now(), beat_at: Date.now() }));
|
|
201
|
+
|
|
202
|
+
const got = await acquireWebrtcHostLock({ takeover: true, graceMs: 0, takeoverWaitMs: 200 });
|
|
203
|
+
expect(got.ok).toBe(true);
|
|
204
|
+
if (got.ok) releases.push(got.release);
|
|
205
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
206
|
+
expect(stubborn.signalCode).toBe("SIGKILL");
|
|
206
207
|
},
|
|
207
208
|
);
|
|
208
209
|
|