agent-yes 1.205.0 → 1.207.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +2 -0
  2. package/dist/{SUPPORTED_CLIS-C8-qh98a.js → SUPPORTED_CLIS-B1CDJwYn.js} +2 -2
  3. package/dist/SUPPORTED_CLIS-vH8hvcZr.js +9 -0
  4. package/dist/{agentShare-99IGGOkv.js → agentShare-DdEbHtAn.js} +3 -3
  5. package/dist/cli.js +4 -4
  6. package/dist/index.js +2 -2
  7. package/dist/{notifyDaemon-DK1HOMjj.js → notifyDaemon-DivHVzZx.js} +2 -2
  8. package/dist/{rustBinary-Tgn948GR.js → rustBinary-CuBIr-zX.js} +2 -2
  9. package/dist/{schedule-CBoM67Vy.js → schedule-Bg_CDt2j.js} +4 -4
  10. package/dist/{serve-IvOlaL-7.js → serve-B-ZCy67S.js} +258 -41
  11. package/dist/{setup-BaKdS-3i.js → setup-D6UBzk4-.js} +2 -2
  12. package/dist/{share-Ciw1mWVN.js → share-_QYjPN7q.js} +1 -1
  13. package/dist/{share-BfIU8t_h.js → share-vchIyVd8.js} +98 -5
  14. package/dist/{subcommands-Bs7wJRBB.js → subcommands-6AQ8egRn.js} +7 -7
  15. package/dist/{subcommands-DyDHZ5YI.js → subcommands-CeRH9-_h.js} +1 -1
  16. package/dist/{ts-Dm6jjLwC.js → ts-DZWZvbLM.js} +2 -2
  17. package/dist/{versionChecker-BomfUpSo.js → versionChecker-C2CV9VOX.js} +2 -2
  18. package/dist/{ws-CSkP2mkD.js → ws-CGNmxCPD.js} +2 -2
  19. package/lab/ui/console-logic.js +23 -1
  20. package/lab/ui/index.html +188 -29
  21. package/package.json +1 -1
  22. package/ts/serve.spec.ts +13 -1
  23. package/ts/serve.ts +263 -34
  24. package/ts/serveLock.spec.ts +15 -14
  25. package/ts/share.ts +80 -1
  26. package/ts/sizeNego.spec.ts +61 -0
  27. package/ts/sizeNego.ts +59 -0
  28. package/ts/statusText.spec.ts +17 -0
  29. package/ts/statusText.ts +19 -0
  30. package/ts/ws.spec.ts +8 -10
  31. package/dist/SUPPORTED_CLIS-D3XTFfS7.js +0 -9
package/ts/share.ts CHANGED
@@ -66,10 +66,28 @@ const MAX_PEER_JOIN_QUEUE = 50;
66
66
  const IDLE_RESTART_UPTIME_MS = 25 * 60_000;
67
67
  const HARD_RESTART_UPTIME_MS = 45 * 60_000;
68
68
  const IDLE_RESTART_CHECK_MS = 60_000;
69
+ const PERF_SLOW_MS = Number(process.env.AGENT_YES_WEBRTC_PERF_SLOW_MS) || 750;
70
+ const PERF_BUFFERED_BYTES = Number(process.env.AGENT_YES_WEBRTC_PERF_BUFFERED_BYTES) || 1_000_000;
71
+ const PERF_VERBOSE = process.env.AGENT_YES_WEBRTC_PERF === "1";
69
72
 
70
73
  type IceServer = { urls: string | string[]; username?: string; credential?: string };
71
74
  const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
72
75
 
76
+ function perfLog(event: string, data: Record<string, unknown>, force = false) {
77
+ if (!force && !PERF_VERBOSE) return;
78
+ process.stderr.write(`[share:perf] ${JSON.stringify({ t: Date.now(), event, ...data })}\n`);
79
+ }
80
+
81
+ function maybeSlow(event: string, startedAt: number, data: Record<string, unknown> = {}) {
82
+ const ms = Math.round(performance.now() - startedAt);
83
+ perfLog(event, { ms, ...data }, ms >= PERF_SLOW_MS);
84
+ }
85
+
86
+ function dcBufferedAmount(dc: any): number {
87
+ const n = Number(dc?.bufferedAmount ?? 0);
88
+ return Number.isFinite(n) ? n : 0;
89
+ }
90
+
73
91
  // Short-lived Cloudflare TURN credentials, minted from a long-term TURN key, so
