agent-yes 1.204.0 → 1.206.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ import { mkdir, readFile, rm, writeFile, rename } from "fs/promises";
2
+ import path from "path";
3
+ import { agentYesHome } from "./agentYesHome.ts";
4
+ import { isTransientLockMkdirError } from "./notifyStore.ts";
5
+
6
+ /**
7
+ * Singleton lock for the WebRTC host role: one `ay serve` host per
8
+ * ~/.agent-yes. Two hosts sharing the persisted room (~/.agent-yes/.share-room)
9
+ * fight over every viewer connection — the exact outage seen live: an orphaned
10
+ * `ay serve --webrtc` from a previous manager era plus a manual run left the
11
+ * managed daemon crash-looping (12 watchdog restarts) and the share link
12
+ * unloadable. The lock makes the loser fail FAST with a pointer to the owner
13
+ * instead of silently contending.
14
+ *
15
+ * mkdir-based (atomic on every platform), owner JSON alongside, heartbeat
16
+ * refresh while held. A stale owner — dead pid, or a heartbeat older than
17
+ * SERVE_LOCK_STALE_MS (a SIGKILLed host can't clean up) — is stolen, so a
18
+ * crashed host never wedges the next start.
19
+ */
20
+
21
+ export const SERVE_LOCK_STALE_MS = 20_000;
22
+ export const SERVE_LOCK_BEAT_MS = 5_000;
23
+ // Riding out a manager roll-forward: stop-old/start-new overlap briefly, so a
24
+ // new host retries for a grace window before declaring the lock busy.
25
+ export const SERVE_LOCK_GRACE_MS = 12_000;
26
+
27
+ export type ServeLockOwner = { pid: number; started_at: number; beat_at: number };
28
+
29
+ export type ServeLockResult =
30
+ | { ok: true; release: () => Promise<void> }
31
+ | { ok: false; owner: ServeLockOwner | null };
32
+
33
+ function lockDir(): string {
34
+ return path.join(agentYesHome(), "webrtc-host.lock");
35
+ }
36
+ function ownerPath(): string {
37
+ return path.join(lockDir(), "owner.json");
38
+ }
39
+
40
+ function pidAlive(pid: number): boolean {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ } catch (e) {
45
+ // EPERM = alive but not ours; only ESRCH means gone.
46
+ return (e as NodeJS.ErrnoException).code === "EPERM";
47
+ }
48
+ }
49
+
50
+ async function readOwner(): Promise<ServeLockOwner | null> {
51
+ try {
52
+ const o = JSON.parse(await readFile(ownerPath(), "utf-8")) as ServeLockOwner;
53
+ return typeof o?.pid === "number" ? o : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Pure staleness decision, exported for tests. */
60
+ export function isOwnerStale(
61
+ owner: ServeLockOwner | null,
62
+ now: number,
63
+ alive: (pid: number) => boolean,
64
+ staleMs = SERVE_LOCK_STALE_MS,
65
+ ): boolean {
66
+ if (!owner) return true; // torn/absent owner: mkdir won but write died
67
+ if (!alive(owner.pid)) return true;
68
+ return now - owner.beat_at > staleMs;
69
+ }
70
+
71
+ // Atomic owner stamp (temp + rename) so a concurrent reader never parses a
72
+ // torn file as "no owner" while we in fact hold the lock.
73
+ async function stampOwner(startedAt: number): Promise<boolean> {
74
+ const tmp = `${ownerPath()}.${process.pid}.tmp`;
75
+ try {
76
+ await writeFile(
77
+ tmp,
78
+ JSON.stringify({ pid: process.pid, started_at: startedAt, beat_at: Date.now() }),
79
+ );
80
+ await rename(tmp, ownerPath());
81
+ return true;
82
+ } catch {
83
+ await rm(tmp, { force: true }).catch(() => {});
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Acquire the host lock, retrying for up to `graceMs`. Returns fast on a live
90
+ * owner once the grace expires (never blocks a daemon boot forever). With
91
+ * `takeover`, a live owner is SIGTERM'd (then SIGKILL'd) and the lock stolen.
92
+ */
93
+ export async function acquireWebrtcHostLock(opts?: {
94
+ takeover?: boolean;
95
+ graceMs?: number;
96
+ staleMs?: number;
97
+ /** Heartbeat cadence override — tests only. */
98
+ beatMs?: number;
99
+ /** SIGTERM→SIGKILL escalation wait for --takeover — tests only. */
100
+ takeoverWaitMs?: number;
101
+ }): Promise<ServeLockResult> {
102
+ const graceMs = opts?.graceMs ?? SERVE_LOCK_GRACE_MS;
103
+ const staleMs = opts?.staleMs ?? SERVE_LOCK_STALE_MS;
104
+ const beatMs = opts?.beatMs ?? SERVE_LOCK_BEAT_MS;
105
+ const startedAt = Date.now();
106
+ const deadline = startedAt + graceMs;
107
+ let tookOver = false;
108
+ for (;;) {
109
+ try {
110
+ await mkdir(lockDir(), { recursive: false });
111
+ if (!(await stampOwner(startedAt))) {
112
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
113
+ return { ok: false, owner: null };
114
+ }
115
+ // Heartbeat while held; stops itself if a thief replaced our owner file.
116
+ const beat = setInterval(() => {
117
+ void (async () => {
118
+ const o = await readOwner();
119
+ if (o?.pid !== process.pid) {
120
+ clearInterval(beat);
121
+ return;
122
+ }
123
+ await stampOwner(startedAt);
124
+ })();
125
+ }, beatMs);
126
+ if (typeof beat.unref === "function") beat.unref();
127
+ return {
128
+ ok: true,
129
+ release: async () => {
130
+ clearInterval(beat);
131
+ if ((await readOwner())?.pid === process.pid)
132
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
133
+ },
134
+ };
135
+ } catch (e) {
136
+ const code = (e as NodeJS.ErrnoException).code;
137
+ if (!isTransientLockMkdirError(code)) throw e;
138
+ if (code !== "EEXIST") {
139
+ // win32 mkdir-vs-rm race: dir mid-delete, just retry.
140
+ await new Promise((r) => setTimeout(r, 25));
141
+ continue;
142
+ }
143
+ const owner = await readOwner();
144
+ if (isOwnerStale(owner, Date.now(), pidAlive, staleMs)) {
145
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
146
+ continue; // re-prove via mkdir — two stealers can't both win
147
+ }
148
+ if (opts?.takeover && owner && !tookOver) {
149
+ tookOver = true; // one shot: never kill a second, newer owner
150
+ try {
151
+ process.kill(owner.pid, "SIGTERM");
152
+ } catch {
153
+ /* already gone */
154
+ }
155
+ // Give it a moment to shut down cleanly (it releases the lock), then
156
+ // escalate; the loop's stale check mops up whatever remains.
157
+ await new Promise((r) => setTimeout(r, opts?.takeoverWaitMs ?? 2_000));
158
+ if (pidAlive(owner.pid)) {
159
+ try {
160
+ process.kill(owner.pid, "SIGKILL");
161
+ } catch {
162
+ /* gone */
163
+ }
164
+ }
165
+ await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
166
+ continue;
167
+ }
168
+ if (Date.now() >= deadline) return { ok: false, owner };
169
+ await new Promise((r) => setTimeout(r, 250));
170
+ }
171
+ }
172
+ }
package/ts/share.ts CHANGED
@@ -66,10 +66,28 @@ const MAX_PEER_JOIN_QUEUE = 50;
66
66
  const IDLE_RESTART_UPTIME_MS = 25 * 60_000;
67
67
  const HARD_RESTART_UPTIME_MS = 45 * 60_000;
68
68
  const IDLE_RESTART_CHECK_MS = 60_000;
69
+ const PERF_SLOW_MS = Number(process.env.AGENT_YES_WEBRTC_PERF_SLOW_MS) || 750;
70
+ const PERF_BUFFERED_BYTES = Number(process.env.AGENT_YES_WEBRTC_PERF_BUFFERED_BYTES) || 1_000_000;
71
+ const PERF_VERBOSE = process.env.AGENT_YES_WEBRTC_PERF === "1";
69
72
 
70
73
  type IceServer = { urls: string | string[]; username?: string; credential?: string };
71
74
  const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
72
75
 
76
+ function perfLog(event: string, data: Record<string, unknown>, force = false) {
77
+ if (!force && !PERF_VERBOSE) return;
78
+ process.stderr.write(`[share:perf] ${JSON.stringify({ t: Date.now(), event, ...data })}\n`);
79
+ }
80
+
81
+ function maybeSlow(event: string, startedAt: number, data: Record<string, unknown> = {}) {
82
+ const ms = Math.round(performance.now() - startedAt);
83
+ perfLog(event, { ms, ...data }, ms >= PERF_SLOW_MS);
84
+ }
85
+
86
+ function dcBufferedAmount(dc: any): number {
87
+ const n = Number(dc?.bufferedAmount ?? 0);
88
+ return Number.isFinite(n) ? n : 0;
89
+ }
90
+
73
91
  // Short-lived Cloudflare TURN credentials, minted from a long-term TURN key, so
74
92
  // browsers can RELAY when a direct P2P path is impossible (symmetric NAT /
75
93
  // CGNAT — the main cause of "rooms offline"). Set CF_TURN_KEY_ID +
@@ -428,6 +446,7 @@ export async function startShare(
428
446
  if (closed) return; // a reconnect timer queued before close() must not revive it
429
447
  const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
430
448
  currentWs = ws;
449
+ const signalStartedAt = performance.now();
431
450
  let ready = false;
432
451
  let lastRecv = Date.now();
433
452
  let hb: ReturnType<typeof setInterval> | undefined;
@@ -443,6 +462,7 @@ export async function startShare(
443
462
  }
444
463
  };
445
464
  ws.onopen = () => {
465
+ maybeSlow("signal.open", signalStartedAt, { room, host });
446
466
  ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
447
467
  ready = true;
448
468
  lastRecv = Date.now();
@@ -480,6 +500,12 @@ export async function startShare(
480
500
  const m = JSON.parse(ev.data as string);
481
501
  if (m.type === "pong") return; // heartbeat ack — liveness already recorded
482
502
  if (m.type === "peer-join") {
503
+ perfLog("peer.join", {
504
+ room,
505
+ peer: String(m.peer),
506
+ queue: peerJoinQueue.length,
507
+ peers: peers.size,
508
+ });
483
509
  // Serialized in drainPeerJoins() to avoid a storm. Skip dupes (already
484
510
  // queued or already an active peer) and cap the queue so a pathological
485
511
  // burst can't grow it unboundedly — dropped joins retry via the browser.
@@ -554,6 +580,7 @@ export async function startShare(
554
580
  };
555
581
 
556
582
  async function startPeer(ws: WebSocket, peerId: string) {
583
+ const startedAt = performance.now();
557
584
  const iceServers = await getIceServers();
558
585
  const pc = new RTCPeerConnection({ iceServers });
559
586
  let resolveKeys!: () => void;
@@ -584,6 +611,11 @@ export async function startShare(
584
611
  dc.binaryType = "arraybuffer";
585
612
  dc.onopen = async () => {
586
613
  try {
614
+ maybeSlow("peer.dc_open", startedAt, {
615
+ room,
616
+ peer: peerId,
617
+ buffered: dcBufferedAmount(dc),
618
+ });
587
619
  await peer.keysReady; // keys derived in the answer handler
588
620
  // Open the mandatory bidirectional key-confirmation handshake. Nothing
589
621
  // the peer sends is acted on until BOTH directions confirm (see onFrame).
@@ -624,6 +656,7 @@ export async function startShare(
624
656
  ws.send(
625
657
  JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp, iceServers }),
626
658
  );
659
+ maybeSlow("peer.offer", startedAt, { room, peer: peerId, iceServers: iceServers.length });
627
660
  }
628
661
 
629
662
  function closePeer(peerId: string) {
@@ -642,8 +675,10 @@ export async function startShare(
642
675
  // Seal an envelope and send it, serialized per peer so the wire order matches
643
676
  // the nonce-counter order (so the receiver's monotonic check never trips).
644
677
  function enqueueSeal(peerId: string, dc: any, peer: Peer, flags: number, obj: object) {
678
+ const queuedAt = performance.now();
645
679
  peer.sendChain = peer.sendChain.then(async () => {
646
680
  if (dc.readyState !== "open" || !peer.keyH2C || !peer.th) return;
681
+ const queuedMs = Math.round(performance.now() - queuedAt);
647
682
  let frame: ArrayBuffer;
648
683
  try {
649
684
  frame = await e2eSeal(peer.keyH2C, peer.send, flags, peer.th, packEnvelope(obj));
@@ -653,6 +688,19 @@ export async function startShare(
653
688
  }
654
689
  try {
655
690
  dc.send(frame);
691
+ const buffered = dcBufferedAmount(dc);
692
+ perfLog(
693
+ "send",
694
+ {
695
+ room,
696
+ peer: peerId,
697
+ kind: (obj as any)?.t,
698
+ queuedMs,
699
+ buffered,
700
+ bytes: frame.byteLength,
701
+ },
702
+ queuedMs >= PERF_SLOW_MS || buffered >= PERF_BUFFERED_BYTES,
703
+ );
656
704
  } catch {
657
705
  /* peer vanished mid-send; dropping the frame is correct */
658
706
  }
@@ -703,6 +751,8 @@ export async function startShare(
703
751
  }
704
752
  if (req.t !== "req") return;
705
753
  const { id, method, path: p, body } = req;
754
+ const startedAt = performance.now();
755
+ perfLog("req.start", { room, peer: peerId, id, method, path: p });
706
756
  const ac = new AbortController();
707
757
  peer.aborts.set(id, ac);
708
758
  try {
@@ -718,6 +768,14 @@ export async function startShare(
718
768
  signal: ac.signal,
719
769
  }),
720
770
  );
771
+ maybeSlow("req.head", startedAt, {
772
+ room,
773
+ peer: peerId,
774
+ id,
775
+ method,
776
+ path: p,
777
+ status: res.status,
778
+ });
721
779
  enqueueSeal(peerId, dc, peer, 0, {
722
780
  t: "res",
723
781
  id,
@@ -727,9 +785,11 @@ export async function startShare(
727
785
  const reader = res.body!.getReader();
728
786
  const dec = new TextDecoder();
729
787
  let seq = 0;
788
+ let bytes = 0;
730
789
  for (;;) {
731
790
  const { done, value } = await reader.read();
732
791
  if (done) break;
792
+ bytes += value.byteLength;
733
793
  const text = dec.decode(value, { stream: true });
734
794
  // Slice on UTF-16 boundaries: JSON round-trips lone surrogates as \uXXXX,
735
795
  // so the receiver reassembles the exact text by concatenating in seq order.
@@ -742,13 +802,32 @@ export async function startShare(
742
802
  });
743
803
  }
744
804
  enqueueSeal(peerId, dc, peer, 0, { t: "end", id, seq });
805
+ maybeSlow("req.end", startedAt, {
806
+ room,
807
+ peer: peerId,
808
+ id,
809
+ method,
810
+ path: p,
811
+ status: res.status,
812
+ chunks: seq,
813
+ bytes,
814
+ });
745
815
  } catch (e) {
746
- if ((e as Error).name !== "AbortError")
816
+ if ((e as Error).name !== "AbortError") {
817
+ maybeSlow("req.error", startedAt, {
818
+ room,
819
+ peer: peerId,
820
+ id,
821
+ method,
822
+ path: p,
823
+ error: String((e as Error).message ?? e),
824
+ });
747
825
  enqueueSeal(peerId, dc, peer, 0, {
748
826
  t: "end",
749
827
  id,
750
828
  error: String((e as Error).message ?? e),
751
829
  });
830
+ }
752
831
  } finally {
753
832
  peer.aborts.delete(id);
754
833
  }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseStatusText } from "./statusText.ts";
3
+
4
+ describe("parseStatusText", () => {
5
+ it("returns the latest Claude spinner/status line", () => {
6
+ expect(
7
+ parseStatusText([
8
+ "older output",
9
+ "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)",
10
+ ]),
11
+ ).toBe("✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)");
12
+ });
13
+
14
+ it("ignores non-status terminal prose", () => {
15
+ expect(parseStatusText(["hello", "Done", ""])).toBe(null);
16
+ });
17
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Extract a short "what is the agent doing right now?" line from the rendered
3
+ * terminal screen. Claude Code paints this as a spinner/status line, e.g.
4
+ * "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)".
5
+ */
6
+
7
+ const SPINNER_PREFIX = /^[\u2800-\u28ff✶✻✢✳✽✦✧✩✷✸✹✺✼·•●◐◓◒◑]\s+/u;
8
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g;
9
+
10
+ export function parseStatusText(lines: string[]): string | null {
11
+ for (let i = lines.length - 1; i >= 0; i--) {
12
+ const line = lines[i]?.replace(CONTROL, "").trim();
13
+ if (!line || line.length < 3) continue;
14
+ if (!SPINNER_PREFIX.test(line)) continue;
15
+ if (/^(?:[•·]\s*)?(?:esc|ctrl|enter|return|shift|tab)\b/i.test(line)) continue;
16
+ return line.slice(0, 220).trim();
17
+ }
18
+ return null;
19
+ }
@@ -1,9 +0,0 @@
1
- import "./ts-D9LzuGpU.js";
2
- import "./logger-CDIsZ-Pp.js";
3
- import "./versionChecker-C8ScoMqV.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-DqsPHFU9.js";
8
-
9
- export { SUPPORTED_CLIS };