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.
@@ -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,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NEGO_FLOOR_COLS, NEGO_FLOOR_ROWS, negotiateSize, sanitizeCap } from "./sizeNego.ts";
3
+
4
+ describe("sanitizeCap", () => {
5
+ it("accepts a normal viewer capacity", () => {
6
+ expect(sanitizeCap({ cols: 80, rows: 24 })).toEqual({ cols: 80, rows: 24 });
7
+ });
8
+ it("floors fractional values", () => {
9
+ expect(sanitizeCap({ cols: 80.9, rows: 24.7 })).toEqual({ cols: 80, rows: 24 });
10
+ });
11
+ it("rejects null / non-object / missing fields", () => {
12
+ expect(sanitizeCap(null)).toBeNull();
13
+ expect(sanitizeCap(undefined)).toBeNull();
14
+ expect(sanitizeCap("80x24")).toBeNull();
15
+ expect(sanitizeCap({})).toBeNull();
16
+ expect(sanitizeCap({ cols: 80 })).toBeNull();
17
+ });
18
+ it("rejects out-of-range junk (mid-layout 0x0, absurd sizes)", () => {
19
+ expect(sanitizeCap({ cols: 0, rows: 0 })).toBeNull();
20
+ expect(sanitizeCap({ cols: 10, rows: 24 })).toBeNull(); // below min cols
21
+ expect(sanitizeCap({ cols: 80, rows: 2 })).toBeNull(); // below min rows
22
+ expect(sanitizeCap({ cols: 9999, rows: 24 })).toBeNull();
23
+ expect(sanitizeCap({ cols: 80, rows: 9999 })).toBeNull();
24
+ expect(sanitizeCap({ cols: NaN, rows: 24 })).toBeNull();
25
+ });
26
+ });
27
+
28
+ describe("negotiateSize", () => {
29
+ it("returns null with no caps (withdraw → tty size rules)", () => {
30
+ expect(negotiateSize([])).toBeNull();
31
+ });
32
+ it("single viewer: adopts its capacity", () => {
33
+ expect(negotiateSize([{ cols: 133, rows: 40 }])).toEqual({ cols: 133, rows: 40 });
34
+ });
35
+ it("phone + desktop: elementwise min (the tmux rule)", () => {
36
+ // phone is narrow but tall, desktop is wide but short → min of each axis
37
+ const phone = { cols: 51, rows: 60 };
38
+ const desktop = { cols: 200, rows: 50 };
39
+ expect(negotiateSize([phone, desktop])).toEqual({ cols: 51, rows: 50 });
40
+ });
41
+ it("clamps to the floor so a tiny viewer can't wedge the agent", () => {
42
+ expect(negotiateSize([{ cols: 20, rows: 5 }])).toEqual({
43
+ cols: NEGO_FLOOR_COLS,
44
+ rows: NEGO_FLOOR_ROWS,
45
+ });
46
+ });
47
+ it("three viewers: min wins per axis", () => {
48
+ expect(
49
+ negotiateSize([
50
+ { cols: 100, rows: 30 },
51
+ { cols: 80, rows: 50 },
52
+ { cols: 120, rows: 24 },
53
+ ]),
54
+ ).toEqual({ cols: 80, rows: 24 });
55
+ });
56
+ });
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 };