74
92
  // browsers can RELAY when a direct P2P path is impossible (symmetric NAT /
75
93
  // CGNAT — the main cause of "rooms offline"). Set CF_TURN_KEY_ID +
@@ -428,6 +446,7 @@ export async function startShare(
428
446
  if (closed) return; // a reconnect timer queued before close() must not revive it
429
447
  const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
430
448
  currentWs = ws;
449
+ const signalStartedAt = performance.now();
431
450
  let ready = false;
432
451
  let lastRecv = Date.now();
433
452
  let hb: ReturnType<typeof setInterval> | undefined;
@@ -443,6 +462,7 @@ export async function startShare(
443
462
  }
444
463
  };
445
464
  ws.onopen = () => {
465
+ maybeSlow("signal.open", signalStartedAt, { room, host });
446
466
  ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
447
467
  ready = true;
448
468
  lastRecv = Date.now();
@@ -480,6 +500,12 @@ export async function startShare(
480
500
  const m = JSON.parse(ev.data as string);
481
501
  if (m.type === "pong") return; // heartbeat ack — liveness already recorded
482
502
  if (m.type === "peer-join") {
503
+ perfLog("peer.join", {
504
+ room,
505
+ peer: String(m.peer),
506
+ queue: peerJoinQueue.length,
507
+ peers: peers.size,
508
+ });
483
509
  // Serialized in drainPeerJoins() to avoid a storm. Skip dupes (already
484
510
  // queued or already an active peer) and cap the queue so a pathological
485
511
  // burst can't grow it unboundedly — dropped joins retry via the browser.
@@ -554,6 +580,7 @@ export async function startShare(
554
580
  };
555
581
 
556
582
  async function startPeer(ws: WebSocket, peerId: string) {
583
+ const startedAt = performance.now();
557
584
  const iceServers = await getIceServers();
558
585
  const pc = new RTCPeerConnection({ iceServers });
559
586
  let resolveKeys!: () => void;
@@ -584,6 +611,11 @@ export async function startShare(
584
611
  dc.binaryType = "arraybuffer";
585
612
  dc.onopen = async () => {
586
613
  try {
614
+ maybeSlow("peer.dc_open", startedAt, {
615
+ room,
616
+ peer: peerId,
617
+ buffered: dcBufferedAmount(dc),
618
+ });
587
619
  await peer.keysReady; // keys derived in the answer handler
588
620
  // Open the mandatory bidirectional key-confirmation handshake. Nothing
589
621
  // the peer sends is acted on until BOTH directions confirm (see onFrame).
@@ -624,6 +656,7 @@ export async function startShare(
624
656
  ws.send(
625
657
  JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp, iceServers }),
626
658
  );
659
+ maybeSlow("peer.offer", startedAt, { room, peer: peerId, iceServers: iceServers.length });
627
660
  }
628
661
 
629
662
  function closePeer(peerId: string) {
@@ -642,8 +675,10 @@ export async function startShare(
642
675
  // Seal an envelope and send it, serialized per peer so the wire order matches
643
676
  // the nonce-counter order (so the receiver's monotonic check never trips).
644
677
  function enqueueSeal(peerId: string, dc: any, peer: Peer, flags: number, obj: object) {
678
+ const queuedAt = performance.now();
645
679
  peer.sendChain = peer.sendChain.then(async () => {
646
680
  if (dc.readyState !== "open" || !peer.keyH2C || !peer.th) return;
681
+ const queuedMs = Math.round(performance.now() - queuedAt);
647
682
  let frame: ArrayBuffer;
648
683
  try {
649
684
  frame = await e2eSeal(peer.keyH2C, peer.send, flags, peer.th, packEnvelope(obj));
@@ -653,6 +688,19 @@ export async function startShare(
653
688
  }
654
689
  try {
655
690
  dc.send(frame);
691
+ const buffered = dcBufferedAmount(dc);
692
+ perfLog(
693
+ "send",
694
+ {
695
+ room,
696
+ peer: peerId,
697
+ kind: (obj as any)?.t,
698
+ queuedMs,
699
+ buffered,
700
+ bytes: frame.byteLength,
701
+ },
702
+ queuedMs >= PERF_SLOW_MS || buffered >= PERF_BUFFERED_BYTES,
703
+ );
656
704
  } catch {
657
705
  /* peer vanished mid-send; dropping the frame is correct */
658
706
  }
@@ -703,6 +751,8 @@ export async function startShare(
703
751
  }
704
752
  if (req.t !== "req") return;
705
753
  const { id, method, path: p, body } = req;
754
+ const startedAt = performance.now();
755
+ perfLog("req.start", { room, peer: peerId, id, method, path: p });
706
756
  const ac = new AbortController();
707
757
  peer.aborts.set(id, ac);
708
758
  try {
@@ -718,6 +768,14 @@ export async function startShare(
718
768
  signal: ac.signal,
719
769
  }),
720
770
  );
771
+ maybeSlow("req.head", startedAt, {
772
+ room,
773
+ peer: peerId,
774
+ id,
775
+ method,
776
+ path: p,
777
+ status: res.status,
778
+ });
721
779
  enqueueSeal(peerId, dc, peer, 0, {
722
780
  t: "res",
723
781
  id,
@@ -727,9 +785,11 @@ export async function startShare(
727
785
  const reader = res.body!.getReader();
728
786
  const dec = new TextDecoder();
729
787
  let seq = 0;
788
+ let bytes = 0;
730
789
  for (;;) {
731
790
  const { done, value } = await reader.read();
732
791
  if (done) break;
792
+ bytes += value.byteLength;
733
793
  const text = dec.decode(value, { stream: true });
734
794
  // Slice on UTF-16 boundaries: JSON round-trips lone surrogates as \uXXXX,
735
795
  // so the receiver reassembles the exact text by concatenating in seq order.
@@ -742,13 +802,32 @@ export async function startShare(
742
802
  });
743
803
  }
744
804
  enqueueSeal(peerId, dc, peer, 0, { t: "end", id, seq });
805
+ maybeSlow("req.end", startedAt, {
806
+ room,
807
+ peer: peerId,
808
+ id,
809
+ method,
810
+ path: p,
811
+ status: res.status,
812
+ chunks: seq,
813
+ bytes,
814
+ });
745
815
  } catch (e) {
746
- if ((e as Error).name !== "AbortError")
816
+ if ((e as Error).name !== "AbortError") {
817
+ maybeSlow("req.error", startedAt, {
818
+ room,
819
+ peer: peerId,
820
+ id,
821
+ method,
822
+ path: p,
823
+ error: String((e as Error).message ?? e),
824
+ });
747
825
  enqueueSeal(peerId, dc, peer, 0, {
748
826
  t: "end",
749
827
  id,
750
828
  error: String((e as Error).message ?? e),
751
829
  });
830
+ }
752
831
  } finally {
753
832
  peer.aborts.delete(id);
754
833
  }
@@ -0,0 +1,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
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseStatusText } from "./statusText.ts";
3
+
4
+ describe("parseStatusText", () => {
5
+ it("returns the latest Claude spinner/status line", () => {
6
+ expect(
7
+ parseStatusText([
8
+ "older output",
9
+ "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)",
10
+ ]),
11
+ ).toBe("✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)");
12
+ });
13
+
14
+ it("ignores non-status terminal prose", () => {
15
+ expect(parseStatusText(["hello", "Done", ""])).toBe(null);
16
+ });
17
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Extract a short "what is the agent doing right now?" line from the rendered
3
+ * terminal screen. Claude Code paints this as a spinner/status line, e.g.
4
+ * "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)".
5
+ */
6
+
7
+ const SPINNER_PREFIX = /^[\u2800-\u28ff✶✻✢✳✽✦✧✩✷✸✹✺✼·•●◐◓◒◑]\s+/u;
8
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g;
9
+
10
+ export function parseStatusText(lines: string[]): string | null {
11
+ for (let i = lines.length - 1; i >= 0; i--) {
12
+ const line = lines[i]?.replace(CONTROL, "").trim();
13
+ if (!line || line.length < 3) continue;
14
+ if (!SPINNER_PREFIX.test(line)) continue;
15
+ if (/^(?:[•·]\s*)?(?:esc|ctrl|enter|return|shift|tab)\b/i.test(line)) continue;
16
+ return line.slice(0, 220).trim();
17
+ }
18
+ return null;
19
+ }
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-Dm6jjLwC.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-BomfUpSo.js";
4
- import "./pidStore-BIvsBQ8X.js";
5
- import "./globalPidIndex-CoNr7tS8.js";
6
- import "./messageLog-CxrKJj77.js";
7
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
8
-
9
- export { SUPPORTED_CLIS };