agent-yes 1.205.0 → 1.206.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/serve.ts CHANGED
@@ -38,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";
@@ -58,7 +59,56 @@ import {
58
59
  resolveSpawnCwd,
59
60
  } from "./workspaceConfig.ts";
60
61
 
61
- const DEFAULT_PORT = 7432;
62
+ const DEFAULT_PORT = 0;
63
+ const PORTLESS_APP_NAME = "agent-yes";
64
+ const PORTLESS_CHILD_ENV = "AGENT_YES_PORTLESS_CHILD";
65
+
66
+ export function portlessConsoleUrl(token?: string): string {
67
+ return portlessConsoleUrlForOrigin(
68
+ process.env.PORTLESS_URL?.replace(/\/$/, "") || `https://${PORTLESS_APP_NAME}.localhost`,
69
+ token,
70
+ );
71
+ }
72
+
73
+ function portlessConsoleUrlForOrigin(origin: string, token?: string): string {
74
+ const fragment = token ? `/#k=${encodeURIComponent(token)}` : "/";
75
+ return `${origin}${fragment}`;
76
+ }
77
+
78
+ async function resolvedPortlessConsoleUrl(token?: string): Promise<string> {
79
+ if (process.env.PORTLESS_URL) return portlessConsoleUrl(token);
80
+ try {
81
+ const port = Number(
82
+ (await readFile(path.join(homedir(), ".portless", "proxy.port"), "utf-8")).trim(),
83
+ );
84
+ if (Number.isInteger(port) && port > 0)
85
+ return portlessConsoleUrlForOrigin(
86
+ `https://${PORTLESS_APP_NAME}.localhost${port === 443 ? "" : `:${port}`}`,
87
+ token,
88
+ );
89
+ } catch {
90
+ /* Portless has not created state yet. */
91
+ }
92
+ return portlessConsoleUrl(token);
93
+ }
94
+
95
+ async function runWithPortless(rest: string[]): Promise<number> {
96
+ const portless = Bun.which("portless");
97
+ if (!portless) {
98
+ process.stderr.write(
99
+ "ay serve: Portless is required for local HTTPS. Install it with: npm install -g portless\n",
100
+ );
101
+ return 1;
102
+ }
103
+ const script = process.argv[1];
104
+ const self =
105
+ script && /\.[cm]?[jt]s$/.test(script) ? [process.execPath, script] : [process.execPath];
106
+ const proc = Bun.spawn([portless, PORTLESS_APP_NAME, ...self, "serve", ...rest], {
107
+ stdio: ["inherit", "inherit", "inherit"],
108
+ env: { ...liveEnv(), [PORTLESS_CHILD_ENV]: "1" },
109
+ });
110
+ return (await proc.exited) ?? 1;
111
+ }
62
112
 
