agent-yes 1.206.0 → 1.208.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/lab/ui/index.html CHANGED
@@ -695,14 +695,14 @@
695
695
  align-items: center;
696
696
  gap: 8px;
697
697
  }
698
- /* Peers badge beside the agent counter how many other humans are in the
699
- fleet right now. Yellow ("here") when a peer shares the agent you have
700
- open, muted otherwise. Hidden entirely when you're alone. */
701
- .peers {
702
- color: var(--muted);
698
+ /* The single consolidated fleet summary: "N/M agents · R rooms · 👤 P".
699
+ Rooms/peers segments appear only when there's more than one room / any
700
+ peer, so a solo fleet just reads "N/M agents". Turns yellow ("here") when
701
+ another peer is watching the agent you have open. */
702
+ .count {
703
703
  cursor: default;
704
704
  }
705
- .peers.here {
705
+ .count.here {
706
706
  color: var(--peer);
707
707
  }
708
708
  .connbadge {
@@ -1851,20 +1851,11 @@
1851
1851
  </div>
1852
1852
  <div class="meta">
1853
1853
  <span class="metaleft">
1854
- <span id="count"></span>
1855
- <span id="peers" class="peers" hidden></span>
1854
+ <span id="count" class="count"></span>
1856
1855
  </span>
1857
1856
  <span class="metaright">
1858
1857
  <button id="foldbtn" class="viewbtn" title="fold subagent trees">⊞ subs</button>
1859
1858
  <button id="sortbtn" class="viewbtn" title="cycle sort order">⇅ state</button>
1860
- <button
1861
- id="viewbtn"
1862
- class="viewbtn"
1863
- title="toggle compact list"
1864
- aria-label="Toggle compact list view"
1865
- >
1866
-
1867
- </button>
1868
1859
  <button id="portsbtn" class="viewbtn" title="manage exposed localhost ports">
1869
1860
  ⇄ ports
1870
1861
  </button>
@@ -2173,7 +2164,7 @@
2173
2164
  selFromBottom,
2174
2165
  parseSel,
2175
2166
  selSegments,
2176
- fitTransform,
2167
+ fitTransformCentered,
2177
2168
  docTitle,
2178
2169
  omniScore,
2179
2170
  } from "./console-logic.js";
@@ -2192,6 +2183,7 @@
2192
2183
  // row keeps pulsing to. See advanceStdinFlashes/focusSummary (console-logic).
2193
2184
  let focusByKey = new Map();
2194
2185
  let peerTotal = 0;
2186
+ let shownAgentCount = 0; // agents matching the current filter (for the summary badge)
2195
2187
  const stdinSeen = new Map();
2196
2188
  const flashUntil = new Map();
2197
2189
  const STDIN_FLASH_MS = 1200;
@@ -2230,7 +2222,15 @@
2230
2222
  } catch {}
2231
2223
  if (term) {
2232
2224
  term.options.fontSize = termFontSize;
2233
- if (fit)
2225
+ // Negotiating host: the grid is canonical (host-owned) — a font change
2226
+ // here only alters our glyph px, so re-letterbox and report the new
2227
+ // capacity; the host reflows the PTY if our readable cols/rows moved.
2228
+ // This is what makes font size a ZOOM control on mobile: bigger font →
2229
+ // fewer readable cells → the PTY itself shrinks → bigger crisp text.
2230
+ if (negoActive) {
2231
+ applyCanvasScale();
2232
+ sendPresence();
2233
+ } else if (fit)
2234
2234
  try {
2235
2235
  fit.fit();
2236
2236
  } catch {}
@@ -3132,6 +3132,13 @@
3132
3132
  // short lease after that, the heartbeat won't follow/override — so our own
3133
3133
  // push isn't fought while the agent's /api/size is still catching up.
3134
3134
  let lastDroveAt = 0;
3135
+ // The selected agent's host negotiates the PTY size from viewer
3136
+ // capacities (its /api/size responses carry nego:true). On such hosts we
3137
+ // never push /api/resize — we report our readable capacity in presence
3138
+ // (sendPresence cap) and follow the negotiated canonical grid, so a phone
3139
+ // and a desktop watching the same agent converge on the smallest
3140
+ // viewer's readable size instead of fighting last-writer-wins.
3141
+ let negoActive = false;
3135
3142
 
3136
3143
  function isDarkColor(c) {
3137
3144
  try {
@@ -3277,6 +3284,18 @@
3277
3284
  const s = term.getSelectionPosition && term.getSelectionPosition();
3278
3285
  selR = selFromBottom(s, term.buffer.active.length);
3279
3286
  } catch {}
3287
+ // Our readable capacity: the grid our pane fits at the current font —
3288
+ // measured from the container (transform-independent), NOT term.cols
3289
+ // (which may be a foreign canonical grid we're letterboxing). The host
3290
+ // negotiates the PTY to the min cap across viewers; read-only viewers
3291
+ // report none (they can't influence the PTY). Older hosts ignore it.
3292
+ let cap = null;
3293
+ try {
3294
+ if (!isReadonlyEnt(cur.e) && fit) {
3295
+ const d = fit.proposeDimensions();
3296
+ if (d && d.cols > 0 && d.rows > 0) cap = { cols: d.cols, rows: d.rows };
3297
+ }
3298
+ } catch {}
3280
3299
  cur.tx
3281
3300
  .post("/api/presence", {
3282
3301
  viewer: myViewerId,
@@ -3284,6 +3303,7 @@
3284
3303
  cols: term.cols,
3285
3304
  rows: term.rows,
3286
3305
  sel: selR,
3306
+ cap,
3287
3307
  })
3288
3308
  .catch(() => {});
3289
3309
  }
@@ -3333,29 +3353,34 @@
3333
3353
  const sum = focusSummary(recs);
3334
3354
  focusByKey = sum.byKey;
3335
3355
  peerTotal = sum.total;
3336
- paintPeersBadge();
3356
+ updateSummary();
3337
3357
  renderList();
3338
3358
  }
3339
- // "N peers" badge beside the agent counter. Yellow when a peer shares the
3340
- // agent you have open ("same agent"); its tooltip breaks the count down by
3341
- // agent so you can see who's where. Hidden when you're the only one here.
3342
- function paintPeersBadge() {
3343
- const el = $("peers");
3359
+ // The ONE consolidated fleet summary in the header, replacing the separate
3360
+ // agent-count and "N peers" badges: "<shown>/<total> agents" plus, only when
3361
+ // relevant, R rooms" (multi-room fleets) and 👤 P" (other humans here).
3362
+ // Yellow ("here") when a peer shares the agent you have open; the tooltip
3363
+ // breaks the peer count down by agent so you can see who's where. Called both
3364
+ // from renderList (agent list changed) and on presence updates (peers).
3365
+ function updateSummary() {
3366
+ const el = $("count");
3344
3367
  if (!el) return;
3345
- if (!peerTotal) {
3346
- el.hidden = true;
3368
+ const rooms = new Set(entries.filter((e) => e._room).map((e) => e._room)).size;
3369
+ let s = `${shownAgentCount} / ${entries.length} agents`;
3370
+ if (rooms > 1) s += ` · ${rooms} rooms`;
3371
+ if (peerTotal > 0) s += ` · 👤 ${peerTotal}`;
3372
+ el.textContent = s;
3373
+ el.classList.toggle("here", !!(sel && focusByKey.get(sel)));
3374
+ if (peerTotal > 0) {
3375
+ const lines = [...focusByKey.entries()].map(([k, n]) => {
3376
+ const e = entries.find((x) => x._key === k);
3377
+ const name = e ? e.title || cliLabel(e) || ident(e) || `pid ${e.pid}` : k;
3378
+ return ` ${n} on ${name}`;
3379
+ });
3380
+ el.title = "peers watching agents in this fleet:\n" + lines.join("\n");
3381
+ } else {
3347
3382
  el.removeAttribute("title");
3348
- return;
3349
3383
  }
3350
- el.hidden = false;
3351
- el.textContent = `👤 ${peerTotal} peer${peerTotal === 1 ? "" : "s"}`;
3352
- el.classList.toggle("here", !!(sel && focusByKey.get(sel)));
3353
- const lines = [...focusByKey.entries()].map(([k, n]) => {
3354
- const e = entries.find((x) => x._key === k);
3355
- const name = e ? e.title || cliLabel(e) || ident(e) || `pid ${e.pid}` : k;
3356
- return ` ${n} on ${name}`;
3357
- });
3358
- el.title = "peers watching agents in this fleet:\n" + lines.join("\n");
3359
3384
  }
3360
3385
  // 3s heartbeat (< the host's 12s TTL): refresh our presence + read others',
3361
3386
  // and follow the canonical grid size (resize+scale, never reflow) so all
@@ -3600,14 +3625,36 @@
3600
3625
  const cs = getComputedStyle(logEl);
3601
3626
  const pw = logEl.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
3602
3627
  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);
3628
+ // fitTransformCentered (console-logic.js, unit-tested) decides
3629
+ // none-vs-scale and centers the letterboxed grid in the pane (fit
3630
+ // width → vertically centered, fit height → horizontally). The peer
3631
+ // overlay reads the transformed screen rect back via
3632
+ // getBoundingClientRect, so the translate needs no extra bookkeeping.
3633
+ xt.style.transform = fitTransformCentered(gw, gh, pw, ph).transform;
3605
3634
  } catch {}
3606
3635
  }
