@alfe.ai/browser 0.1.0 → 0.2.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/dist/index.cjs CHANGED
@@ -33,6 +33,14 @@ puppeteer_core = __toESM(puppeteer_core);
33
33
  * new tabs are what gets screencast, not a stale main window.
34
34
  */
35
35
  const DEFAULT_IDLE_MS = 300 * 1e3;
36
+ /**
37
+ * A page target is worth streaming only once it has a real, navigable http(s)
38
+ * URL. Transient `about:blank` (and `chrome://`, `data:`, blank string) targets
39
+ * are throwaways we must not flip the screencast to.
40
+ */
41
+ function isNavigablePageUrl(url) {
42
+ return url.startsWith("http://") || url.startsWith("https://");
43
+ }
36
44
  const noopLogger$2 = {
37
45
  info: () => {},
38
46
  warn: () => {},
@@ -76,11 +84,13 @@ var BrowserSession = class {
76
84
  });
77
85
  this.activePage = (await this.browser.pages())[0] ?? await this.browser.newPage();
78
86
  this.browser.on("targetcreated", (target) => {
87
+ if (target.type() !== puppeteer_core.TargetType.PAGE) return;
79
88
  target.page().then((page) => {
80
- if (page) {
81
- this.activePage = page;
82
- this.log.debug("Active page switched to new target");
83
- }
89
+ if (!page) return;
90
+ if (this.adoptIfNavigable(page)) return;
91
+ page.on("framenavigated", (frame) => {
92
+ if (frame === page.mainFrame()) this.adoptIfNavigable(page);
93
+ });
84
94
  }).catch(() => {});
85
95
  });
86
96
  this.browser.on("disconnected", () => {
@@ -88,6 +98,18 @@ var BrowserSession = class {
88
98
  this.activePage = null;
89
99
  });
90
100
  }
101
+ /**
102
+ * Adopt `page` as the streamed active page iff it has a real navigable
103
+ * http(s) URL. Returns whether it was adopted. Guards against flipping the
104
+ * screencast to a transient `about:blank` throwaway target.
105
+ */
106
+ adoptIfNavigable(page) {
107
+ if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
108
+ if (this.activePage === page) return true;
109
+ this.activePage = page;
110
+ this.log.debug("Active page switched to new target");
111
+ return true;
112
+ }
91
113
  /** The current active page, launching Chrome first if needed. */
92
114
  async getActivePage() {
93
115
  await this.ensureLaunched();
@@ -491,18 +513,29 @@ var BrowserSurface = class {
491
513
  }
492
514
  }
493
515
  /**
494
- * Agent tool entry point: hand control to the human and block until they
495
- * release it (RELEASE_CONTROL) or `timeoutMs` elapses. Resolves with the
496
- * final page URL/title so the agent resumes on the same live page.
516
+ * Agent tool entry point: PARK the agent and wait for a human to take over
517
+ * and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
518
+ * `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
519
+ * resumes on the same live page.
520
+ *
521
+ * Crucially this does NOT grant the human turn up front. The turn is granted
522
+ * only when a human ACTUALLY takes control — i.e. when the controlling
523
+ * (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
524
+ * `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
525
+ * "human in control" before anyone had claimed, so a read-only viewer's
526
+ * teardown would fire `releaseToAgent()` and complete the session out from
527
+ * under the user (mislabeled 409 on the real claim; agent only resuming when
528
+ * the tab closed).
497
529
  */