63
113
  /**
64
114
  * Normalize a user-supplied GitHub-ish source into the standard
@@ -704,9 +754,28 @@ async function readDaemonServeArgs(mgr: DaemonManager): Promise<string[] | null>
704
754
  }
705
755
  }
706
756
 
707
- function portFromArgs(args: string[]): number {
757
+ function portFromArgs(args: string[]): number | null {
708
758
  const m = /--port[=\s](\d+)/.exec(args.join(" "));
709
- return m ? Number(m[1]) : DEFAULT_PORT;
759
+ return m ? Number(m[1]) : null;
760
+ }
761
+
762
+ function argsUsePortless(args: string[]): boolean {
763
+ const wantsHttp =
764
+ args.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
765
+ !args.some((a) => a.startsWith("--webrtc"));
766
+ return wantsHttp && portFromArgs(args) === null;
767
+ }
768
+
769
+ async function portlessAppPort(): Promise<number | null> {
770
+ try {
771
+ const routes = JSON.parse(
772
+ await readFile(path.join(homedir(), ".portless", "routes.json"), "utf-8"),
773
+ ) as { hostname?: string; port?: number }[];
774
+ const route = routes.find((r) => r.hostname === `${PORTLESS_APP_NAME}.localhost`);
775
+ return typeof route?.port === "number" ? route.port : null;
776
+ } catch {
777
+ return null;
778
+ }
710
779
  }
711
780
 
712
781
  async function warnStrayServeProcesses(): Promise<void> {
@@ -741,7 +810,8 @@ function explicitWebrtcUrl(args: string[]): string | undefined {
741
810
  // Ask the live daemon its version over the local HTTP API. null if it's not
742
811
  // listening (webrtc-only) or too old to expose /api/version — both of which we
743
812
  // treat as "outdated" so a re-install rolls it forward.
744
- 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;
745
815
  try {
746
816
  const r = await fetch(`http://127.0.0.1:${port}/api/version`, {
747
817
  headers: { Authorization: `Bearer ${token}` },
@@ -889,7 +959,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
889
959
  // share link for a WebRTC bridge that isn't actually running. A bare re-run
890
960
  // passes no args, so effArgs === priorArgs and this stays a no-op as before.
891
961
  const sameConfig = JSON.stringify(effArgs) === JSON.stringify(priorArgs);
892
- const runningVer = await fetchDaemonVersion(portFromArgs(effArgs), token);
962
+ const daemonPort = argsUsePortless(effArgs) ? await portlessAppPort() : portFromArgs(effArgs);
963
+ const runningVer = await fetchDaemonVersion(daemonPort, token);
893
964
  if (runningVer === current && sameConfig) {
894
965
  await ensureBootAutostart(mgr);
895
966
  process.stdout.write(`'${DAEMON_NAME}' already running v${current} (up to date)\n`);
@@ -982,7 +1053,8 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
982
1053
  const code = await proc.exited;
983
1054
  if (code === 0) {
984
1055
  const onBoot = await ensureBootAutostart(mgr);
985
- const port = portFromArgs(effArgs);
1056
+ const port = argsUsePortless(effArgs) ? await portlessAppPort() : portFromArgs(effArgs);
1057
+ const localUrl = argsUsePortless(effArgs) ? await resolvedPortlessConsoleUrl(token) : null;
986
1058
  // Mirror cmdServe's mode resolution: webrtc-only daemons open no HTTP port.
987
1059
  const httpish =
988
1060
  effArgs.some((a) => a.startsWith("--http") || a.startsWith("--share")) ||
@@ -1020,8 +1092,12 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
1020
1092
  );
1021
1093
  process.stdout.write(`token: ${token}\n\n`);
1022
1094
  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`);
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
+ }
1025
1101
  }
1026
1102
  process.stdout.write(` ay serve logs # view server logs\n`);
1027
1103
  process.stdout.write(` ay serve uninstall # remove daemon\n`);
@@ -1054,7 +1130,9 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1054
1130
  const daemonArgs = mgr ? await readDaemonServeArgs(mgr) : null;
1055
1131
  const installed = daemonArgs !== null;
1056
1132
  const a = daemonArgs ?? [];
1057
- const port = portFromArgs(a);
1133
+ const local = argsUsePortless(a);
1134
+ const port = local ? await portlessAppPort() : portFromArgs(a);
1135
+ const localUrl = local ? await resolvedPortlessConsoleUrl(token ?? undefined) : null;
1058
1136
  // Mirror cmdServe/install mode resolution: webrtc when --webrtc/--share; http
1059
1137
  // when --http/--share OR no --webrtc at all (http is the implicit default).
1060
1138
  const webrtcish = a.some((x) => x.startsWith("--webrtc") || x.startsWith("--share"));
@@ -1076,6 +1154,7 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1076
1154
  installed,
1077
1155
  mode,
1078
1156
  port: httpish ? port : null,
1157
+ localUrl: httpish ? localUrl : null,
1079
1158
  reachable: runningVersion !== null,
1080
1159
  runningVersion,
1081
1160
  currentVersion: current,
@@ -1096,25 +1175,32 @@ async function cmdServeStatus(args: string[]): Promise<number> {
1096
1175
  w(`manager: ${mgr ? mgr.id : "none — install pm2 or oxmgr to daemonize"}`);
1097
1176
  if (installed) {
1098
1177
  w(`installed: yes (via ${mgr!.id})`);
1099
- w(`mode: ${mode}${httpish ? ` (port ${port})` : ""}`);
1178
+ w(`mode: ${mode}${httpish ? (local ? ` (${localUrl})` : ` (port ${port})`) : ""}`);
1100
1179
  if (a.length) w(`args: ${a.join(" ")}`);
1101
1180
  } else {
1102
1181
  w(`installed: no — start a daemon with: ay serve install [--share]`);
1103
1182
  }
1104
1183
  if (runningVersion !== null) {
1105
1184
  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})`);
1185
+ w(
1186
+ `http api: reachable ${local ? `via ${localUrl}` : `on 127.0.0.1:${port}`} — v${runningVersion} (${tag})`,
1187
+ );
1107
1188
  } else if (mode === "webrtc") {
1108
1189
  w(`http api: none (webrtc-only)`);
1109
1190
  } else {
1110
- w(`http api: not reachable on 127.0.0.1:${port} (not running)`);
1191
+ w(
1192
+ `http api: not reachable ${local ? `via ${localUrl}` : `on 127.0.0.1:${port}`} (not running)`,
1193
+ );
1111
1194
  }
1112
1195
  w(`token: ${token ?? "(none yet — created on first serve)"}`);
1113
1196
  if (shareLink) w(`share link: ${shareLink}`);
1114
1197
  if (token && httpish) {
1115
1198
  w();
1116
- w(`connect: ay ls ${token}@<host>:${port}`);
1117
- w(` ay remote add <alias> http://${token}@<host>:${port}`);
1199
+ if (local) w(`console: ${localUrl}`);
1200
+ else if (port !== null) {
1201
+ w(`connect: ay ls ${token}@<host>:${port}`);
1202
+ w(` ay remote add <alias> http://${token}@<host>:${port}`);
1203
+ }
1118
1204
  }
1119
1205
  return 0;
1120
1206
  }
@@ -1138,11 +1224,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
1138
1224
  ` (stable link across restarts; delete the file to rotate).\n` +
1139
1225
  ` --share [URL] Legacy alias for --http --webrtc\n\n` +
1140
1226
  `Options:\n` +
1141
- ` --port N Port to listen on (default: ${DEFAULT_PORT})\n` +
1227
+ ` --port N Bypass Portless and listen on a fixed HTTP port\n` +
1142
1228
  ` --host HOST Interface to bind (default: 127.0.0.1; use 0.0.0.0 to expose)\n` +
1143
1229
  ` --token TOKEN Auth token (auto-generated and saved if omitted)\n` +
1144
1230
  ` -d, --daemon Install these flags as a background daemon (pm2/oxmgr)\n` +
1145
1231
  ` (same as: ay serve install <flags>)\n` +
1232
+ ` --local Explicitly use Portless (the default for HTTP mode)\n` +
1233
+ ` ${portlessConsoleUrl()}\n` +
1146
1234
  ` --allow-spawn Deprecated no-op — the console can always spawn agents\n` +
1147
1235
  ` --tls-cert FILE TLS certificate PEM\n` +
1148
1236
  ` --tls-key FILE TLS private key PEM\n\n` +
@@ -1152,8 +1240,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
1152
1240
  ` ay serve uninstall remove daemon\n` +
1153
1241
  ` ay serve logs view daemon logs\n\n` +
1154
1242
  `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`,
1243
+ ` ay ls <token>@<host>:<port>\n` +
1244
+ ` ay remote add <alias> http://<token>@<host>:<port>\n`,
1157
1245
  );
1158
1246
  return 0;
1159
1247
  }
@@ -1189,7 +1277,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1189
1277
 
1190
1278
  const y = yargs(rest)
1191
1279
  .usage("Usage: ay serve [options]")
1192
- .option("port", { type: "number", default: DEFAULT_PORT, description: "Port to listen on" })
1280
+ .option("port", { type: "number", description: "Bypass Portless and listen on a fixed port" })
1193
1281
  .option("host", {
1194
1282
  type: "string",
1195
1283
  default: "127.0.0.1",
@@ -1217,6 +1305,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1217
1305
  default: false,
1218
1306
  description: "Install as a background daemon (same as: ay serve install <flags>)",
1219
1307
  })
1308
+ .option("local", {
1309
+ type: "boolean",
1310
+ default: false,
1311
+ description: `Use Portless at ${portlessConsoleUrl()} (default for HTTP mode)`,
1312
+ })
1220
1313
  .option("allow-spawn", {
1221
1314
  type: "boolean",
1222
1315
  default: false,
@@ -1225,7 +1318,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
1225
1318
  .option("takeover", {
1226
1319
  type: "boolean",
1227
1320
  default: false,
1228
- description: "If another ay serve already hosts this home's WebRTC room, stop it and take the room",
1321
+ description:
1322
+ "If another ay serve already hosts this home's WebRTC room, stop it and take the room",
1229
1323
  })
1230
1324
  .help(false)
1231
1325
  .version(false)
@@ -1240,6 +1334,18 @@ export async function cmdServe(rest: string[]): Promise<number> {
1240
1334
  return cmdServeDaemon("install", fwd);
1241
1335
  }
1242
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
+
1243
1349
  // `ay serve` takes only flags (plus the install/uninstall/logs subcommands
1244
1350
  // handled above). A bare word like `ay serve share` is silently dropped into
1245
1351
  // argv._ by yargs and would otherwise start in the wrong mode — most often
@@ -1260,7 +1366,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1260
1366
  // top-level agents regardless of the spawn path.
1261
1367
  delete process.env.AGENT_YES_PID;
1262
1368
 
1263
- const port = (argv.port as number) ?? DEFAULT_PORT;
1369
+ const port = (argv.port as number | undefined) ?? (Number(process.env.PORT) || DEFAULT_PORT);
1264
1370
  const host = (argv.host as string) ?? "127.0.0.1";
1265
1371
  const tokenFlag = typeof argv.token === "string" ? argv.token : undefined;
1266
1372
  const certPath = typeof argv["tls-cert"] === "string" ? argv["tls-cert"] : undefined;
@@ -1276,8 +1382,6 @@ export async function cmdServe(rest: string[]): Promise<number> {
1276
1382
  // Modes: --http (HTTP listener + web console), --webrtc (port-free WebRTC
1277
1383
  // share), or both. Bare `ay serve` stays HTTP-only; --share keeps its old
1278
1384
  // 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
1385
 
1282
1386
  // Singleton WebRTC host per ~/.agent-yes: a second host joining the SAME
1283
1387
  // persisted room (~/.agent-yes/.share-room) fights the first over every
@@ -1435,6 +1539,28 @@ export async function cmdServe(rest: string[]): Promise<number> {
1435
1539
  }
1436
1540
  };
1437
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
+
1438
1564
  // Per-agent "waiting on you" detection: the agent is parked on an interactive
1439
1565
  // menu it did NOT auto-resolve (config `needsInput` patterns). Same source and
1440
1566
  // classifier as `ay ls` / `ay status`, so the console's dot matches the CLI's
@@ -1701,6 +1827,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1701
1827
  // WHAT the agent is waiting on. Null otherwise.
1702
1828
  question,
1703
1829
  title: await logTitle(r.log_file),
1830
+ status_text: status === "exited" ? null : await logStatusText(r.log_file),
1704
1831
  git: status === "exited" ? null : await gitStatus(r.cwd),
1705
1832
  // Task progress from the rendered todo block (null when none detected → no
1706
1833
  // badge). Skipped for exited agents — their screen is no longer live.
@@ -3208,17 +3335,20 @@ export async function cmdServe(rest: string[]): Promise<number> {
3208
3335
  // server is null only when we degraded to WebRTC-only above (port wedged);
3209
3336
  // skip the HTTP connection banner since nothing is listening on the port.
3210
3337
  if (server) {
3338
+ const listenPort = server.port;
3211
3339
  const uiHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
3212
- process.stdout.write(`ay serve ${scheme}://${host}:${port}\n`);
3340
+ process.stdout.write(`ay serve ${scheme}://${host}:${listenPort}\n`);
3213
3341
  process.stdout.write(`token: ${token}\n\n`);
3214
3342
  process.stdout.write(`web console (token in the # is eaten on open):\n`);
3215
- process.stdout.write(` ${scheme}://${uiHost}:${port}/#k=${token}\n\n`);
3343
+ process.stdout.write(
3344
+ ` ${usePortless ? portlessConsoleUrl(token) : `${scheme}://${uiHost}:${listenPort}/#k=${token}`}\n\n`,
3345
+ );
3216
3346
  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`);
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`);
3220
3350
  process.stdout.write(`save as alias:\n`);
3221
- process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${port}\n\n`);
3351
+ process.stdout.write(` ay remote add <alias> ${scheme}://${token}@<host>:${listenPort}\n\n`);
3222
3352
  if (!useHttps) {
3223
3353
  process.stdout.write(
3224
3354
  `for HTTPS: ay serve --tls-cert cert.pem --tls-key key.pem\n` +
package/ts/share.ts CHANGED
@@ -66,10 +66,28 @@ const MAX_PEER_JOIN_QUEUE = 50;
66
66
  const IDLE_RESTART_UPTIME_MS = 25 * 60_000;
67
67
  const HARD_RESTART_UPTIME_MS = 45 * 60_000;
68
68
  const IDLE_RESTART_CHECK_MS = 60_000;
69
+ const PERF_SLOW_MS = Number(process.env.AGENT_YES_WEBRTC_PERF_SLOW_MS) || 750;
70
+ const PERF_BUFFERED_BYTES = Number(process.env.AGENT_YES_WEBRTC_PERF_BUFFERED_BYTES) || 1_000_000;
71
+ const PERF_VERBOSE = process.env.AGENT_YES_WEBRTC_PERF === "1";
69
72
 
70
73
  type IceServer = { urls: string | string[]; username?: string; credential?: string };
71
74
  const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
72
75
 
76
+ function perfLog(event: string, data: Record<string, unknown>, force = false) {
77
+ if (!force && !PERF_VERBOSE) return;
78
+ process.stderr.write(`[share:perf] ${JSON.stringify({ t: Date.now(), event, ...data })}\n`);
79
+ }
80
+
81
+ function maybeSlow(event: string, startedAt: number, data: Record<string, unknown> = {}) {
82
+ const ms = Math.round(performance.now() - startedAt);
83
+ perfLog(event, { ms, ...data }, ms >= PERF_SLOW_MS);
84
+ }
85
+
86
+ function dcBufferedAmount(dc: any): number {
87
+ const n = Number(dc?.bufferedAmount ?? 0);
88
+ return Number.isFinite(n) ? n : 0;
89
+ }
90
+
73
91
  // Short-lived Cloudflare TURN credentials, minted from a long-term TURN key, so
74
92
  // browsers can RELAY when a direct P2P path is impossible (symmetric NAT /
75
93
  // CGNAT — the main cause of "rooms offline"). Set CF_TURN_KEY_ID +
@@ -428,6 +446,7 @@ export async function startShare(
428
446
  if (closed) return; // a reconnect timer queued before close() must not revive it
429
447
  const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
430
448
  currentWs = ws;
449
+ const signalStartedAt = performance.now();
431
450
  let ready = false;
432
451
  let lastRecv = Date.now();
433
452
  let hb: ReturnType<typeof setInterval> | undefined;
@@ -443,6 +462,7 @@ export async function startShare(
443
462
  }
444
463
  };
445
464
  ws.onopen = () => {
465
+ maybeSlow("signal.open", signalStartedAt, { room, host });
446
466
  ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
447
467
  ready = true;
448
468
  lastRecv = Date.now();
@@ -480,6 +500,12 @@ export async function startShare(
480
500
  const m = JSON.parse(ev.data as string);
481
501
  if (m.type === "pong") return; // heartbeat ack — liveness already recorded
482
502
  if (m.type === "peer-join") {
503
+ perfLog("peer.join", {
504
+ room,
505
+ peer: String(m.peer),
506
+ queue: peerJoinQueue.length,
507
+ peers: peers.size,
508
+ });
483
509
  // Serialized in drainPeerJoins() to avoid a storm. Skip dupes (already
484
510
  // queued or already an active peer) and cap the queue so a pathological
485
511
  // burst can't grow it unboundedly — dropped joins retry via the browser.
@@ -554,6 +580,7 @@ export async function startShare(
554
580
  };
555
581
 
556
582
  async function startPeer(ws: WebSocket, peerId: string) {
583
+ const startedAt = performance.now();
557
584
  const iceServers = await getIceServers();
558
585
  const pc = new RTCPeerConnection({ iceServers });
559
586
  let resolveKeys!: () => void;
@@ -584,6 +611,11 @@ export async function startShare(
584
611
  dc.binaryType = "arraybuffer";
585
612
  dc.onopen = async () => {
586
613
  try {
614
+ maybeSlow("peer.dc_open", startedAt, {
615
+ room,
616
+ peer: peerId,
617
+ buffered: dcBufferedAmount(dc),
618
+ });
587
619
  await peer.keysReady; // keys derived in the answer handler
588
620
  // Open the mandatory bidirectional key-confirmation handshake. Nothing
589
621
  // the peer sends is acted on until BOTH directions confirm (see onFrame).
@@ -624,6 +656,7 @@ export async function startShare(
624
656
  ws.send(
625
657
  JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp, iceServers }),
626
658
  );
659
+ maybeSlow("peer.offer", startedAt, { room, peer: peerId, iceServers: iceServers.length });
627
660
  }
628
661
 
629
662
  function closePeer(peerId: string) {
@@ -642,8 +675,10 @@ export async function startShare(
642
675
  // Seal an envelope and send it, serialized per peer so the wire order matches
643
676
  // the nonce-counter order (so the receiver's monotonic check never trips).
644
677
  function enqueueSeal(peerId: string, dc: any, peer: Peer, flags: number, obj: object) {
678
+ const queuedAt = performance.now();
645
679
  peer.sendChain = peer.sendChain.then(async () => {
646
680
  if (dc.readyState !== "open" || !peer.keyH2C || !peer.th) return;
681
+ const queuedMs = Math.round(performance.now() - queuedAt);
647
682
  let frame: ArrayBuffer;
648
683
  try {
649
684
  frame = await e2eSeal(peer.keyH2C, peer.send, flags, peer.th, packEnvelope(obj));
@@ -653,6 +688,19 @@ export async function startShare(
653
688
  }
654
689
  try {
655
690
  dc.send(frame);
691
+ const buffered = dcBufferedAmount(dc);
692
+ perfLog(
693
+ "send",
694
+ {
695
+ room,
696
+ peer: peerId,
697
+ kind: (obj as any)?.t,
698
+ queuedMs,
699
+ buffered,
700
+ bytes: frame.byteLength,
701
+ },
702
+ queuedMs >= PERF_SLOW_MS || buffered >= PERF_BUFFERED_BYTES,
703
+ );
656
704
  } catch {
657
705
  /* peer vanished mid-send; dropping the frame is correct */
