agent-yes 1.206.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.
@@ -1,8 +1,8 @@
1
- import { t as CLIS_CONFIG } from "./ts-DnqRreF4.js";
1
+ import { t as CLIS_CONFIG } from "./ts-DZWZvbLM.js";
2
2
 
3
3
  //#region ts/SUPPORTED_CLIS.ts
4
4
  const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
5
5
 
6
6
  //#endregion
7
7
  export { SUPPORTED_CLIS as t };
8
- //# sourceMappingURL=SUPPORTED_CLIS-DQSgURGW.js.map
8
+ //# sourceMappingURL=SUPPORTED_CLIS-B1CDJwYn.js.map
@@ -0,0 +1,9 @@
1
+ import "./ts-DZWZvbLM.js";
2
+ import "./logger-CDIsZ-Pp.js";
3
+ import "./versionChecker-C2CV9VOX.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-B1CDJwYn.js";
8
+
9
+ export { SUPPORTED_CLIS };
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { M as resolveOne } from "./subcommands-DkrMRVNh.js";
5
+ import { M as resolveOne } from "./subcommands-6AQ8egRn.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -229,4 +229,4 @@ function transformEvent(rawEvent, agentId, forwarded) {
229
229
 
230
230
  //#endregion
231
231
  export { createScopedShare, listShares, revokeAllShares, revokeShare };
232
- //# sourceMappingURL=agentShare-k8224qiZ.js.map
232
+ //# sourceMappingURL=agentShare-DdEbHtAn.js.map
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
3
3
  import { n as logger } from "./logger-CDIsZ-Pp.js";
4
- import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-d5c6_vgS.js";
5
- import { n as getRustBinary } from "./rustBinary-BjRvvEs8.js";
4
+ import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-C2CV9VOX.js";
5
+ import { n as getRustBinary } from "./rustBinary-CuBIr-zX.js";
6
6
  import { argv } from "process";
7
7
  import { spawn } from "child_process";
8
8
  import ms from "ms";
@@ -285,7 +285,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
285
285
  const rawArg = process.argv[2];
286
286
  const managerCommands = !invokedCliName(process.argv);
287
287
  const isHelpFlag = rawArg === "-h" || rawArg === "--help";
288
- const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DCGZjr4t.js");
288
+ const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-CeRH9-_h.js");
289
289
  if (isHelpFlag && process.argv.length === 3) {
290
290
  await cmdHelp(managerCommands);
291
291
  process.exit(0);
@@ -327,7 +327,7 @@ if (config.useRust) {
327
327
  }
328
328
  }