498
530
  async requestHandoff(timeoutMs) {
499
- this.turn.grantHuman();
500
- this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
531
+ if (this.handoff) this.releaseToAgent();
501
532
  return new Promise((resolve) => {
502
533
  const timer = setTimeout(() => {
503
534
  this.handoff = null;
504
- this.turn.releaseHuman();
505
- this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
535
+ if (this.turn.humanInControl) {
536
+ this.turn.releaseHuman();
537
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
538
+ }
506
539
  this.currentPageInfo().then(({ url, title }) => {
507
540
  resolve({
508
541
  released: false,
@@ -519,6 +552,18 @@ var BrowserSurface = class {
519
552
  };
520
553
  });
521
554
  }
555
+ /**
556
+ * Keep the shared Chrome alive across an awaiting-human window. Delegates to
557
+ * the session's hold counter (which also backs per-viewer holds). Idempotent
558
+ * and leak-safe when paired with {@link removeHold} in a `finally`.
559
+ */
560
+ addHold() {
561
+ this.session.addHold();
562
+ }
563
+ /** Release a hold taken by {@link addHold}. */
564
+ removeHold() {
565
+ this.session.removeHold();
566
+ }
522
567
  async shutdown() {
523
568
  await this.pump.stop();
524
569
  await this.injector.detach();
package/dist/index.d.cts CHANGED
@@ -150,6 +150,12 @@ declare class BrowserSession {
150
150
  /** Launch Chrome if not already running (idempotent, concurrent-safe). */
151
151
  ensureLaunched(): Promise<void>;
152
152
  private doLaunch;
153
+ /**
154
+ * Adopt `page` as the streamed active page iff it has a real navigable
155
+ * http(s) URL. Returns whether it was adopted. Guards against flipping the
156
+ * screencast to a transient `about:blank` throwaway target.
157
+ */
158
+ private adoptIfNavigable;
153
159
  /** The current active page, launching Chrome first if needed. */
154
160
  getActivePage(): Promise<Page>;
155
161
  /** Prevent idle shutdown while a viewer or op is active. */
@@ -213,11 +219,29 @@ declare class BrowserSurface implements SurfaceHandler {
213
219
  handleFrame(frame: RemoteFrame): void;
214
220
  closeSession(sessionId: number): void;
215
221
  /**
216
- * Agent tool entry point: hand control to the human and block until they
217
- * release it (RELEASE_CONTROL) or `timeoutMs` elapses. Resolves with the
218
- * final page URL/title so the agent resumes on the same live page.
222
+ * Agent tool entry point: PARK the agent and wait for a human to take over
223
+ * and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
224
+ * `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
225
+ * resumes on the same live page.
226
+ *
227
+ * Crucially this does NOT grant the human turn up front. The turn is granted
228
+ * only when a human ACTUALLY takes control — i.e. when the controlling
229
+ * (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
230
+ * `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
231
+ * "human in control" before anyone had claimed, so a read-only viewer's
232
+ * teardown would fire `releaseToAgent()` and complete the session out from
233
+ * under the user (mislabeled 409 on the real claim; agent only resuming when
234
+ * the tab closed).
219
235
  */
220
236
  requestHandoff(timeoutMs: number): Promise<HandoffResult>;
237
+ /**
238
+ * Keep the shared Chrome alive across an awaiting-human window. Delegates to
239
+ * the session's hold counter (which also backs per-viewer holds). Idempotent
240
+ * and leak-safe when paired with {@link removeHold} in a `finally`.
241
+ */
242
+ addHold(): void;
243
+ /** Release a hold taken by {@link addHold}. */
244
+ removeHold(): void;
221
245
  shutdown(): Promise<void>;
222
246
  private releaseToAgent;
223
247
  private ensureStreaming;
package/dist/index.d.ts CHANGED
@@ -150,6 +150,12 @@ declare class BrowserSession {
150
150
  /** Launch Chrome if not already running (idempotent, concurrent-safe). */
151
151
  ensureLaunched(): Promise<void>;
152
152
  private doLaunch;
153
+ /**
154
+ * Adopt `page` as the streamed active page iff it has a real navigable
155
+ * http(s) URL. Returns whether it was adopted. Guards against flipping the
156
+ * screencast to a transient `about:blank` throwaway target.
157
+ */
158
+ private adoptIfNavigable;
153
159
  /** The current active page, launching Chrome first if needed. */
154
160
  getActivePage(): Promise<Page>;
155
161
  /** Prevent idle shutdown while a viewer or op is active. */
@@ -213,11 +219,29 @@ declare class BrowserSurface implements SurfaceHandler {
213
219
  handleFrame(frame: RemoteFrame): void;
214
220
  closeSession(sessionId: number): void;
215
221
  /**
216
- * Agent tool entry point: hand control to the human and block until they
217
- * release it (RELEASE_CONTROL) or `timeoutMs` elapses. Resolves with the
218
- * final page URL/title so the agent resumes on the same live page.
222
+ * Agent tool entry point: PARK the agent and wait for a human to take over
223
+ * and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
224
+ * `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
225
+ * resumes on the same live page.
226
+ *
227
+ * Crucially this does NOT grant the human turn up front. The turn is granted
228
+ * only when a human ACTUALLY takes control — i.e. when the controlling
229
+ * (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
230
+ * `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
231
+ * "human in control" before anyone had claimed, so a read-only viewer's
232
+ * teardown would fire `releaseToAgent()` and complete the session out from
233
+ * under the user (mislabeled 409 on the real claim; agent only resuming when
234
+ * the tab closed).
219
235
  */
220
236
  requestHandoff(timeoutMs: number): Promise<HandoffResult>;
237
+ /**
238
+ * Keep the shared Chrome alive across an awaiting-human window. Delegates to
239
+ * the session's hold counter (which also backs per-viewer holds). Idempotent
240
+ * and leak-safe when paired with {@link removeHold} in a `finally`.
241
+ */
242
+ addHold(): void;
243
+ /** Release a hold taken by {@link addHold}. */
244
+ removeHold(): void;
221
245
  shutdown(): Promise<void>;
222
246
  private releaseToAgent;
223
247
  private ensureStreaming;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { RemoteFrameType, TurnController, decodeJson, encodeFrame, encodeJsonFrame, encodeScreencastFrame } from "@alfe.ai/remote";
2
- import puppeteer from "puppeteer-core";
2
+ import puppeteer, { TargetType } from "puppeteer-core";
3
3
  //#region src/browser-session.ts
4
4
  /**
5
5
  * BrowserSession — owns the single headless Chrome instance the agent
@@ -9,6 +9,14 @@ import puppeteer from "puppeteer-core";
9
9
  * new tabs are what gets screencast, not a stale main window.
10
10
  */
11
11
  const DEFAULT_IDLE_MS = 300 * 1e3;
12
+ /**
13
+ * A page target is worth streaming only once it has a real, navigable http(s)
14
+ * URL. Transient `about:blank` (and `chrome://`, `data:`, blank string) targets
15
+ * are throwaways we must not flip the screencast to.
16
+ */
17
+ function isNavigablePageUrl(url) {
18
+ return url.startsWith("http://") || url.startsWith("https://");
19
+ }
12
20
  const noopLogger$2 = {
13
21
  info: () => {},
14
22
  warn: () => {},
@@ -52,11 +60,13 @@ var BrowserSession = class {
52
60
  });
53
61
  this.activePage = (await this.browser.pages())[0] ?? await this.browser.newPage();
54
62
  this.browser.on("targetcreated", (target) => {
63
+ if (target.type() !== TargetType.PAGE) return;
55
64
  target.page().then((page) => {
56
- if (page) {
57
- this.activePage = page;
58
- this.log.debug("Active page switched to new target");
59
- }
65
+ if (!page) return;
66
+ if (this.adoptIfNavigable(page)) return;
67
+ page.on("framenavigated", (frame) => {
68
+ if (frame === page.mainFrame()) this.adoptIfNavigable(page);
69
+ });
60
70
  }).catch(() => {});
61
71
  });
62
72
  this.browser.on("disconnected", () => {
@@ -64,6 +74,18 @@ var BrowserSession = class {
64
74
  this.activePage = null;
65
75
  });
66
76
  }
77
+ /**
78
+ * Adopt `page` as the streamed active page iff it has a real navigable
79
+ * http(s) URL. Returns whether it was adopted. Guards against flipping the
80
+ * screencast to a transient `about:blank` throwaway target.
81
+ */
82
+ adoptIfNavigable(page) {
83
+ if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
84
+ if (this.activePage === page) return true;
85
+ this.activePage = page;
86
+ this.log.debug("Active page switched to new target");
87
+ return true;
88
+ }
67
89
  /** The current active page, launching Chrome first if needed. */
68
90
  async getActivePage() {
69
91
  await this.ensureLaunched();
@@ -467,18 +489,29 @@ var BrowserSurface = class {
467
489
  }
468
490
  }
469
491
  /**
470
- * Agent tool entry point: hand control to the human and block until they
471
- * release it (RELEASE_CONTROL) or `timeoutMs` elapses. Resolves with the
472
- * final page URL/title so the agent resumes on the same live page.
492
+ * Agent tool entry point: PARK the agent and wait for a human to take over
493
+ * and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
494
+ * `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
495
+ * resumes on the same live page.
496
+ *
497
+ * Crucially this does NOT grant the human turn up front. The turn is granted
498
+ * only when a human ACTUALLY takes control — i.e. when the controlling
499
+ * (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
500
+ * `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
501
+ * "human in control" before anyone had claimed, so a read-only viewer's
502
+ * teardown would fire `releaseToAgent()` and complete the session out from
503
+ * under the user (mislabeled 409 on the real claim; agent only resuming when
504
+ * the tab closed).
473
505
  */
474
506
  async requestHandoff(timeoutMs) {
475
- this.turn.grantHuman();
476
- this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
507
+ if (this.handoff) this.releaseToAgent();
477
508
  return new Promise((resolve) => {
478
509
  const timer = setTimeout(() => {
479
510
  this.handoff = null;
480
- this.turn.releaseHuman();
481
- this.broadcast(RemoteFrameType.CONTROL_REVOKED);
511
+ if (this.turn.humanInControl) {
512
+ this.turn.releaseHuman();
513
+ this.broadcast(RemoteFrameType.CONTROL_REVOKED);
514
+ }
482
515
  this.currentPageInfo().then(({ url, title }) => {
483
516
  resolve({
484
517
  released: false,
@@ -495,6 +528,18 @@ var BrowserSurface = class {
495
528
  };
496
529
  });
497
530
  }
531
+ /**
532
+ * Keep the shared Chrome alive across an awaiting-human window. Delegates to
533
+ * the session's hold counter (which also backs per-viewer holds). Idempotent
534
+ * and leak-safe when paired with {@link removeHold} in a `finally`.
535
+ */
536
+ addHold() {
537
+ this.session.addHold();
538
+ }
539
+ /** Release a hold taken by {@link addHold}. */
540
+ removeHold() {
541
+ this.session.removeHold();
542
+ }
498
543
  async shutdown() {
499
544
  await this.pump.stop();
500
545
  await this.injector.detach();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/browser",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CDP-driven browser surface for the Alfe interactive remote-control relay — one shared headless Chrome for agent automation + human co-browse takeover",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",