3636
+ // Pane geometry changed (window resize, splitter drag, soft keyboard, tab
3637
+ // refocus): on legacy hosts re-fit the grid to the pane (the size push
3638
+ // rides term.onResize → pushSize); on negotiating hosts the grid is
3639
+ // canonical — never reflow it locally, just re-letterbox and report the
3640
+ // new capacity so the host can move the min if needed.
3641
+ function refitPane() {
3642
+ if (!term) return;
3643
+ if (negoActive) {
3644
+ applyCanvasScale();
3645
+ sendPresence();
3646
+ return;
3647
+ }
3648
+ if (fit)
3649
+ try {
3650
+ fit.fit();
3651
+ } catch {}
3652
+ }
3607
3653
  // Follow the canonical grid: resize our xterm to the agent's size (set by the
3608
3654
  // active driver / local owner) WITHOUT reflowing to our pane, then scale to
3609
3655
  // fit. No-op when we already match (we're the driver, or sizes agree).
3610
3656
  function followAgentSize(sz) {
3657
+ if (sz && sz.nego != null) negoActive = !!sz.nego;
3611
3658
  if (!term || !sz || !sz.cols || !sz.rows) return;
3612
3659
  // While our own recent push is still settling, don't fight it.
3613
3660
  if (Date.now() - lastDroveAt < 6000) {
@@ -4654,8 +4701,9 @@
4654
4701
  s.streaming = false;
4655
4702
  }
4656
4703
 
4657
- // Compact list: one line per agent (dot + cli + title), persisted per device.
4658
- let compactList = localStorage.getItem("ay.compactList") === "1";
4704
+ // Compact list is now the ONLY list view (one line per agent: dot + cli +
4705
+ // title). The toggle button was removed — compact is always on.
4706
+ const compactList = true;
4659
4707
 
4660
4708
  // Fold subagent trees: hide nested (sub)agent rows and roll each root's
4661
4709
  // hidden descendants into a summary chip. Folded BY DEFAULT (only "0" opts
@@ -4844,11 +4892,10 @@
4844
4892
  const rows = foldSubs
4845
4893
  ? fullRows.filter((r) => !(r.kind === "agent" && r.parentEntry))
4846
4894
  : fullRows;
4847
- $("count").textContent = `${agentRows.length} / ${entries.length} agents`;
4848
- paintPeersBadge(); // keep the peers badge in step with sel / the entry list
4895
+ shownAgentCount = agentRows.length;
4896
+ updateSummary(); // one consolidated "agents · rooms · peers" badge
4849
4897
  $("foldbtn").textContent = foldSubs ? "⊞ subs" : "⊟ subs";
4850
4898
  $("foldbtn").classList.toggle("on", !foldSubs);
4851
- $("viewbtn").classList.toggle("on", compactList);
4852
4899
  // Show the active sort mode on the button so it's self-documenting; the
4853
4900
  // default ("state") is also highlighted to hint it's the implicit order.
4854
4901
  $("sortbtn").textContent = `⇅ ${sortMode}`;
@@ -4985,25 +5032,22 @@
4985
5032
  // the new mtime, so a silent `ay send` still trips the flash within ~1s.
4986
5033
  const until = Date.now() + STDIN_FLASH_MS;
4987
5034
  for (const k of advanceStdinFlashes(entries, stdinSeen)) flashUntil.set(k, until);
4988
- // Badge: total agents + how many rooms are live. Red only when nothing
4989
- // at all is reachable (no source answered).
4990
- // Count ROOMS, not machines a codehost room with three machines is still
4991
- // one room, and it can be connecting before any machine has appeared.
5035
+ // Connection-status badge ONLY (dot + state). The agent/room/peer COUNTS
5036
+ // live in one place now the #count summary on the left — so this badge no
5037
+ // longer repeats them; it just shows liveness and stays the rooms-manager
5038
+ // click target (see its title). Red only when nothing is reachable.
4992
5039
  const roomSrcs = srcs.filter((s) => s.id !== LOCAL);
4993
- const liveRooms = new Set(roomSrcs.filter((s) => s.live).map((s) => s.room || s.id)).size;
4994
5040
  const anyLive = srcs.some((s) => s.live);
4995
5041
  const connecting =
4996
5042
  roomSrcs.some((s) => !s.tried) || [...chRooms.values()].some((r) => !r.tried);
4997
5043
  const roomCount = roomSrcs.length || chRooms.size;
4998
- const n = entries.length;
4999
5044
  if (!srcs.length && !chRooms.size) {
5000
5045
  setConn("● no fleet", "var(--muted)");
5001
5046
  } else if (!anyLive) {
5002
5047
  if (connecting) setConn("● connecting…", "var(--amber)");
5003
5048
  else setConn(roomCount ? "● rooms offline" : "● ay serve down", "var(--red)");
5004
5049
  } else {
5005
- const roomBit = liveRooms ? ` · ${liveRooms} room${liveRooms === 1 ? "" : "s"}` : "";
5006
- setConn(`● ${n} agent${n === 1 ? "" : "s"}${roomBit}`, "var(--green)");
5050
+ setConn(" live", "var(--green)");
5007
5051
  }
5008
5052
  renderRoomsIfOpen();
5009
5053
  renderList();
@@ -5187,6 +5231,14 @@
5187
5231
  applyCanvasScale();
5188
5232
  return;
5189
5233
  }
