@poncho-ai/browser 0.6.24 → 0.6.26

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,5 +1,5 @@
1
1
 
2
- > @poncho-ai/browser@0.6.24 build /home/runner/work/poncho-ai/poncho-ai/packages/browser
2
+ > @poncho-ai/browser@0.6.26 build /home/runner/work/poncho-ai/poncho-ai/packages/browser
3
3
  > tsup src/index.ts --format esm --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -7,8 +7,8 @@
7
7
  CLI tsup v8.5.1
8
8
  CLI Target: es2022
9
9
  ESM Build start
10
- ESM dist/index.js 47.13 KB
11
- ESM ⚡️ Build success in 67ms
10
+ ESM dist/index.js 47.98 KB
11
+ ESM ⚡️ Build success in 60ms
12
12
  DTS Build start
13
- DTS ⚡️ Build success in 5077ms
14
- DTS dist/index.d.ts 13.69 KB
13
+ DTS ⚡️ Build success in 4894ms
14
+ DTS dist/index.d.ts 13.77 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @poncho-ai/browser
2
2
 
3
+ ## 0.6.26
4
+
5
+ ### Patch Changes
6
+
7
+ - [#182](https://github.com/cesr/poncho-ai/pull/182) [`5ca3615`](https://github.com/cesr/poncho-ai/commit/5ca361576cbe1a97e6315f550a58a302b4e70aca) Thanks [@cesr](https://github.com/cesr)! - Keep host viewport listeners alive across browser sessions. `onFrame` /
8
+ `onStatus` listeners were stored inside the per-conversation `ConversationTab`
9
+ object, so `closeTab` (and LRU eviction) deleted them along with the tab. When
10
+ an agent closed one browser and opened another in the same conversation, the
11
+ new tab had empty listener sets — the host's live-viewport subscription was
12
+ silently orphaned, so the second session's `browser:status` / frames never
13
+ reached the client until it reconnected (the "pill/sheet doesn't appear, or is
14
+ left over after close, until I navigate away and back" bug). Listeners now live
15
+ in session-level maps keyed by conversationId, independent of any tab's
16
+ lifetime; they persist until the host unsubscribes, and `emitStatus` delivers
17
+ the final `active:false` on close before the tab is removed.
18
+
19
+ ## 0.6.25
20
+
21
+ ### Patch Changes
22
+
23
+ - [#180](https://github.com/cesr/poncho-ai/pull/180) [`4e27887`](https://github.com/cesr/poncho-ai/commit/4e27887655eda1d420b8f69097cfc79e42f9596c) Thanks [@cesr](https://github.com/cesr)! - Fix a deadlock that wedged the browser session after the first lock-acquire
24
+ timeout. `lock()` pushed a wrapper closure onto `_lockQueue` but, on the 30s
25
+ timeout, tried to remove the entry with `indexOf(resolve)` — searching for a
26
+ different function — so the timed-out waiter was never spliced out. When the
27
+ current owner later called `unlock()`, it `shift()`ed that zombie waiter and
28
+ invoked it; `resolve()` on the already-rejected promise was a no-op, so the
29
+ unlock was consumed by a dead waiter, `_locked` stayed `true`, and no live
30
+ operation could ever acquire the lock again. Every subsequent browser call
31
+ then returned "Browser operation timed out waiting for lock (30s)" until the
32
+ session was torn down. Waiters are now tracked as objects with a `settled`
33
+ flag: a timed-out waiter removes itself from the queue, and `unlock()` skips
34
+ any already-settled waiters when handing off ownership.
35
+
3
36
  ## 0.6.24
4
37
 
5
38
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -80,6 +80,8 @@ declare class BrowserSession {
80
80
  private readonly sessionId;
81
81
  private manager;
82
82
  private readonly tabs;
83
+ private readonly frameListeners;
84
+ private readonly statusListeners;
83
85
  private _contextStealthInstalled;
84
86
  private readonly _uaOverrideApplied;
85
87
  private _lockQueue;
package/dist/index.js CHANGED
@@ -250,6 +250,15 @@ var BrowserSession = class {
250
250
  manager;
251
251
  // Tab management: conversationId → tab state
252
252
  tabs = /* @__PURE__ */ new Map();
253
+ // Viewport listeners, keyed by conversationId and kept SEPARATE from the
254
+ // tab. A host (e.g. a live iOS viewport) subscribes once; its listeners must
255
+ // outlive any individual tab so that closing one browser and opening another
256
+ // in the same conversation — or an LRU tab eviction — doesn't silently
257
+ // orphan the subscription (the symptom: the second session's status/frames
258
+ // never reach the client until it reconnects). Tabs come and go; listeners
259
+ // persist until the host unsubscribes.
260
+ frameListeners = /* @__PURE__ */ new Map();
261
+ statusListeners = /* @__PURE__ */ new Map();
253
262
  // Whether context-level stealth init script has been installed
254
263
  _contextStealthInstalled = false;
255
264
  // Track which tabs have had per-page CDP UA override applied
@@ -275,21 +284,33 @@ var BrowserSession = class {
275
284
  return;
276
285
  }
277
286
  return new Promise((resolve2, reject) => {
287
+ const waiter = { settled: false, grant: () => {
288
+ } };
278
289
  const timer = setTimeout(() => {
279
- const idx = this._lockQueue.indexOf(resolve2);
290
+ if (waiter.settled) return;
291
+ waiter.settled = true;
292
+ const idx = this._lockQueue.indexOf(waiter);
280
293
  if (idx !== -1) this._lockQueue.splice(idx, 1);
281
294
  reject(new Error("Browser operation timed out waiting for lock (30s)"));
282
295
  }, 3e4);
283
- this._lockQueue.push(() => {
296
+ waiter.grant = () => {
297
+ if (waiter.settled) return;
298
+ waiter.settled = true;
284
299
  clearTimeout(timer);
285
300
  resolve2();
286
- });
301
+ };
302
+ this._lockQueue.push(waiter);
287
303
  });
288
304
  }
289
305
  unlock() {
290
- const next = this._lockQueue.shift();
291
- if (next) next();
292
- else this._locked = false;
306
+ while (this._lockQueue.length > 0) {
307
+ const next = this._lockQueue.shift();
308
+ if (!next.settled) {
309
+ next.grant();
310
+ return;
311
+ }
312
+ }
313
+ this._locked = false;
293
314
  }
294
315
  // -----------------------------------------------------------------------
295
316
  // Core browser + tab management
@@ -537,13 +558,10 @@ var BrowserSession = class {
537
558
  if (realTabs > 0) {
538
559
  await mgr.newTab();
539
560
  }
540
- const existing = tab;
541
561
  tab = {
542
562
  tabIndex: mgr.getActiveIndex(),
543
563
  active: true,
544
- lastUsed: Date.now(),
545
- frameListeners: existing?.frameListeners ?? /* @__PURE__ */ new Set(),
546
- statusListeners: existing?.statusListeners ?? /* @__PURE__ */ new Set()
564
+ lastUsed: Date.now()
547
565
  };
548
566
  this.tabs.set(conversationId, tab);
549
567
  } else {
@@ -817,15 +835,15 @@ var BrowserSession = class {
817
835
  (frame) => {
818
836
  const cid = this._screencastConversation;
819
837
  if (!cid) return;
820
- const t = this.tabs.get(cid);
821
- if (!t) return;
838
+ const listeners = this.frameListeners.get(cid);
839
+ if (!listeners || listeners.size === 0) return;
822
840
  const browserFrame = {
823
841
  data: frame.data,
824
842
  width: frame.metadata.deviceWidth,
825
843
  height: frame.metadata.deviceHeight,
826
844
  timestamp: Date.now()
827
845
  };
828
- for (const listener of t.frameListeners) {
846
+ for (const listener of listeners) {
829
847
  try {
830
848
  listener(browserFrame);
831
849
  } catch {
@@ -853,29 +871,37 @@ var BrowserSession = class {
853
871
  // Per-conversation event listeners
854
872
  // -----------------------------------------------------------------------
855
873
  onFrame(conversationId, listener) {
856
- let tab = this.tabs.get(conversationId);
857
- if (!tab) {
858
- tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: /* @__PURE__ */ new Set(), statusListeners: /* @__PURE__ */ new Set() };
859
- this.tabs.set(conversationId, tab);
874
+ let set = this.frameListeners.get(conversationId);
875
+ if (!set) {
876
+ set = /* @__PURE__ */ new Set();
877
+ this.frameListeners.set(conversationId, set);
860
878
  }
861
- tab.frameListeners.add(listener);
879
+ set.add(listener);
862
880
  return () => {
863
- tab.frameListeners.delete(listener);
864
- if (tab.frameListeners.size === 0 && this._screencastConversation === conversationId) {
865
- this.stopScreencast().catch(() => {
866
- });
881
+ const s = this.frameListeners.get(conversationId);
882
+ if (!s) return;
883
+ s.delete(listener);
884
+ if (s.size === 0) {
885
+ this.frameListeners.delete(conversationId);
886
+ if (this._screencastConversation === conversationId) {
887
+ this.stopScreencast().catch(() => {
888
+ });
889
+ }
867
890
  }
868
891
  };
869
892
  }
870
893
  onStatus(conversationId, listener) {
871
- let tab = this.tabs.get(conversationId);
872
- if (!tab) {
873
- tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: /* @__PURE__ */ new Set(), statusListeners: /* @__PURE__ */ new Set() };
874
- this.tabs.set(conversationId, tab);
894
+ let set = this.statusListeners.get(conversationId);
895
+ if (!set) {
896
+ set = /* @__PURE__ */ new Set();
897
+ this.statusListeners.set(conversationId, set);
875
898
  }
876
- tab.statusListeners.add(listener);
899
+ set.add(listener);
877
900
  return () => {
878
- tab.statusListeners.delete(listener);
901
+ const s = this.statusListeners.get(conversationId);
902
+ if (!s) return;
903
+ s.delete(listener);
904
+ if (s.size === 0) this.statusListeners.delete(conversationId);
879
905
  };
880
906
  }
881
907
  // -----------------------------------------------------------------------
@@ -1075,8 +1101,9 @@ var BrowserSession = class {
1075
1101
  url: tab?.url,
1076
1102
  interactionAllowed: tab?.active ?? false
1077
1103
  };
1078
- if (tab) {
1079
- for (const listener of tab.statusListeners) {
1104
+ const listeners = this.statusListeners.get(conversationId);
1105
+ if (listeners) {
1106
+ for (const listener of listeners) {
1080
1107
  try {
1081
1108
  listener(status);
1082
1109
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/browser",
3
- "version": "0.6.24",
3
+ "version": "0.6.26",
4
4
  "description": "Browser automation for Poncho agents, powered by agent-browser",
5
5
  "repository": {
6
6
  "type": "git",
package/src/session.ts CHANGED
@@ -144,8 +144,6 @@ interface ConversationTab {
144
144
  url?: string;
145
145
  active: boolean;
146
146
  lastUsed: number;
147
- frameListeners: Set<FrameListener>;
148
- statusListeners: Set<StatusListener>;
149
147
  }
150
148
 
151
149
  export class BrowserSession {
@@ -156,6 +154,16 @@ export class BrowserSession {
156
154
  // Tab management: conversationId → tab state
157
155
  private readonly tabs = new Map<string, ConversationTab>();
158
156
 
157
+ // Viewport listeners, keyed by conversationId and kept SEPARATE from the
158
+ // tab. A host (e.g. a live iOS viewport) subscribes once; its listeners must
159
+ // outlive any individual tab so that closing one browser and opening another
160
+ // in the same conversation — or an LRU tab eviction — doesn't silently
161
+ // orphan the subscription (the symptom: the second session's status/frames
162
+ // never reach the client until it reconnects). Tabs come and go; listeners
163
+ // persist until the host unsubscribes.
164
+ private readonly frameListeners = new Map<string, Set<FrameListener>>();
165
+ private readonly statusListeners = new Map<string, Set<StatusListener>>();
166
+
159
167
  // Whether context-level stealth init script has been installed
160
168
  private _contextStealthInstalled = false;
161
169
 
@@ -163,7 +171,7 @@ export class BrowserSession {
163
171
  private readonly _uaOverrideApplied = new Set<string>();
164
172
 
165
173
  // Serialization lock for tab-switching operations
166
- private _lockQueue: Array<() => void> = [];
174
+ private _lockQueue: Array<{ grant: () => void; settled: boolean }> = [];
167
175
  private _locked = false;
168
176
 
169
177
  // Currently screencast conversation (only one at a time due to CDP)
@@ -189,19 +197,38 @@ export class BrowserSession {
189
197
  return;
190
198
  }
191
199
  return new Promise<void>((resolve, reject) => {
200
+ const waiter = { settled: false, grant: () => {} };
192
201
  const timer = setTimeout(() => {
193
- const idx = this._lockQueue.indexOf(resolve);
202
+ if (waiter.settled) return;
203
+ waiter.settled = true;
204
+ const idx = this._lockQueue.indexOf(waiter);
194
205
  if (idx !== -1) this._lockQueue.splice(idx, 1);
195
206
  reject(new Error("Browser operation timed out waiting for lock (30s)"));
196
207
  }, 30_000);
197
- this._lockQueue.push(() => { clearTimeout(timer); resolve(); });
208
+ waiter.grant = () => {
209
+ if (waiter.settled) return;
210
+ waiter.settled = true;
211
+ clearTimeout(timer);
212
+ resolve();
213
+ };
214
+ this._lockQueue.push(waiter);
198
215
  });
199
216
  }
200
217
 
201
218
  private unlock(): void {
202
- const next = this._lockQueue.shift();
203
- if (next) next();
204
- else this._locked = false;
219
+ // Hand ownership to the next waiter that is still live. A waiter whose
220
+ // 30s timeout already fired has rejected its promise and marked itself
221
+ // settled; granting it would silently drop the lock (resolve is a no-op)
222
+ // and leave `_locked` true forever, wedging every later caller. Skip the
223
+ // dead ones; if none remain, release the lock.
224
+ while (this._lockQueue.length > 0) {
225
+ const next = this._lockQueue.shift()!;
226
+ if (!next.settled) {
227
+ next.grant();
228
+ return;
229
+ }
230
+ }
231
+ this._locked = false;
205
232
  }
206
233
 
207
234
  // -----------------------------------------------------------------------
@@ -481,13 +508,10 @@ export class BrowserSession {
481
508
  if (realTabs > 0) {
482
509
  await mgr.newTab();
483
510
  }
484
- const existing = tab;
485
511
  tab = {
486
512
  tabIndex: mgr.getActiveIndex(),
487
513
  active: true,
488
514
  lastUsed: Date.now(),
489
- frameListeners: existing?.frameListeners ?? new Set(),
490
- statusListeners: existing?.statusListeners ?? new Set(),
491
515
  };
492
516
  this.tabs.set(conversationId, tab);
493
517
  } else {
@@ -780,15 +804,15 @@ export class BrowserSession {
780
804
  (frame) => {
781
805
  const cid = this._screencastConversation;
782
806
  if (!cid) return;
783
- const t = this.tabs.get(cid);
784
- if (!t) return;
807
+ const listeners = this.frameListeners.get(cid);
808
+ if (!listeners || listeners.size === 0) return;
785
809
  const browserFrame: BrowserFrame = {
786
810
  data: frame.data,
787
811
  width: frame.metadata.deviceWidth,
788
812
  height: frame.metadata.deviceHeight,
789
813
  timestamp: Date.now(),
790
814
  };
791
- for (const listener of t.frameListeners) {
815
+ for (const listener of listeners) {
792
816
  try { listener(browserFrame); } catch { /* */ }
793
817
  }
794
818
  },
@@ -816,28 +840,38 @@ export class BrowserSession {
816
840
  // -----------------------------------------------------------------------
817
841
 
818
842
  onFrame(conversationId: string, listener: FrameListener): () => void {
819
- let tab = this.tabs.get(conversationId);
820
- if (!tab) {
821
- tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: new Set(), statusListeners: new Set() };
822
- this.tabs.set(conversationId, tab);
843
+ let set = this.frameListeners.get(conversationId);
844
+ if (!set) {
845
+ set = new Set();
846
+ this.frameListeners.set(conversationId, set);
823
847
  }
824
- tab.frameListeners.add(listener);
848
+ set.add(listener);
825
849
  return () => {
826
- tab!.frameListeners.delete(listener);
827
- if (tab!.frameListeners.size === 0 && this._screencastConversation === conversationId) {
828
- this.stopScreencast().catch(() => {});
850
+ const s = this.frameListeners.get(conversationId);
851
+ if (!s) return;
852
+ s.delete(listener);
853
+ if (s.size === 0) {
854
+ this.frameListeners.delete(conversationId);
855
+ if (this._screencastConversation === conversationId) {
856
+ this.stopScreencast().catch(() => {});
857
+ }
829
858
  }
830
859
  };
831
860
  }
832
861
 
833
862
  onStatus(conversationId: string, listener: StatusListener): () => void {
834
- let tab = this.tabs.get(conversationId);
835
- if (!tab) {
836
- tab = { tabIndex: -1, active: false, lastUsed: Date.now(), frameListeners: new Set(), statusListeners: new Set() };
837
- this.tabs.set(conversationId, tab);
863
+ let set = this.statusListeners.get(conversationId);
864
+ if (!set) {
865
+ set = new Set();
866
+ this.statusListeners.set(conversationId, set);
838
867
  }
839
- tab.statusListeners.add(listener);
840
- return () => { tab!.statusListeners.delete(listener); };
868
+ set.add(listener);
869
+ return () => {
870
+ const s = this.statusListeners.get(conversationId);
871
+ if (!s) return;
872
+ s.delete(listener);
873
+ if (s.size === 0) this.statusListeners.delete(conversationId);
874
+ };
841
875
  }
842
876
 
843
877
  // -----------------------------------------------------------------------
@@ -1049,8 +1083,12 @@ export class BrowserSession {
1049
1083
  url: tab?.url,
1050
1084
  interactionAllowed: tab?.active ?? false,
1051
1085
  };
1052
- if (tab) {
1053
- for (const listener of tab.statusListeners) {
1086
+ // Listeners live at the session level, so a close (which deletes the tab)
1087
+ // still delivers the final active:false to the host before the tab is
1088
+ // gone — and a later reopen reuses the same subscription.
1089
+ const listeners = this.statusListeners.get(conversationId);
1090
+ if (listeners) {
1091
+ for (const listener of listeners) {
1054
1092
  try { listener(status); } catch { /* */ }
1055
1093
  }
1056
1094
  }