658
706
  }
@@ -703,6 +751,8 @@ export async function startShare(
703
751
  }
704
752
  if (req.t !== "req") return;
705
753
  const { id, method, path: p, body } = req;
754
+ const startedAt = performance.now();
755
+ perfLog("req.start", { room, peer: peerId, id, method, path: p });
706
756
  const ac = new AbortController();
707
757
  peer.aborts.set(id, ac);
708
758
  try {
@@ -718,6 +768,14 @@ export async function startShare(
718
768
  signal: ac.signal,
719
769
  }),
720
770
  );
771
+ maybeSlow("req.head", startedAt, {
772
+ room,
773
+ peer: peerId,
774
+ id,
775
+ method,
776
+ path: p,
777
+ status: res.status,
778
+ });
721
779
  enqueueSeal(peerId, dc, peer, 0, {
722
780
  t: "res",
723
781
  id,
@@ -727,9 +785,11 @@ export async function startShare(
727
785
  const reader = res.body!.getReader();
728
786
  const dec = new TextDecoder();
729
787
  let seq = 0;
788
+ let bytes = 0;
730
789
  for (;;) {
731
790
  const { done, value } = await reader.read();
732
791
  if (done) break;
792
+ bytes += value.byteLength;
733
793
  const text = dec.decode(value, { stream: true });
734
794
  // Slice on UTF-16 boundaries: JSON round-trips lone surrogates as \uXXXX,
735
795
  // so the receiver reassembles the exact text by concatenating in seq order.
@@ -742,13 +802,32 @@ export async function startShare(
742
802
  });
743
803
  }
744
804
  enqueueSeal(peerId, dc, peer, 0, { t: "end", id, seq });
805
+ maybeSlow("req.end", startedAt, {
806
+ room,
807
+ peer: peerId,
808
+ id,
809
+ method,
810
+ path: p,
811
+ status: res.status,
812
+ chunks: seq,
813
+ bytes,
814
+ });
745
815
  } catch (e) {
746
- if ((e as Error).name !== "AbortError")
816
+ if ((e as Error).name !== "AbortError") {
817
+ maybeSlow("req.error", startedAt, {
818
+ room,
819
+ peer: peerId,
820
+ id,
821
+ method,
822
+ path: p,
823
+ error: String((e as Error).message ?? e),
824
+ });
747
825
  enqueueSeal(peerId, dc, peer, 0, {
748
826
  t: "end",
749
827
  id,
750
828
  error: String((e as Error).message ?? e),
751
829
  });
830
+ }
752
831
  } finally {
753
832
  peer.aborts.delete(id);
754
833
  }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseStatusText } from "./statusText.ts";