5234
+ // Negotiating host: the PTY size is the host's min over viewer
5235
+ // capacities, not ours to push. Re-letterbox and refresh our cap
5236
+ // (the pane may have changed) — the host resizes if the min moved.
5237
+ if (negoActive) {
5238
+ applyCanvasScale();
5239
+ sendPresence();
5240
+ return;
5241
+ }
5190
5242
  lastDroveAt = Date.now(); // we're driving the size now (lease the grid)
5191
5243
  tx.post("/api/resize/" + encodeURIComponent(pid), {
5192
5244
  cols: term.cols,
@@ -5227,6 +5279,7 @@
5227
5279
  const selKey = e._key;
5228
5280
  const fitAndSync = (sz) => {
5229
5281
  if (sel !== selKey || !term) return;
5282
+ negoActive = !!(sz && sz.nego);
5230
5283
  if (sz && sz.cols && sz.rows) agentSize = sz; // remember for the badge tooltip
5231
5284
  // Read-only viewer: don't reflow the agent to our pane (we can't resize
5232
5285
  // it, and shouldn't). Adopt the AGENT's own grid and CSS-scale it to fit,
@@ -5238,6 +5291,22 @@
5238
5291
  suppressPush = false;
5239
5292
  return;
5240
5293
  }
5294
+ // Negotiating host: adopt the canonical (negotiated) grid like a
5295
+ // watcher and report our capacity via presence — the host takes the
5296
+ // min over all viewers and resizes the PTY itself. No direct push, so
5297
+ // two viewers can't fight over the size.
5298
+ if (negoActive) {
5299
+ if (sz.cols && sz.rows) followAgentSize(sz);
5300
+ else {
5301
+ // agent has no size on record yet — seed from our own fit
5302
+ try {
5303
+ fit.fit();
5304
+ } catch {}
5305
+ }
5306
+ suppressPush = false;
5307
+ sendPresence(); // cap → host negotiates → size event brings the grid
5308
+ return;
5309
+ }
5241
5310
  try {
5242
5311
  fit.fit();
5243
5312
  } catch {}
@@ -5381,11 +5450,6 @@
5381
5450
  } catch {}
5382
5451
  renderList();
5383
5452
  });