329
329
  if (rustBinary) {
330
- const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-CQ6e94ln.js");
330
+ const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-vH8hvcZr.js");
331
331
  const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
332
332
  if (config.verbose) {
333
333
  console.log(`[rust] Using binary: ${rustBinary}`);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-DnqRreF4.js";
1
+ import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-DZWZvbLM.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-d5c6_vgS.js";
3
+ import "./versionChecker-C2CV9VOX.js";
4
4
  import "./pidStore-BIvsBQ8X.js";
5
5
  import "./globalPidIndex-CoNr7tS8.js";
6
6
  import "./messageLog-CxrKJj77.js";
@@ -2,7 +2,7 @@ import { n as logger } from "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { $ as daemonLockOwnerPath, G as gcInboxes, J as listInboxParents, K as hostId, Q as daemonLockDir, S as listRecords, W as appendEvent, X as readInbox, Y as liveWatchers, Z as shouldStealLock, _ as isPidAlive, c as deriveLiveState, et as notifyDir, k as renderLogTailLines } from "./subcommands-DkrMRVNh.js";
5
+ import { $ as daemonLockOwnerPath, G as gcInboxes, J as listInboxParents, K as hostId, Q as daemonLockDir, S as listRecords, W as appendEvent, X as readInbox, Y as liveWatchers, Z as shouldStealLock, _ as isPidAlive, c as deriveLiveState, et as notifyDir, k as renderLogTailLines } from "./subcommands-6AQ8egRn.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -589,4 +589,4 @@ async function ensureDaemon() {
589
589
 
590
590
  //#endregion
591
591
  export { daemonStatus, ensureDaemon, requestDaemonStop, runDaemon };
592
- //# sourceMappingURL=notifyDaemon-BbVnNvlo.js.map
592
+ //# sourceMappingURL=notifyDaemon-DivHVzZx.js.map
@@ -1,4 +1,4 @@
1
- import { r as getInstalledPackage } from "./versionChecker-d5c6_vgS.js";
1
+ import { r as getInstalledPackage } from "./versionChecker-C2CV9VOX.js";
2
2
  import { execFileSync } from "child_process";
3
3
  import { existsSync, mkdirSync, unlinkSync } from "fs";
4
4
  import { chmod, copyFile } from "fs/promises";
@@ -225,4 +225,4 @@ async function getRustBinary(options = {}) {
225
225
 
226
226
  //#endregion
227
227
  export { getRustBinary as n, findSpawnHiddenLauncher as t };
228
- //# sourceMappingURL=rustBinary-BjRvvEs8.js.map
228
+ //# sourceMappingURL=rustBinary-CuBIr-zX.js.map
@@ -1,10 +1,10 @@
1
- import "./ts-DnqRreF4.js";
1
+ import "./ts-DZWZvbLM.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-d5c6_vgS.js";
3
+ import "./versionChecker-C2CV9VOX.js";
4
4
  import "./pidStore-BIvsBQ8X.js";
5
5
  import "./globalPidIndex-CoNr7tS8.js";
6
6
  import "./messageLog-CxrKJj77.js";
7
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DQSgURGW.js";
7
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-B1CDJwYn.js";
8
8
  import { d as resolveSpawnCwd } from "./workspaceConfig-CjaRvTjf.js";
9
9
  import { n as liveEnv, t as ensureNodeRuntime } from "./nodeRuntime-CwNJuwH5.js";
10
10
  import { createHash } from "node:crypto";
@@ -151,4 +151,4 @@ async function cmdSchedule(rest) {
151
151
 
152
152
  //#endregion
153
153
  export { cmdSchedule };
154
- //# sourceMappingURL=schedule-DJ-v4yVa.js.map
154
+ //# sourceMappingURL=schedule-Bg_CDt2j.js.map
@@ -1,15 +1,15 @@
1
- import "./ts-DnqRreF4.js";
1
+ import "./ts-DZWZvbLM.js";
2
2
  import "./logger-CDIsZ-Pp.js";
3
- import { r as getInstalledPackage } from "./versionChecker-d5c6_vgS.js";
4
- import { t as findSpawnHiddenLauncher } from "./rustBinary-BjRvvEs8.js";
3
+ import { r as getInstalledPackage } from "./versionChecker-C2CV9VOX.js";
4
+ import { t as findSpawnHiddenLauncher } from "./rustBinary-CuBIr-zX.js";
5
5
  import { t as agentYesHome$1 } from "./agentYesHome-CtHb5b71.js";
6
6
  import "./pidStore-BIvsBQ8X.js";
7
7
  import { a as updateGlobalPidStatus } from "./globalPidIndex-CoNr7tS8.js";
8
8
  import { r as recordInbox } from "./messageLog-CxrKJj77.js";
9
9
  import { t as pgidForWrapper } from "./reaper-BUHCyxdF.js";
10
10
  import "./configShared-aKTg-sa5.js";
11
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DQSgURGW.js";
12
- import { A as renderRawLog, D as recentMessageEdges, E as readPtysize, L as snapshotStatus, M as resolveOne, O as recentReadEdges, S as listRecords, T as readNotes, U as writeToIpc, b as isUserTyping, f as extractNeedsInput, k as renderLogTailLines, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, q as isTransientLockMkdirError, tt as TYPING_BADGE, u as extractBadges } from "./subcommands-DkrMRVNh.js";
11
+ import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-B1CDJwYn.js";
12
+ import { A as renderRawLog, D as recentMessageEdges, E as readPtysize, L as snapshotStatus, M as resolveOne, O as recentReadEdges, S as listRecords, T as readNotes, U as writeToIpc, b as isUserTyping, f as extractNeedsInput, k as renderLogTailLines, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, q as isTransientLockMkdirError, tt as TYPING_BADGE, u as extractBadges } from "./subcommands-6AQ8egRn.js";
13
13
  import "./e2e-jb0Hp43q.js";
14
14
  import "./webrtcLink-B7REGtK2.js";
15
15
  import "./remotes-Cim0dBU7.js";
@@ -207,6 +207,48 @@ async function acquireWebrtcHostLock(opts) {
207
207
  }
208
208
  }
209
209
 
210
+ //#endregion
211
+ //#region ts/sizeNego.ts
212
+ /** Reject junk caps (a viewer mid-layout can report 0×0 or absurd numbers). */
213
+ const CAP_MIN_COLS = 20;
214
+ const CAP_MIN_ROWS = 5;
215
+ const CAP_MAX_COLS = 500;
216
+ const CAP_MAX_ROWS = 200;
217
+ /** Never negotiate below this — TUIs (claude, codex) break down when the grid
218
+ * gets absurdly narrow, so a tiny watch-sized viewer can't wedge the agent. */
219
+ const NEGO_FLOOR_COLS = 40;
220
+ const NEGO_FLOOR_ROWS = 10;
221
+ /** Parse a presence-reported cap into a sane SizeCap, or null if unusable. */
222
+ function sanitizeCap(cap) {
223
+ if (typeof cap !== "object" || cap === null) return null;
224
+ const c = Math.floor(Number(cap.cols) || 0);
225
+ const r = Math.floor(Number(cap.rows) || 0);
226
+ if (c < CAP_MIN_COLS || c > CAP_MAX_COLS) return null;
227
+ if (r < CAP_MIN_ROWS || r > CAP_MAX_ROWS) return null;
228
+ return {
229
+ cols: c,
230
+ rows: r
231
+ };
232
+ }
233
+ /**
234
+ * Elementwise minimum over the live viewers' capacities, clamped to the floor.
235
+ * Returns null when no viewer reports a capacity — meaning "withdraw the
236
+ * negotiated size, let the real tty size rule again".
237
+ */
238
+ function negotiateSize(caps) {
239
+ let cols = Infinity;
240
+ let rows = Infinity;
241
+ for (const cap of caps) {
242
+ if (cap.cols < cols) cols = cap.cols;
243
+ if (cap.rows < rows) rows = cap.rows;
244
+ }
245
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return null;
246
+ return {
247
+ cols: Math.max(NEGO_FLOOR_COLS, cols),
248
+ rows: Math.max(NEGO_FLOOR_ROWS, rows)
249
+ };
250
+ }
251
+
210
252
  //#endregion
211
253
  //#region ts/strayServe.ts
212
254
  const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
@@ -387,7 +429,7 @@ function loginShellEnv() {
387
429
  const delim = "_AY_SHELL_ENV_DELIM_";
388
430
  const out = Bun.spawnSync([
389
431
  shell,
390
- "-ilc",
432
+ "-lc",
391
433
  `printf %s "${delim}"; env -0; printf %s "${delim}"`
392
434
  ], {
393
435
  stdin: "ignore",
@@ -1467,6 +1509,53 @@ Options:
1467
1509
  };
1468
1510
  const presence = /* @__PURE__ */ new Map();
1469
1511
  const PRESENCE_TTL_MS = 12e3;
1512
+ const negoApplied = /* @__PURE__ */ new Map();
1513
+ const negoTimers = /* @__PURE__ */ new Map();
1514
+ const NEGO_DEBOUNCE_MS = 350;
1515
+ const winsizePathFor = (pid) => path.join(process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"), "winsize", String(pid));
1516
+ const liveCapsFor = (pid) => {
1517
+ const now = Date.now();
1518
+ const caps = [];
1519
+ for (const v of presence.values()) if (now - v.ts <= PRESENCE_TTL_MS && String(v.agent) === String(pid) && v.cap) caps.push(v.cap);
1520
+ return caps;
1521
+ };
1522
+ const applyNego = async (pid) => {
1523
+ negoTimers.delete(pid);
1524
+ const eff = negotiateSize(liveCapsFor(pid));
1525
+ const file = winsizePathFor(pid);
1526
+ const prev = negoApplied.get(pid);
1527
+ try {
1528
+ if (eff) {
1529
+ if (prev && prev.cols === eff.cols && prev.rows === eff.rows) return;
1530
+ const content = `${eff.cols} ${eff.rows} ${Date.now()}\n`;
1531
+ await mkdir(path.dirname(file), { recursive: true });
1532
+ await writeFile(file, content);
1533
+ negoApplied.set(pid, {
1534
+ ...eff,
1535
+ content
1536
+ });
1537
+ } else {
1538
+ if (!prev) return;
1539
+ negoApplied.delete(pid);
1540
+ try {
1541
+ if (await readFile(file, "utf-8") !== prev.content) return;
1542
+ await unlink(file);
1543
+ } catch {
1544
+ return;
1545
+ }
1546
+ }
1547
+ try {
1548
+ process.kill(pid, "SIGWINCH");
1549
+ } catch {}
1550
+ } catch {}
1551
+ };
1552
+ const scheduleNego = (pid) => {
1553
+ if (!Number.isFinite(pid) || pid <= 0 || negoTimers.has(pid)) return;
1554
+ negoTimers.set(pid, setTimeout(() => void applyNego(pid), NEGO_DEBOUNCE_MS));
1555
+ };
1556
+ setInterval(() => {
1557
+ for (const pid of negoApplied.keys()) scheduleNego(pid);
1558
+ }, 5e3);
1470
1559
  const apiFetch = async (req) => {
1471
1560
  if (!checkAuth(req, token)) return new Response("Unauthorized", { status: 401 });
1472
1561
  const url = new URL(req.url);
@@ -1779,7 +1868,7 @@ Options:
1779
1868
  }
1780
1869
  });
1781
1870
  if (req.method === "GET" && p === "/api/ws") {
1782
- const ws = await import("./ws-BmHpWaG_.js");
1871
+ const ws = await import("./ws-CGNmxCPD.js");
1783
1872
  try {
1784
1873
  await ws.loadProvision();
1785
1874
  } catch (e) {
@@ -1799,7 +1888,7 @@ Options:
1799
1888
  if (req.method === "GET" && p === "/api/ws/status") {
1800
1889
  const dirRaw = url.searchParams.get("path");
1801
1890
  if (!dirRaw) return new Response("missing ?path=<workspace dir>", { status: 400 });
1802
- const ws = await import("./ws-BmHpWaG_.js");
1891
+ const ws = await import("./ws-CGNmxCPD.js");
1803
1892
  let prov;
1804
1893
  try {
1805
1894
  prov = await ws.loadProvision();
@@ -1864,7 +1953,8 @@ Options:
1864
1953
  return Response.json({
1865
1954
  pid: record.pid,
1866
1955
  cols: size?.cols ?? null,
1867
- rows: size?.rows ?? null
1956
+ rows: size?.rows ?? null,
1957
+ nego: true
1868
1958
  });
1869
1959
  } catch (e) {
1870
1960
  return new Response(e.message, { status: 404 });
@@ -2145,6 +2235,7 @@ Options:
2145
2235
  }
2146
2236
  const viewer = String(b.viewer ?? "").slice(0, 64);
2147
2237
  if (!viewer) return new Response("missing viewer", { status: 400 });
2238
+ const prevAgent = presence.get(viewer)?.agent;
2148
2239
  if (b.agent == null) presence.delete(viewer);
2149
2240
  else presence.set(viewer, {
2150
2241
  viewer,
@@ -2152,8 +2243,11 @@ Options:
2152
2243
  cols: Math.max(0, Math.floor(Number(b.cols) || 0)),
2153
2244
  rows: Math.max(0, Math.floor(Number(b.rows) || 0)),
2154
2245
  sel: typeof b.sel === "string" ? b.sel.slice(0, 200) : null,
2246
+ cap: sanitizeCap(b.cap),
2155
2247
  ts: Date.now()
2156
2248
  });
2249
+ if (b.agent != null) scheduleNego(Number(b.agent));
2250
+ if (prevAgent != null && String(prevAgent) !== String(b.agent ?? "")) scheduleNego(Number(prevAgent));
2157
2251
  return new Response(null, { status: 204 });
2158
2252
  }
2159
2253
  if (req.method === "GET" && p === "/api/presence") {
@@ -2355,7 +2449,7 @@ Options:
2355
2449
  const perm = body.perm ?? "r";
2356
2450
  if (perm !== "r" && perm !== "rw") return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
2357
2451
  try {
2358
- const { createScopedShare } = await import("./agentShare-k8224qiZ.js");
2452
+ const { createScopedShare } = await import("./agentShare-DdEbHtAn.js");
2359
2453
  const share = await createScopedShare({
2360
2454
  agent: body.agent,
2361
2455
  perm,
@@ -2370,12 +2464,12 @@ Options:
2370
2464
  }
2371
2465
  }
2372
2466
  if (req.method === "GET" && p === "/api/shares") {
2373
- const { listShares } = await import("./agentShare-k8224qiZ.js");
2467
+ const { listShares } = await import("./agentShare-DdEbHtAn.js");
2374
2468
  return Response.json(listShares());
2375
2469
  }
2376
2470
  const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
2377
2471
  if (req.method === "DELETE" && revokeM) {
2378
- const { revokeShare } = await import("./agentShare-k8224qiZ.js");
2472
+ const { revokeShare } = await import("./agentShare-DdEbHtAn.js");
2379
2473
  const ok = revokeShare(decodeURIComponent(revokeM[1]));
2380
2474
  return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
2381
2475
  }
@@ -2561,7 +2655,7 @@ Options:
2561
2655
  const shutdown = (resolve) => {
2562
2656
  if (heartbeat) clearInterval(heartbeat);
2563
2657
  closeShare?.();
2564
- import("./agentShare-k8224qiZ.js").then((m) => m.revokeAllShares()).catch(() => {});
2658
+ import("./agentShare-DdEbHtAn.js").then((m) => m.revokeAllShares()).catch(() => {});
2565
2659
  server?.stop();
2566
2660
  Promise.resolve(releaseHostLock?.()).catch(() => {}).then(resolve);
2567
2661
  };
@@ -2574,4 +2668,4 @@ Options:
2574
2668
 
2575
2669
  //#endregion
2576
2670
  export { cmdServe };
2577
- //# sourceMappingURL=serve-crLypY_c.js.map
2671
+ //# sourceMappingURL=serve-B-ZCy67S.js.map
@@ -32,7 +32,7 @@ async function cmdSetup(rest) {
32
32
  if (!existsSync(abs)) process.stderr.write(` note: that directory doesn't exist yet — create it, or agents spawned there will fail\n`);
33
33
  if (noShare) return 0;
34
34
  process.stdout.write(`\nsharing this machine to agent-yes.com…\n`);
35
- const { cmdServe } = await import("./serve-crLypY_c.js");
35
+ const { cmdServe } = await import("./serve-B-ZCy67S.js");
36
36
  return cmdServe([
37
37
  "install",
38
38
  "--share",
@@ -42,4 +42,4 @@ async function cmdSetup(rest) {
42
42
 
43
43
  //#endregion
44
44
  export { cmdSetup };
45
- //# sourceMappingURL=setup-BPNkeSmY.js.map
45
+ //# sourceMappingURL=setup-D6UBzk4-.js.map
@@ -1237,19 +1237,19 @@ async function runSubcommand(argv) {
1237
1237
  case "restart": return await cmdRestart(rest);
1238
1238
  case "note": return await cmdNote(rest);
1239
1239
  case "serve": {
1240
- const { cmdServe } = await import("./serve-crLypY_c.js");
1240
+ const { cmdServe } = await import("./serve-B-ZCy67S.js");
1241
1241
  return cmdServe(rest);
1242
1242
  }
1243
1243
  case "setup": {
1244
- const { cmdSetup } = await import("./setup-BPNkeSmY.js");
1244
+ const { cmdSetup } = await import("./setup-D6UBzk4-.js");
1245
1245
  return cmdSetup(rest);
1246
1246
  }
1247
1247
  case "ws": {
1248
- const { cmdWs } = await import("./ws-BmHpWaG_.js");
1248
+ const { cmdWs } = await import("./ws-CGNmxCPD.js");
1249
1249
  return cmdWs(rest);
1250
1250
  }
1251
1251
  case "schedule": {
1252
- const { cmdSchedule } = await import("./schedule-DJ-v4yVa.js");
1252
+ const { cmdSchedule } = await import("./schedule-Bg_CDt2j.js");
1253
1253
  return cmdSchedule(rest);
1254
1254
  }
1255
1255
  case "remote": {
@@ -4269,7 +4269,7 @@ async function cmdNotify(rest) {
4269
4269
  }
4270
4270
  const ensure = async () => {
4271
4271
  if (!argv["ensure-daemon"]) return;
4272
- const { ensureDaemon } = await import("./notifyDaemon-BbVnNvlo.js");
4272
+ const { ensureDaemon } = await import("./notifyDaemon-DivHVzZx.js");
4273
4273
  await ensureDaemon().catch(() => null);
4274
4274
  };
4275
4275
  await heartbeatWatcher(parent, selfStartedAt);
@@ -4343,7 +4343,7 @@ async function cmdNotifyCursor(args) {
4343
4343
  }
4344
4344
  async function cmdNotifyd(rest) {
4345
4345
  const sub = rest[0] ?? "status";
4346
- const daemon = await import("./notifyDaemon-BbVnNvlo.js");
4346
+ const daemon = await import("./notifyDaemon-DivHVzZx.js");
4347
4347
  switch (sub) {
4348
4348
  case "run": return daemon.runDaemon();
4349
4349
  case "once": return daemon.runDaemon({ once: true });
@@ -4370,4 +4370,4 @@ async function cmdNotifyd(rest) {
4370
4370
 
4371
4371
  //#endregion
4372
4372
  export { daemonLockOwnerPath as $, renderRawLog as A, submitAndConfirm as B, matchKeyword as C, recentMessageEdges as D, readPtysize as E, restartHintLines as F, gcInboxes as G, writeKeysPaced as H, runSubcommand as I, listInboxParents as J, hostId as K, snapshotStatus as L, resolveOne as M, resolveReadWindow as N, recentReadEdges as O, resolveResumeArgs as P, daemonLockDir as Q, stdinActivityPath as R, listRecords as S, readNotes as T, writeToIpc as U, waitForLogQuiet as V, appendEvent as W, readInbox as X, liveWatchers as Y, shouldStealLock as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, notifyDir as et, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, renderRawLogLines as j, renderLogTailLines as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, isTransientLockMkdirError as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, TYPING_BADGE as tt, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, stopTipForCli as z };
4373
- //# sourceMappingURL=subcommands-DkrMRVNh.js.map
4373
+ //# sourceMappingURL=subcommands-6AQ8egRn.js.map
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { A as renderRawLog, B as submitAndConfirm, C as matchKeyword, D as recentMessageEdges, E as readPtysize, F as restartHintLines, H as writeKeysPaced, I as runSubcommand, L as snapshotStatus, M as resolveOne, N as resolveReadWindow, O as recentReadEdges, P as resolveResumeArgs, R as stdinActivityPath, S as listRecords, T as readNotes, U as writeToIpc, V as waitForLogQuiet, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as renderRawLogLines, k as renderLogTailLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as stopTipForCli } from "./subcommands-DkrMRVNh.js";
5
+ import { A as renderRawLog, B as submitAndConfirm, C as matchKeyword, D as recentMessageEdges, E as readPtysize, F as restartHintLines, H as writeKeysPaced, I as runSubcommand, L as snapshotStatus, M as resolveOne, N as resolveReadWindow, O as recentReadEdges, P as resolveResumeArgs, R as stdinActivityPath, S as listRecords, T as readNotes, U as writeToIpc, V as waitForLogQuiet, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as renderRawLogLines, k as renderLogTailLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as stopTipForCli } from "./subcommands-6AQ8egRn.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -1,5 +1,5 @@
1
1
  import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
2
- import { r as getInstalledPackage } from "./versionChecker-d5c6_vgS.js";
2
+ import { r as getInstalledPackage } from "./versionChecker-C2CV9VOX.js";
3
3
  import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
4
4
  import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CNMl13dC.js";
5
5
  import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
@@ -1901,4 +1901,4 @@ function sleep(ms) {
1901
1901
 
1902
1902
  //#endregion
1903
1903
  export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
1904
- //# sourceMappingURL=ts-DnqRreF4.js.map
1904
+ //# sourceMappingURL=ts-DZWZvbLM.js.map
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
7
7
 
8
8
  //#region package.json
9
9
  var name = "agent-yes";
10
- var version = "1.206.0";
10
+ var version = "1.207.0";
11
11
 
12
12
  //#endregion
13
13
  //#region ts/versionChecker.ts
@@ -215,4 +215,4 @@ async function displayVersion() {
215
215
 
216
216
  //#endregion
217
217
  export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
218
- //# sourceMappingURL=versionChecker-d5c6_vgS.js.map
218
+ //# sourceMappingURL=versionChecker-C2CV9VOX.js.map
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
2
2
  import "./globalPidIndex-CoNr7tS8.js";
3
3
  import "./messageLog-CxrKJj77.js";
4
4
  import "./configShared-aKTg-sa5.js";
5
- import { S as listRecords } from "./subcommands-DkrMRVNh.js";
5
+ import { S as listRecords } from "./subcommands-6AQ8egRn.js";
6
6
  import "./e2e-jb0Hp43q.js";
7
7
  import "./webrtcLink-B7REGtK2.js";
8
8
  import "./remotes-Cim0dBU7.js";
@@ -418,4 +418,4 @@ async function cmdWs(args) {
418
418
 
419
419
  //#endregion
420
420
  export { WS_JSON_SCHEMA, cmdWs, collectWorkspaces, isPathInside, loadProvision, workspaceStatus };
421
- //# sourceMappingURL=ws-BmHpWaG_.js.map
421
+ //# sourceMappingURL=ws-CGNmxCPD.js.map
@@ -701,6 +701,26 @@ export function fitTransform(gridW, gridH, paneW, paneH) {
701
701
  return s > 0.985 && s < 1.04 ? "none" : "scale(" + s.toFixed(4) + ")";
702
702
  }
703
703
 
704
+ // Centered variant: same scale selection as fitTransform, plus a translate that
705
+ // centers the scaled grid in the pane (fit-width → vertically centered,
706
+ // fit-height → horizontally centered). transform-origin stays top-left, so the
707
+ // translate is in pane px and consumers mapping grid→screen coords (peer
708
+ // overlays) can read the offset back off this same object. "none" (the crisp
709
+ // driver path) never gets a translate — grid ≈ pane, nothing to center.
710
+ export function fitTransformCentered(gridW, gridH, paneW, paneH) {
711
+ const t = fitTransform(gridW, gridH, paneW, paneH);
712
+ if (t === "none") return { transform: "none", scale: 1, dx: 0, dy: 0 };
713
+ const s = Math.min(paneW / gridW, paneH / gridH);
714
+ const dx = Math.max(0, (paneW - gridW * s) / 2);
715
+ const dy = Math.max(0, (paneH - gridH * s) / 2);
716
+ return {
717
+ transform: `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) ` + t,
718
+ scale: s,
719
+ dx,
720
+ dy,
721
+ };
722
+ }
723
+
704
724
  // Browser-tab title: "<glyph> <selected agent title> - agent-yes", or the bare
705
725
  // console title when nothing is selected (blank/whitespace name). The leading
706
726
  // glyph mirrors the agent's status dot — ⌨ needs_input ("your turn"), ⚠ stuck,
package/lab/ui/index.html CHANGED
@@ -2173,7 +2173,7 @@
2173
2173
  selFromBottom,
2174
2174
  parseSel,
2175
2175
  selSegments,
2176
- fitTransform,
2176
+ fitTransformCentered,
2177
2177
  docTitle,
2178
2178
  omniScore,
2179
2179
  } from "./console-logic.js";
@@ -2230,7 +2230,15 @@
2230
2230
  } catch {}
2231
2231
  if (term) {
2232
2232
  term.options.fontSize = termFontSize;
2233
- if (fit)
2233
+ // Negotiating host: the grid is canonical (host-owned) — a font change
2234
+ // here only alters our glyph px, so re-letterbox and report the new
2235
+ // capacity; the host reflows the PTY if our readable cols/rows moved.
2236
+ // This is what makes font size a ZOOM control on mobile: bigger font →
2237
+ // fewer readable cells → the PTY itself shrinks → bigger crisp text.
2238
+ if (negoActive) {
2239
+ applyCanvasScale();
2240
+ sendPresence();
2241
+ } else if (fit)
2234
2242
  try {
2235
2243
  fit.fit();
2236
2244
  } catch {}
@@ -3132,6 +3140,13 @@
3132
3140
  // short lease after that, the heartbeat won't follow/override — so our own
3133
3141
  // push isn't fought while the agent's /api/size is still catching up.
3134
3142
  let lastDroveAt = 0;
3143
+ // The selected agent's host negotiates the PTY size from viewer
3144
+ // capacities (its /api/size responses carry nego:true). On such hosts we
3145
+ // never push /api/resize — we report our readable capacity in presence
3146
+ // (sendPresence cap) and follow the negotiated canonical grid, so a phone
3147
+ // and a desktop watching the same agent converge on the smallest
3148
+ // viewer's readable size instead of fighting last-writer-wins.
3149
+ let negoActive = false;
3135
3150
 
3136
3151
  function isDarkColor(c) {
3137
3152
  try {
@@ -3277,6 +3292,18 @@
3277
3292
  const s = term.getSelectionPosition && term.getSelectionPosition();
3278
3293
  selR = selFromBottom(s, term.buffer.active.length);
3279
3294
  } catch {}
3295
+ // Our readable capacity: the grid our pane fits at the current font —
3296
+ // measured from the container (transform-independent), NOT term.cols
3297
+ // (which may be a foreign canonical grid we're letterboxing). The host
3298
+ // negotiates the PTY to the min cap across viewers; read-only viewers
3299
+ // report none (they can't influence the PTY). Older hosts ignore it.
3300
+ let cap = null;
3301
+ try {
3302
+ if (!isReadonlyEnt(cur.e) && fit) {
3303
+ const d = fit.proposeDimensions();
3304
+ if (d && d.cols > 0 && d.rows > 0) cap = { cols: d.cols, rows: d.rows };
3305
+ }
3306
+ } catch {}
3280
3307
  cur.tx
3281
3308
  .post("/api/presence", {
3282
3309
  viewer: myViewerId,
@@ -3284,6 +3311,7 @@
3284
3311
  cols: term.cols,
3285
3312
  rows: term.rows,
3286
3313
  sel: selR,
3314
+ cap,
3287
3315
  })
3288
3316
  .catch(() => {});
3289
3317
  }
@@ -3600,14 +3628,36 @@
3600
3628
  const cs = getComputedStyle(logEl);
3601
3629
  const pw = logEl.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
3602
3630
  const ph = logEl.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
3603
- // fitTransform (console-logic.js) decides none-vs-scale (unit-tested).
3604
- xt.style.transform = fitTransform(gw, gh, pw, ph);
3631
+ // fitTransformCentered (console-logic.js, unit-tested) decides
3632
+ // none-vs-scale and centers the letterboxed grid in the pane (fit
3633
+ // width → vertically centered, fit height → horizontally). The peer
3634
+ // overlay reads the transformed screen rect back via
3635
+ // getBoundingClientRect, so the translate needs no extra bookkeeping.
3636
+ xt.style.transform = fitTransformCentered(gw, gh, pw, ph).transform;
3605
3637
  } catch {}
3606
3638
  }
3639
+ // Pane geometry changed (window resize, splitter drag, soft keyboard, tab
3640
+ // refocus): on legacy hosts re-fit the grid to the pane (the size push
3641
+ // rides term.onResize → pushSize); on negotiating hosts the grid is
3642
+ // canonical — never reflow it locally, just re-letterbox and report the
3643
+ // new capacity so the host can move the min if needed.
3644
+ function refitPane() {
3645
+ if (!term) return;
3646
+ if (negoActive) {
3647
+ applyCanvasScale();
3648
+ sendPresence();
3649
+ return;
3650
+ }
3651
+ if (fit)
3652
+ try {
3653
+ fit.fit();
3654
+ } catch {}
3655
+ }
3607
3656
  // Follow the canonical grid: resize our xterm to the agent's size (set by the
3608
3657
  // active driver / local owner) WITHOUT reflowing to our pane, then scale to
3609
3658
  // fit. No-op when we already match (we're the driver, or sizes agree).
3610
3659
  function followAgentSize(sz) {
3660
+ if (sz && sz.nego != null) negoActive = !!sz.nego;
3611
3661
  if (!term || !sz || !sz.cols || !sz.rows) return;
3612
3662
  // While our own recent push is still settling, don't fight it.
3613
3663
  if (Date.now() - lastDroveAt < 6000) {
@@ -5187,6 +5237,14 @@
5187
5237
  applyCanvasScale();
5188
5238
  return;
5189
5239
  }
5240
+ // Negotiating host: the PTY size is the host's min over viewer
5241
+ // capacities, not ours to push. Re-letterbox and refresh our cap
5242
+ // (the pane may have changed) — the host resizes if the min moved.
5243
+ if (negoActive) {
5244
+ applyCanvasScale();
5245
+ sendPresence();
5246
+ return;
5247
+ }
5190
5248
  lastDroveAt = Date.now(); // we're driving the size now (lease the grid)
5191
5249
  tx.post("/api/resize/" + encodeURIComponent(pid), {
5192
5250
  cols: term.cols,
@@ -5227,6 +5285,7 @@
5227
5285
  const selKey = e._key;
5228
5286
  const fitAndSync = (sz) => {
5229
5287
  if (sel !== selKey || !term) return;
5288
+ negoActive = !!(sz && sz.nego);
5230
5289
  if (sz && sz.cols && sz.rows) agentSize = sz; // remember for the badge tooltip
5231
5290
  // Read-only viewer: don't reflow the agent to our pane (we can't resize
5232
5291
  // it, and shouldn't). Adopt the AGENT's own grid and CSS-scale it to fit,
@@ -5238,6 +5297,22 @@
5238
5297
  suppressPush = false;
5239
5298
  return;
5240
5299
  }
5300
+ // Negotiating host: adopt the canonical (negotiated) grid like a
5301
+ // watcher and report our capacity via presence — the host takes the
5302
+ // min over all viewers and resizes the PTY itself. No direct push, so
5303
+ // two viewers can't fight over the size.
5304
+ if (negoActive) {
5305
+ if (sz.cols && sz.rows) followAgentSize(sz);
5306
+ else {
5307
+ // agent has no size on record yet — seed from our own fit
5308
+ try {
5309
+ fit.fit();
5310
+ } catch {}
5311
+ }
5312
+ suppressPush = false;
5313
+ sendPresence(); // cap → host negotiates → size event brings the grid
5314
+ return;
5315
+ }
5241
5316
  try {
5242
5317
  fit.fit();
5243
5318
  } catch {}
@@ -5413,13 +5488,9 @@
5413
5488
  renderList();
5414
5489
  });
5415
5490
  window.addEventListener("resize", () => {
5416
- // Resizing our window is strong intent → re-fit to our pane (we become the
5417
- // size driver; the push rides term.onResize). applyCanvasScale then clears
5418
- // the transform since our grid now matches our pane.
5419
- if (fit)
5420
- try {
5421
- fit.fit();
5422
- } catch {}
5491
+ // Resizing our window is strong intent → re-fit / re-report capacity
5492
+ // (refitPane picks the legacy-push vs negotiate path).
5493
+ refitPane();
5423
5494
  applyCanvasScale();
5424
5495
  renderPeerSelections(); // cell size changed → reposition peer-selection overlays
5425
5496
  });
@@ -5434,15 +5505,76 @@
5434
5505
  const onVV = () => {
5435
5506
  const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
5436
5507
  document.documentElement.style.setProperty("--kb", kb + "px");
5437
- if (fit)
5438
- try {
5439
- fit.fit();
5440
- } catch {}
5508
+ refitPane();
5441
5509
  };
5442
5510
  vv.addEventListener("resize", onVV);
5443
5511
  vv.addEventListener("scroll", onVV);
5444
5512
  onVV();
5445
5513
  }
5514
+ // ── mobile pinch on the terminal = terminal zoom, not page zoom ─────────
5515
+ // Two fingers over the log pane adjust the terminal FONT SIZE. On a
5516
+ // negotiating host that reflows the PTY itself (bigger font → fewer
5517
+ // readable cells → the host shrinks the grid → bigger crisp text; smaller
5518
+ // font → more rows/cols), which is what a phone user actually means by
5519
+ // pinch: "show me more / make it readable", with the surrounding UI
5520
+ // staying put. Only 2-touch gestures over .log are intercepted — 1-finger
5521
+ // scroll/selection passes to xterm, and the page keeps its native
5522
+ // pinch-zoom everywhere else (an a11y requirement, WCAG 1.4.4).
5523
+ (function () {
5524
+ const logEl = $("log");
5525
+ if (!logEl) return;
5526
+ let startDist = 0; // 0 = no pinch in flight
5527
+ let startFont = 0;
5528
+ let lastRatio = 1;
5529
+ let baseTransform = "";
5530
+ const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
5531
+ logEl.addEventListener(
5532
+ "touchstart",
5533
+ (ev) => {
5534
+ if (ev.touches.length !== 2) return;
5535
+ ev.preventDefault(); // this gesture is ours — no page zoom
5536
+ startDist = dist(ev.touches);
5537
+ startFont = termFontSize;
5538
+ lastRatio = 1;
5539
+ const xt = logEl.querySelector(".xterm");
5540
+ baseTransform = xt ? xt.style.transform || "none" : "none";
5541
+ },
5542
+ { passive: false },
5543
+ );
5544
+ logEl.addEventListener(
5545
+ "touchmove",
5546
+ (ev) => {
5547
+ if (ev.touches.length !== 2 || !startDist) return;
5548
+ ev.preventDefault();
5549
+ lastRatio = dist(ev.touches) / startDist;
5550
+ // Live preview: compose the pinch onto the fitted transform. The
5551
+ // grid truly reflows only when the negotiated size lands after the
5552
+ // gesture commits — a brief settle, traded for zero SIGWINCH storm
5553
+ // while the fingers are still moving.
5554
+ const xt = logEl.querySelector(".xterm");
5555
+ if (xt)
5556
+ xt.style.transform =
5557
+ `scale(${lastRatio.toFixed(3)})` +
5558
+ (baseTransform === "none" ? "" : " " + baseTransform);
5559
+ },
5560
+ { passive: false },
5561
+ );
5562
+ const endPinch = () => {
5563
+ if (!startDist) return;
5564
+ const target = Math.round(startFont * lastRatio);
5565
+ startDist = 0;
5566
+ if (target !== termFontSize) setTermFontSize(target); // nego: cap → host reflows
5567
+ applyCanvasScale(); // clear the preview; re-letterbox at the real grid
5568
+ };
5569
+ logEl.addEventListener("touchend", (ev) => {
5570
+ if (ev.touches.length < 2) endPinch();
5571
+ });
5572
+ logEl.addEventListener("touchcancel", () => endPinch());
5573
+ // Safari's proprietary gesture events fire for pinch regardless of
5574
+ // touch-action; swallow them over the terminal so the page never zooms.
5575
+ for (const t of ["gesturestart", "gesturechange", "gestureend"])
5576
+ logEl.addEventListener(t, (ev) => ev.preventDefault());
5577
+ })();
5446
5578
 
5447
5579
  // Step the selection up/down the (filtered) list — same order the left panel
5448
5580
  // renders. Clamps at the ends, scrolls the row into view.
@@ -6983,11 +7115,7 @@
6983
7115
  // terminal of a different size) that re-push fights the local terminal
6984
7116
  // and reflows the stream on every tab switch — the glitch the eager
6985
7117
  // per-focus resync introduced. The one-shot adopt still runs on select.
6986
- if (term && fit) {
6987
- try {
6988
- fit.fit();
6989
- } catch {}
6990
- }
7118
+ refitPane();
6991
7119
  }
6992
7120
  });
6993
7121
  watchVersion();
@@ -7039,10 +7167,7 @@
7039
7167
  if (!dragging) return;
7040
7168
  const w = Math.min(Math.max(e.clientX, 280), window.innerWidth - 360);
7041
7169
  app.style.setProperty("--leftw", w + "px");
7042
- if (fit)
7043
- try {
7044
- fit.fit();
7045
- } catch {}
7170
+ refitPane();
7046
7171
  });
7047
7172
  const end = (e) => {
7048
7173
  if (!dragging) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.206.0",
3
+ "version": "1.207.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
package/ts/serve.ts CHANGED
@@ -48,6 +48,7 @@ import { findSpawnHiddenLauncher } from "./rustBinary.ts";
48
48
  import { pgidForWrapper } from "./reaper.ts";
49
49
  import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
50
50
  import { getInstalledPackage } from "./versionChecker.ts";
51
+ import { negotiateSize, sanitizeCap, type SizeCap } from "./sizeNego.ts";
51
52
  import { listStrayServeProcesses } from "./strayServe.ts";
52
53
  import {
53
54
  getProvisionHook,
@@ -235,10 +236,19 @@ const SESSION_PIN_ENV = new Set([
235
236
  // ~/.bun/bin, ~/.cargo/bin, Homebrew, nvm shims, etc. A console-spawned agent
236
237
  // that inherits that env can't find `bun`/`bunx`/etc. (the user reported "bunx:
237
238
  // command not found" when spawning a new agent from the web UI). We recover the
238
- // real environment by running the user's interactive login shell and dumping its
239
- // env, exactly as a fresh terminal would have it. Returns null on Windows (no
240
- // rc-file model the process env is already the right one) or on any failure, so
241
- // 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.
242
252
  let loginShellEnvCache: Record<string, string> | null | undefined;
243
253
  function loginShellEnv(): Record<string, string> | null {
244
254
  if (loginShellEnvCache !== undefined) return loginShellEnvCache;
@@ -250,7 +260,7 @@ function loginShellEnv(): Record<string, string> | null {
250
260
  // print to stdout; `env -0` is NUL-separated so values with newlines survive.
251
261
  const delim = "_AY_SHELL_ENV_DELIM_";
252
262
  const res = Bun.spawnSync(
253
- [shell, "-ilc", `printf %s "${delim}"; env -0; printf %s "${delim}"`],
263
+ [shell, "-lc", `printf %s "${delim}"; env -0; printf %s "${delim}"`],
254
264
  {
255
265
  stdin: "ignore",
256
266
  stderr: "ignore",
@@ -1850,10 +1860,87 @@ export async function cmdServe(rest: string[]): Promise<number> {
1850
1860
  // never a security surface — viewers self-report. Pruned by TTL on read.
1851
1861
  const presence = new Map<
1852
1862
  string,
1853
- { 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
+ }
1854
1872
  >();
1855
1873
  const PRESENCE_TTL_MS = 12_000;
1856
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
+
1857
1944
  // The whole API as a plain handler: served over HTTP by Bun.serve (--http)
1858
1945
  // and called in-process by the WebRTC bridge (--webrtc) — the latter needs
1859
1946
  // no TCP port at all.
@@ -2330,6 +2417,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
2330
2417
  pid: record.pid,
2331
2418
  cols: size?.cols ?? null,
2332
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,
2333
2424
  });
2334
2425
  } catch (e) {
2335
2426
  return new Response((e as Error).message, { status: 404 });
@@ -2735,6 +2826,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
2735
2826
  cols?: number;
2736
2827
  rows?: number;
2737
2828
  sel?: string;
2829
+ cap?: { cols?: number; rows?: number };
2738
2830
  };
2739
2831
  try {
2740
2832
  b = (await req.json()) as typeof b;
@@ -2743,6 +2835,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
2743
2835
  }
2744
2836
  const viewer = String(b.viewer ?? "").slice(0, 64);
2745
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;
2746
2841
  if (b.agent == null) presence.delete(viewer);
2747
2842
  else
2748
2843
  presence.set(viewer, {
@@ -2751,8 +2846,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
2751
2846
  cols: Math.max(0, Math.floor(Number(b.cols) || 0)),
2752
2847
  rows: Math.max(0, Math.floor(Number(b.rows) || 0)),
2753
2848
  sel: typeof b.sel === "string" ? b.sel.slice(0, 200) : null,
2849
+ cap: sanitizeCap(b.cap),
2754
2850
  ts: Date.now(),
2755
2851
  });
2852
+ if (b.agent != null) scheduleNego(Number(b.agent));
2853
+ if (prevAgent != null && String(prevAgent) !== String(b.agent ?? ""))
2854
+ scheduleNego(Number(prevAgent));
2756
2855
  return new Response(null, { status: 204 });
2757
2856
  }
2758
2857
  // GET /api/presence — all live viewers (TTL-pruned), for "who's watching".
@@ -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
 
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ NEGO_FLOOR_COLS,
4
+ NEGO_FLOOR_ROWS,
5
+ negotiateSize,
6
+ sanitizeCap,
7
+ } from "./sizeNego.ts";
8
+
9
+ describe("sanitizeCap", () => {
10
+ it("accepts a normal viewer capacity", () => {
11
+ expect(sanitizeCap({ cols: 80, rows: 24 })).toEqual({ cols: 80, rows: 24 });
12
+ });
13
+ it("floors fractional values", () => {
14
+ expect(sanitizeCap({ cols: 80.9, rows: 24.7 })).toEqual({ cols: 80, rows: 24 });
15
+ });
16
+ it("rejects null / non-object / missing fields", () => {
17
+ expect(sanitizeCap(null)).toBeNull();
18
+ expect(sanitizeCap(undefined)).toBeNull();
19
+ expect(sanitizeCap("80x24")).toBeNull();
20
+ expect(sanitizeCap({})).toBeNull();
21
+ expect(sanitizeCap({ cols: 80 })).toBeNull();
22
+ });
23
+ it("rejects out-of-range junk (mid-layout 0x0, absurd sizes)", () => {
24
+ expect(sanitizeCap({ cols: 0, rows: 0 })).toBeNull();
25
+ expect(sanitizeCap({ cols: 10, rows: 24 })).toBeNull(); // below min cols
26
+ expect(sanitizeCap({ cols: 80, rows: 2 })).toBeNull(); // below min rows
27
+ expect(sanitizeCap({ cols: 9999, rows: 24 })).toBeNull();
28
+ expect(sanitizeCap({ cols: 80, rows: 9999 })).toBeNull();
29
+ expect(sanitizeCap({ cols: NaN, rows: 24 })).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("negotiateSize", () => {
34
+ it("returns null with no caps (withdraw → tty size rules)", () => {
35
+ expect(negotiateSize([])).toBeNull();
36
+ });
37
+ it("single viewer: adopts its capacity", () => {
38
+ expect(negotiateSize([{ cols: 133, rows: 40 }])).toEqual({ cols: 133, rows: 40 });
39
+ });
40
+ it("phone + desktop: elementwise min (the tmux rule)", () => {
41
+ // phone is narrow but tall, desktop is wide but short → min of each axis
42
+ const phone = { cols: 51, rows: 60 };
43
+ const desktop = { cols: 200, rows: 50 };
44
+ expect(negotiateSize([phone, desktop])).toEqual({ cols: 51, rows: 50 });
45
+ });
46
+ it("clamps to the floor so a tiny viewer can't wedge the agent", () => {
47
+ expect(negotiateSize([{ cols: 20, rows: 5 }])).toEqual({
48
+ cols: NEGO_FLOOR_COLS,
49
+ rows: NEGO_FLOOR_ROWS,
50
+ });
51
+ });
52
+ it("three viewers: min wins per axis", () => {
53
+ expect(
54
+ negotiateSize([
55
+ { cols: 100, rows: 30 },
56
+ { cols: 80, rows: 50 },
57
+ { cols: 120, rows: 24 },
58
+ ]),
59
+ ).toEqual({ cols: 80, rows: 24 });
60
+ });
61
+ });
package/ts/sizeNego.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Multi-viewer PTY size negotiation — the tmux "smallest client wins" rule.
3
+ *
4
+ * Every writable web viewer reports its readable capacity (the cols×rows its
5
+ * pane can render at its chosen font size) via /api/presence. The host resizes
6
+ * the agent's PTY to the elementwise minimum across live viewers, so the
7
+ * narrowest screen (a phone) gets a grid it can actually read while wider
8
+ * viewers simply render fewer columns. When the last capacity-reporting viewer
9
+ * leaves, the negotiated size is withdrawn and the PTY falls back to the real
10
+ * terminal's size (the wrapper re-reads the tty once the winsize file is gone).
11
+ *
12
+ * Pure logic only — the serve layer owns presence bookkeeping, the winsize
13
+ * file, and SIGWINCH delivery.
14
+ */
15
+
16
+ export interface SizeCap {
17
+ cols: number;
18
+ rows: number;
19
+ }
20
+
21
+ /** Reject junk caps (a viewer mid-layout can report 0×0 or absurd numbers). */
22
+ const CAP_MIN_COLS = 20;
23
+ const CAP_MIN_ROWS = 5;
24
+ const CAP_MAX_COLS = 500;
25
+ const CAP_MAX_ROWS = 200;
26
+
27
+ /** Never negotiate below this — TUIs (claude, codex) break down when the grid
28
+ * gets absurdly narrow, so a tiny watch-sized viewer can't wedge the agent. */
29
+ export const NEGO_FLOOR_COLS = 40;
30
+ export const NEGO_FLOOR_ROWS = 10;
31
+
32
+ /** Parse a presence-reported cap into a sane SizeCap, or null if unusable. */
33
+ export function sanitizeCap(cap: unknown): SizeCap | null {
34
+ if (typeof cap !== "object" || cap === null) return null;
35
+ const c = Math.floor(Number((cap as SizeCap).cols) || 0);
36
+ const r = Math.floor(Number((cap as SizeCap).rows) || 0);
37
+ if (c < CAP_MIN_COLS || c > CAP_MAX_COLS) return null;
38
+ if (r < CAP_MIN_ROWS || r > CAP_MAX_ROWS) return null;
39
+ return { cols: c, rows: r };
40
+ }
41
+
42
+ /**
43
+ * Elementwise minimum over the live viewers' capacities, clamped to the floor.
44
+ * Returns null when no viewer reports a capacity — meaning "withdraw the
45
+ * negotiated size, let the real tty size rule again".
46
+ */
47
+ export function negotiateSize(caps: ReadonlyArray<SizeCap>): SizeCap | null {
48
+ let cols = Infinity;
49
+ let rows = Infinity;
50
+ for (const cap of caps) {
51
+ if (cap.cols < cols) cols = cap.cols;
52
+ if (cap.rows < rows) rows = cap.rows;
53
+ }
54
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return null;
55
+ return {
56
+ cols: Math.max(NEGO_FLOOR_COLS, cols),
57
+ rows: Math.max(NEGO_FLOOR_ROWS, rows),
58
+ };
59
+ }
package/ts/ws.spec.ts CHANGED
@@ -468,16 +468,14 @@ describe("collectWorkspaces / workspaceStatus (mocked provision)", () => {
468
468
  beforeEach(() => {
469
469
  root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "ay-ws-api-")));
470
470
  prov.wsRoot = root;
471
- prov.readStatus
472
- .mockReset()
473
- .mockResolvedValue({
474
- branch: "main",
475
- head: "abc123",
476
- ahead: 1,
477
- behind: 0,
478
- dirty: true,
479
- hasUpstream: true,
480
- });
471
+ prov.readStatus.mockReset().mockResolvedValue({
472
+ branch: "main",
473
+ head: "abc123",
474
+ ahead: 1,
475
+ behind: 0,
476
+ dirty: true,
477
+ hasUpstream: true,
478
+ });
481
479
  homeBackup = process.env.AGENT_YES_HOME;
482
480
  process.env.AGENT_YES_HOME = path.join(root, ".ay-home");
483
481
  });
@@ -1,9 +0,0 @@
1
- import "./ts-DnqRreF4.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-d5c6_vgS.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-DQSgURGW.js";
8
-
9
- export { SUPPORTED_CLIS };