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.
Files changed (31) hide show
  1. package/README.md +2 -0
  2. package/dist/{SUPPORTED_CLIS-C8-qh98a.js → SUPPORTED_CLIS-B1CDJwYn.js} +2 -2
  3. package/dist/SUPPORTED_CLIS-vH8hvcZr.js +9 -0
  4. package/dist/{agentShare-99IGGOkv.js → agentShare-DdEbHtAn.js} +3 -3
  5. package/dist/cli.js +4 -4
  6. package/dist/index.js +2 -2
  7. package/dist/{notifyDaemon-DK1HOMjj.js → notifyDaemon-DivHVzZx.js} +2 -2
  8. package/dist/{rustBinary-Tgn948GR.js → rustBinary-CuBIr-zX.js} +2 -2
  9. package/dist/{schedule-CBoM67Vy.js → schedule-Bg_CDt2j.js} +4 -4
  10. package/dist/{serve-IvOlaL-7.js → serve-B-ZCy67S.js} +258 -41
  11. package/dist/{setup-BaKdS-3i.js → setup-D6UBzk4-.js} +2 -2
  12. package/dist/{share-Ciw1mWVN.js → share-_QYjPN7q.js} +1 -1
  13. package/dist/{share-BfIU8t_h.js → share-vchIyVd8.js} +98 -5
  14. package/dist/{subcommands-Bs7wJRBB.js → subcommands-6AQ8egRn.js} +7 -7
  15. package/dist/{subcommands-DyDHZ5YI.js → subcommands-CeRH9-_h.js} +1 -1
  16. package/dist/{ts-Dm6jjLwC.js → ts-DZWZvbLM.js} +2 -2
  17. package/dist/{versionChecker-BomfUpSo.js → versionChecker-C2CV9VOX.js} +2 -2
  18. package/dist/{ws-CSkP2mkD.js → ws-CGNmxCPD.js} +2 -2
  19. package/lab/ui/console-logic.js +23 -1
  20. package/lab/ui/index.html +188 -29
  21. package/package.json +1 -1
  22. package/ts/serve.spec.ts +13 -1
  23. package/ts/serve.ts +263 -34
  24. package/ts/serveLock.spec.ts +15 -14
  25. package/ts/share.ts +80 -1
  26. package/ts/sizeNego.spec.ts +61 -0
  27. package/ts/sizeNego.ts +59 -0
  28. package/ts/statusText.spec.ts +17 -0
  29. package/ts/statusText.ts +19 -0
  30. package/ts/ws.spec.ts +8 -10
  31. 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 = 7432;
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 interactive login shell and dumping its
189
- // env, exactly as a fresh terminal would have it. Returns null on Windows (no
190
- // rc-file model the process env is already the right one) or on any failure, so
191
- // callers fall back to process.env.
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, "-ilc", `printf %s "${delim}"; env -0; printf %s "${delim}"`],
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]) : DEFAULT_PORT;
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 runningVer = await fetchDaemonVersion(portFromArgs(effArgs), token);
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
- process.stdout.write(` ay ls ${token}@<host>:${port}\n`);
1024
- process.stdout.write(` ay remote add <alias> http://${token}@<host>:${port}\n`);
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 port = portFromArgs(a);
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(`http api: reachable on 127.0.0.1:${port} — v${runningVersion} (${tag})`);
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(`http api: not reachable on 127.0.0.1:${port} (not running)`);
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(`connect: ay ls ${token}@<host>:${port}`);
1117
- w(` ay remote add <alias> http://${token}@<host>:${port}`);
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 Port to listen on (default: ${DEFAULT_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>:${DEFAULT_PORT}\n` +
1156
- ` ay remote add <alias> http://<token>@<host>:${DEFAULT_PORT}\n`,
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", default: DEFAULT_PORT, description: "Port to listen on" })
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: "If another ay serve already hosts this home's WebRTC room, stop it and take the room",
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
- { viewer: string; agent: string; cols: number; rows: number; sel: string | null; ts: number }
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}:${port}\n`);
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(` ${scheme}://${uiHost}:${port}/#k=${token}\n\n`);
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>:${port}\n`);
3218
- process.stdout.write(` ay tail ${token}@<host>:${port}:<keyword>\n`);
3219
- process.stdout.write(` ay send ${token}@<host>:${port}:<keyword> "message"\n\n`);
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>:${port}\n\n`);
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` +
@@ -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 () => JSON.parse(await readFile(ownerFile(), "utf-8")) as ServeLockOwner;
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
- 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");
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