5384
- $("viewbtn").addEventListener("click", () => {
5385
- compactList = !compactList;
5386
- localStorage.setItem("ay.compactList", compactList ? "1" : "0");
5387
- renderList();
5388
- });
5389
5453
  // Fold / unfold every subagent tree (global toggle, persisted per device).
5390
5454
  $("foldbtn").addEventListener("click", () => {
5391
5455
  foldSubs = !foldSubs;
@@ -5413,13 +5477,9 @@
5413
5477
  renderList();
5414
5478
  });
5415
5479
  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 {}
5480
+ // Resizing our window is strong intent → re-fit / re-report capacity
5481
+ // (refitPane picks the legacy-push vs negotiate path).
5482
+ refitPane();
5423
5483
  applyCanvasScale();
5424
5484
  renderPeerSelections(); // cell size changed → reposition peer-selection overlays
5425
5485
  });
@@ -5434,15 +5494,76 @@
5434
5494
  const onVV = () => {
5435
5495
  const kb = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
5436
5496
  document.documentElement.style.setProperty("--kb", kb + "px");
5437
- if (fit)
5438
- try {
5439
- fit.fit();
5440
- } catch {}
5497
+ refitPane();
5441
5498
  };
5442
5499
  vv.addEventListener("resize", onVV);
5443
5500
  vv.addEventListener("scroll", onVV);