3
+
4
+ describe("parseStatusText", () => {
5
+ it("returns the latest Claude spinner/status line", () => {
6
+ expect(
7
+ parseStatusText([
8
+ "older output",
9
+ "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)",
10
+ ]),
11
+ ).toBe("✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)");
12
+ });
13
+
14
+ it("ignores non-status terminal prose", () => {
15
+ expect(parseStatusText(["hello", "Done", ""])).toBe(null);
16
+ });
17
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Extract a short "what is the agent doing right now?" line from the rendered
3
+ * terminal screen. Claude Code paints this as a spinner/status line, e.g.
4
+ * "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)".
5
+ */
6
+
7
+ const SPINNER_PREFIX = /^[\u2800-\u28ff✶✻✢✳✽✦✧✩✷✸✹✺✼·•●◐◓◒◑]\s+/u;
8
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g;
9
+
10
+ export function parseStatusText(lines: string[]): string | null {
11
+ for (let i = lines.length - 1; i >= 0; i--) {
12
+ const line = lines[i]?.replace(CONTROL, "").trim();
13
+ if (!line || line.length < 3) continue;
14
+ if (!SPINNER_PREFIX.test(line)) continue;
15
+ if (/^(?:[•·]\s*)?(?:esc|ctrl|enter|return|shift|tab)\b/i.test(line)) continue;
16
+ return line.slice(0, 220).trim();
17
+ }
18
+ return null;
19
+ }
@@ -1,9 +0,0 @@
1
- import "./ts-Dm6jjLwC.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-BomfUpSo.js";
4
- import "./pidStore-BIvsBQ8X.js";
5
- import "./globalPidIndex-CoNr7tS8.js";
6
- import "./messageLog-CxrKJj77.js";
7
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
8
-
9
- export { SUPPORTED_CLIS };