5444
5501
  onVV();
5445
5502
  }
5503
+ // ── mobile pinch on the terminal = terminal zoom, not page zoom ─────────
5504
+ // Two fingers over the log pane adjust the terminal FONT SIZE. On a
5505
+ // negotiating host that reflows the PTY itself (bigger font → fewer
5506
+ // readable cells → the host shrinks the grid → bigger crisp text; smaller
5507
+ // font → more rows/cols), which is what a phone user actually means by
5508
+ // pinch: "show me more / make it readable", with the surrounding UI
5509
+ // staying put. Only 2-touch gestures over .log are intercepted — 1-finger
5510
+ // scroll/selection passes to xterm, and the page keeps its native
5511
+ // pinch-zoom everywhere else (an a11y requirement, WCAG 1.4.4).
5512
+ (function () {
5513
+ const logEl = $("log");
5514
+ if (!logEl) return;
5515
+ let startDist = 0; // 0 = no pinch in flight
5516
+ let startFont = 0;
5517
+ let lastRatio = 1;
5518
+ let baseTransform = "";
5519
+ const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
5520
+ logEl.addEventListener(
5521
+ "touchstart",
5522
+ (ev) => {
5523
+ if (ev.touches.length !== 2) return;
5524
+ ev.preventDefault(); // this gesture is ours — no page zoom
5525
+ startDist = dist(ev.touches);
5526
+ startFont = termFontSize;
5527
+ lastRatio = 1;
5528
+ const xt = logEl.querySelector(".xterm");
5529
+ baseTransform = xt ? xt.style.transform || "none" : "none";
5530
+ },
5531
+ { passive: false },
5532
+ );
5533
+ logEl.addEventListener(
5534
+ "touchmove",
5535
+ (ev) => {
5536
+ if (ev.touches.length !== 2 || !startDist) return;
5537
+ ev.preventDefault();
5538
+ lastRatio = dist(ev.touches) / startDist;
5539
+ // Live preview: compose the pinch onto the fitted transform. The
5540
+ // grid truly reflows only when the negotiated size lands after the
5541
+ // gesture commits — a brief settle, traded for zero SIGWINCH storm
5542
+ // while the fingers are still moving.
5543
+ const xt = logEl.querySelector(".xterm");
5544
+ if (xt)
5545
+ xt.style.transform =
5546
+ `scale(${lastRatio.toFixed(3)})` +
5547
+ (baseTransform === "none" ? "" : " " + baseTransform);
5548
+ },
5549
+ { passive: false },
5550
+ );
5551
+ const endPinch = () => {
5552
+ if (!startDist) return;
5553
+ const target = Math.round(startFont * lastRatio);
5554
+ startDist = 0;
5555
+ if (target !== termFontSize) setTermFontSize(target); // nego: cap → host reflows
5556
+ applyCanvasScale(); // clear the preview; re-letterbox at the real grid
5557
+ };
5558
+ logEl.addEventListener("touchend", (ev) => {
5559
+ if (ev.touches.length < 2) endPinch();
5560
+ });
5561
+ logEl.addEventListener("touchcancel", () => endPinch());
5562
+ // Safari's proprietary gesture events fire for pinch regardless of
5563
+ // touch-action; swallow them over the terminal so the page never zooms.
5564
+ for (const t of ["gesturestart", "gesturechange", "gestureend"])
5565
+ logEl.addEventListener(t, (ev) => ev.preventDefault());
5566
+ })();
5446
5567
 
5447
5568
  // Step the selection up/down the (filtered) list — same order the left panel
5448
5569
  // renders. Clamps at the ends, scrolls the row into view.
@@ -6983,11 +7104,7 @@
6983
7104
  // terminal of a different size) that re-push fights the local terminal
6984
7105
  // and reflows the stream on every tab switch — the glitch the eager
6985
7106
  // per-focus resync introduced. The one-shot adopt still runs on select.
6986
- if (term && fit) {
6987
- try {
6988
- fit.fit();
6989
- } catch {}
6990
- }
7107
+ refitPane();
6991
7108
  }
6992
7109
  });
6993
7110
  watchVersion();
@@ -7039,10 +7156,7 @@
7039
7156
  if (!dragging) return;
7040
7157
  const w = Math.min(Math.max(e.clientX, 280), window.innerWidth - 360);
7041
7158
  app.style.setProperty("--leftw", w + "px");
7042
- if (fit)
7043
- try {
7044
- fit.fit();
7045
- } catch {}
7159
+ refitPane();
7046
7160
  });
7047
7161
  const end = (e) => {
7048
7162
  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.208.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,91 @@ 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(
1889
+ process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes"),
1890
+ "winsize",
1891
+ String(pid),
1892
+ );
1893
+ const liveCapsFor = (pid: number): SizeCap[] => {
1894
+ const now = Date.now();
1895
+ const caps: SizeCap[] = [];
1896
+ for (const v of presence.values())
1897
+ if (now - v.ts <= PRESENCE_TTL_MS && String(v.agent) === String(pid) && v.cap)
1898
+ caps.push(v.cap);
1899
+ return caps;
1900
+ };
1901
+ const applyNego = async (pid: number) => {
1902
+ negoTimers.delete(pid);
1903
+ const eff = negotiateSize(liveCapsFor(pid));
1904
+ const file = winsizePathFor(pid);
1905
+ const prev = negoApplied.get(pid);
1906
+ try {
1907
+ if (eff) {
1908
+ if (prev && prev.cols === eff.cols && prev.rows === eff.rows) return;
1909
+ const content = `${eff.cols} ${eff.rows} ${Date.now()}\n`;
1910
+ await mkdir(path.dirname(file), { recursive: true });
1911
+ await writeFile(file, content);
1912
+ negoApplied.set(pid, { ...eff, content });
1913
+ } else {
1914
+ if (!prev) return;
1915
+ negoApplied.delete(pid);
1916
+ try {
1917
+ // withdraw only what we wrote — a different content means ay attach
1918
+ // (or a direct /api/resize) owns the size now; leave it alone.
1919
+ if ((await readFile(file, "utf-8")) !== prev.content) return;
1920
+ await unlink(file);
1921
+ } catch {
1922
+ return; // already gone — nothing to signal
1923
+ }
1924
+ }
1925
+ try {
1926
+ process.kill(pid, "SIGWINCH");
1927
+ } catch {
1928
+ /* agent gone */
1929
+ }
1930
+ } catch {
1931
+ /* best-effort: a failed write just leaves the previous size in place */
1932
+ }
1933
+ };
1934
+ const scheduleNego = (pid: number) => {
1935
+ if (!Number.isFinite(pid) || pid <= 0 || negoTimers.has(pid)) return;
1936
+ negoTimers.set(
1937
+ pid,
1938
+ setTimeout(() => void applyNego(pid), NEGO_DEBOUNCE_MS),
1939
+ );
1940
+ };
1941
+ // TTL grow-back sweep: a viewer that vanished without clearing its presence
1942
+ // (tab killed, phone locked) stops refreshing; once its entry expires the
1943
+ // negotiated size must be recomputed even though no presence POST arrives.
1944
+ setInterval(() => {
1945
+ for (const pid of negoApplied.keys()) scheduleNego(pid);
1946
+ }, 5_000);
1947
+
1857
1948
  // The whole API as a plain handler: served over HTTP by Bun.serve (--http)
1858
1949
  // and called in-process by the WebRTC bridge (--webrtc) — the latter needs
1859
1950
  // no TCP port at all.
@@ -2330,6 +2421,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
2330
2421
  pid: record.pid,
2331
2422
  cols: size?.cols ?? null,
2332
2423
  rows: size?.rows ?? null,
2424
+ // This host negotiates the PTY size from viewer capacities (presence
2425
+ // `cap`) — a capable console should report its cap instead of pushing
2426
+ // /api/resize directly, so viewers stop fighting last-writer-wins.
2427
+ nego: true,
2333
2428
  });
2334
2429
  } catch (e) {
2335
2430
  return new Response((e as Error).message, { status: 404 });
@@ -2735,6 +2830,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
2735
2830
  cols?: number;
2736
2831
  rows?: number;
2737
2832
  sel?: string;
2833
+ cap?: { cols?: number; rows?: number };
2738
2834
  };
2739
2835
  try {
2740
2836
  b = (await req.json()) as typeof b;
@@ -2743,6 +2839,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
2743
2839
  }
2744
2840
  const viewer = String(b.viewer ?? "").slice(0, 64);
2745
2841
  if (!viewer) return new Response("missing viewer", { status: 400 });
2842
+ // The agent this viewer WAS on — if the entry moves (agent switch) or
2843
+ // clears, that agent's negotiated size must be recomputed (grow-back).
2844
+ const prevAgent = presence.get(viewer)?.agent;
2746
2845
  if (b.agent == null) presence.delete(viewer);
2747
2846
  else
2748
2847
  presence.set(viewer, {
@@ -2751,8 +2850,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
2751
2850
  cols: Math.max(0, Math.floor(Number(b.cols) || 0)),
2752
2851
  rows: Math.max(0, Math.floor(Number(b.rows) || 0)),
2753
2852
  sel: typeof b.sel === "string" ? b.sel.slice(0, 200) : null,
2853
+ cap: sanitizeCap(b.cap),
2754
2854
  ts: Date.now(),
2755
2855
  });
2856
+ if (b.agent != null) scheduleNego(Number(b.agent));
2857
+ if (prevAgent != null && String(prevAgent) !== String(b.agent ?? ""))
2858
+ scheduleNego(Number(prevAgent));
2756
2859
  return new Response(null, { status: 204 });
2757
2860
  }
2758
2861
  // GET /api/presence — all live viewers (TTL-pruned), for "who's watching".