@velanir/openclaw-browserbase 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -27,9 +27,19 @@ browser tool ──cdpUrl http://127.0.0.1:<port>/?token=…──> broker (this
27
27
  - Every session is created with the coworker's persistent context
28
28
  (`persist: true`), the configured timeout/viewport/captcha/recording policy,
29
29
  and `userMetadata` tagging for observability and reaping.
30
- - Disconnect (task end, idle, gateway restart) releases the session. Login
31
- state survives in the context; the next browser action gets a fresh session
32
- that is already logged in.
30
+ - Sessions are created with **`keepAlive: true`**, so on downstream
31
+ disconnect the broker **lingers**: it closes the CDP connection but keeps
32
+ the Browserbase session alive (default 60s) and **re-dials a fresh CDP
33
+ connection** to it on the next browser action — no new mint, no cooldown,
34
+ and tabs/login state intact. OpenClaw's browser tool opens short probe
35
+ connections and reconnects between actions; without linger every probe
36
+ minted (and released) a billable session and the post-release cooldown
37
+ starved the real connection. Because each client gets a brand-new CDP
38
+ connection (fresh id space), a late reply to a previous client's command
39
+ can never reach the next one. Explicit release, idle disconnect, linger
40
+ expiry, and gateway shutdown all release the keepAlive session
41
+ deterministically (keepAlive sessions do not self-terminate, so the reaper
42
+ also backstops crash leftovers).
33
43
  - One session per coworker context at a time (Browserbase forbids concurrent
34
44
  sessions on one context), with a short cooldown after release.
35
45
 
@@ -90,8 +100,8 @@ Notes:
90
100
  See `openclaw.plugin.json` for the full schema. Defaults implement the locked
91
101
  v1 policy: timeout 3600 s, viewport 1280×900, captcha solving on, recording on,
92
102
  `ignoreCertificateErrors: false`, per-coworker context with `persist: true`,
93
- cooldown 10 s, idle disconnect 15 min, reaper every 5 min, no proxies
94
- (`proxies: []` passes through to session creation when set).
103
+ linger 60 s, cooldown 3 s, idle disconnect 15 min, reaper every 5 min, no
104
+ proxies (`proxies: []` passes through to session creation when set).
95
105
 
96
106
  ## Agent tools
97
107
 
package/dist/index.js CHANGED
@@ -25,7 +25,13 @@ function buildSessionPayload(config, mint) {
25
25
  projectId: config.projectId,
26
26
  region: config.region,
27
27
  timeout: Math.round(config.defaults.timeoutSeconds),
28
- keepAlive: false,
28
+ // keepAlive lets the session survive a CDP disconnect so the broker can
29
+ // close the old connection and re-dial a FRESH one for the next client
30
+ // (linger) — no mint, and a fresh CDP id space so stale replies are
31
+ // impossible by construction. The broker is responsible for explicit
32
+ // release (linger expiry, idle, shutdown, reaper), since keepAlive
33
+ // sessions do not self-terminate on disconnect.
34
+ keepAlive: true,
29
35
  // Omit `proxies` entirely when unset so Browserbase applies plain direct
30
36
  // egress rather than an empty routing array.
31
37
  ...config.proxies.length > 0 ? { proxies: config.proxies } : {},
@@ -250,8 +256,9 @@ var DEFAULT_TIMEOUT_SECONDS = 3600;
250
256
  var MIN_TIMEOUT_SECONDS = 60;
251
257
  var MAX_TIMEOUT_SECONDS = 21600;
252
258
  var DEFAULT_VIEWPORT = { width: 1280, height: 900 };
253
- var DEFAULT_CONTEXT_COOLDOWN_SECONDS = 10;
259
+ var DEFAULT_CONTEXT_COOLDOWN_SECONDS = 3;
254
260
  var DEFAULT_CONTEXT_WAIT_MS = 1e4;
261
+ var DEFAULT_LINGER_SECONDS = 60;
255
262
  var DEFAULT_IDLE_DISCONNECT_MINUTES = 15;
256
263
  var DEFAULT_REAPER_INTERVAL_MINUTES = 5;
257
264
  var DEFAULT_API_BASE_URL = "https://api.browserbase.com";
@@ -368,6 +375,7 @@ function normalizeConfig(raw) {
368
375
  waitMs: readNumber(context.waitMs, DEFAULT_CONTEXT_WAIT_MS, 0, 6e4)
369
376
  },
370
377
  proxies: readProxies(root.proxies),
378
+ lingerSeconds: readNumber(root.lingerSeconds, DEFAULT_LINGER_SECONDS, 0, 600),
371
379
  idleDisconnectMinutes: readNumber(root.idleDisconnectMinutes, DEFAULT_IDLE_DISCONNECT_MINUTES, 0, 1440),
372
380
  reaperIntervalMinutes: readNumber(root.reaperIntervalMinutes, DEFAULT_REAPER_INTERVAL_MINUTES, 0, 1440),
373
381
  apiBaseUrl: readString(root.apiBaseUrl) ?? DEFAULT_API_BASE_URL,
@@ -522,6 +530,9 @@ var BrowserbaseBroker = class {
522
530
  idleSweepIntervalMs;
523
531
  server = null;
524
532
  active = null;
533
+ // A live keepAlive session held resumable (by connectUrl) for a window —
534
+ // re-dialed on the next connect (linger) or retry (adoption), released on
535
+ // expiry.
525
536
  adoptable = null;
526
537
  cooldownUntil = 0;
527
538
  timers = [];
@@ -581,8 +592,7 @@ var BrowserbaseBroker = class {
581
592
  clearTimeout(this.adoptable.timer);
582
593
  const held = this.adoptable;
583
594
  this.adoptable = null;
584
- held.upstream?.terminate();
585
- await this.releaseQuietly(held.session.sessionId, "shutdown-unattached");
595
+ await this.releaseQuietly(held.session.sessionId, "shutdown-resumable");
586
596
  }
587
597
  const active = this.active;
588
598
  if (active) {
@@ -734,9 +744,9 @@ var BrowserbaseBroker = class {
734
744
  socket.destroy();
735
745
  return;
736
746
  }
737
- let held;
747
+ let minted;
738
748
  try {
739
- held = await this.acquireSession(leaseId);
749
+ minted = await this.acquireSession(leaseId);
740
750
  } catch (err) {
741
751
  const message = err instanceof Error ? err.message : String(err);
742
752
  this.logger.warn?.(`session acquire failed: ${message}`);
@@ -748,36 +758,33 @@ var BrowserbaseBroker = class {
748
758
  }
749
759
  return;
750
760
  }
751
- const minted = held.session;
752
761
  if (liveness.gone) {
753
762
  liveness.detach();
754
763
  socket.destroy();
755
- this.holdForAdoption(minted, held.upstream);
764
+ this.holdForAdoption(minted);
756
765
  return;
757
766
  }
758
- let upstream = held.upstream && held.upstream.readyState === held.upstream.OPEN ? held.upstream : null;
759
- if (upstream === null) {
760
- held.upstream?.terminate();
761
- try {
762
- upstream = await this.dialUpstream(minted.connectUrl);
763
- } catch (err) {
764
- const message = err instanceof Error ? err.message : String(err);
765
- this.logger.warn?.(`upstream dial failed for session ${minted.sessionId}: ${message}`);
766
- await this.releaseQuietly(minted.sessionId, "upstream-dial-failed");
767
- this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
768
- liveness.detach();
769
- if (liveness.gone) {
770
- socket.destroy();
771
- } else {
772
- destroyWithHttpError(socket, 502, "Bad Gateway", `Browserbase connect failed: ${message}`);
773
- }
774
- return;
767
+ let upstream;
768
+ try {
769
+ upstream = await this.dialUpstream(minted.connectUrl);
770
+ } catch (err) {
771
+ const message = err instanceof Error ? err.message : String(err);
772
+ this.logger.warn?.(`upstream dial failed for session ${minted.sessionId}: ${message}`);
773
+ await this.releaseQuietly(minted.sessionId, "upstream-dial-failed");
774
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
775
+ liveness.detach();
776
+ if (liveness.gone) {
777
+ socket.destroy();
778
+ } else {
779
+ destroyWithHttpError(socket, 502, "Bad Gateway", `Browserbase connect failed: ${message}`);
775
780
  }
781
+ return;
776
782
  }
777
783
  if (liveness.gone) {
778
784
  liveness.detach();
779
785
  socket.destroy();
780
- this.holdForAdoption(minted, upstream);
786
+ upstream.terminate();
787
+ this.holdForAdoption(minted);
781
788
  return;
782
789
  }
783
790
  liveness.detach();
@@ -804,6 +811,7 @@ var BrowserbaseBroker = class {
804
811
  sessionId: minted.sessionId,
805
812
  leaseId: minted.leaseId,
806
813
  contextId: minted.contextId,
814
+ connectUrl: minted.connectUrl,
807
815
  ...minted.expiresAt ? { expiresAt: minted.expiresAt } : {},
808
816
  startedAt: Date.now(),
809
817
  pipe
@@ -813,10 +821,27 @@ var BrowserbaseBroker = class {
813
821
  );
814
822
  }
815
823
  onPipeClosed(sessionId, reason) {
816
- if (this.active?.sessionId !== sessionId) {
824
+ const active = this.active;
825
+ if (active?.sessionId !== sessionId) {
817
826
  return;
818
827
  }
819
828
  this.active = null;
829
+ const downstreamGone = reason === "downstream-closed" || reason === "downstream-error";
830
+ if (downstreamGone && !this.stopping && this.config.lingerSeconds > 0) {
831
+ this.holdForAdoption(
832
+ {
833
+ sessionId: active.sessionId,
834
+ connectUrl: active.connectUrl,
835
+ contextId: active.contextId,
836
+ leaseId: active.leaseId,
837
+ ...active.expiresAt ? { expiresAt: active.expiresAt } : {}
838
+ },
839
+ { windowMs: this.config.lingerSeconds * 1e3, reason: "linger" }
840
+ );
841
+ this.logger.info?.(`session ${sessionId} disconnected (${reason}); lingering for re-dial (keepAlive)`);
842
+ this.onActiveReleased?.();
843
+ return;
844
+ }
820
845
  this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
821
846
  this.logger.info?.(`session ${sessionId} disconnected (${reason}); releasing`);
822
847
  void this.releaseQuietly(sessionId, reason).finally(() => {
@@ -828,7 +853,7 @@ var BrowserbaseBroker = class {
828
853
  const adopted = this.takeAdoptable();
829
854
  if (adopted) {
830
855
  this.logger.info?.(
831
- `adopting held session ${adopted.session.sessionId} (minted for lease ${adopted.session.leaseId}, requested by lease ${leaseId})`
856
+ `resuming held session ${adopted.sessionId} (minted for lease ${adopted.leaseId}, requested by lease ${leaseId})`
832
857
  );
833
858
  return adopted;
834
859
  }
@@ -845,7 +870,7 @@ var BrowserbaseBroker = class {
845
870
  if (cooldownRemaining > 0) {
846
871
  await sleep(cooldownRemaining);
847
872
  }
848
- return { session: await this.mintSession(leaseId) };
873
+ return this.mintSession(leaseId);
849
874
  }
850
875
  async mintSession(leaseId) {
851
876
  const contextId = await this.ensureContext();
@@ -907,37 +932,21 @@ var BrowserbaseBroker = class {
907
932
  });
908
933
  });
909
934
  }
910
- holdForAdoption(session, upstream) {
935
+ holdForAdoption(session, opts = {}) {
936
+ const reason = opts.reason ?? "adoption";
937
+ const windowMs = opts.windowMs ?? ADOPTION_WINDOW_MS;
911
938
  this.logger.info?.(
912
- `holding session ${session.sessionId} for adoption (client disconnected mid-handshake, upstream ${upstream ? "open" : "not dialed"})`
939
+ `holding session ${session.sessionId} (${reason}, ${Math.round(windowMs / 1e3)}s window) for re-dial`
913
940
  );
914
941
  const timer = setTimeout(() => {
915
942
  if (this.adoptable?.session.sessionId === session.sessionId) {
916
- const held = this.adoptable;
917
943
  this.adoptable = null;
918
- held.upstream?.terminate();
919
- void this.releaseQuietly(session.sessionId, "adoption-expired");
944
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
945
+ void this.releaseQuietly(session.sessionId, `${reason}-expired`);
920
946
  }
921
- }, ADOPTION_WINDOW_MS);
947
+ }, windowMs);
922
948
  timer.unref();
923
- let onUpstreamLost;
924
- if (upstream) {
925
- onUpstreamLost = () => {
926
- if (this.adoptable?.session.sessionId === session.sessionId && this.adoptable.upstream === upstream) {
927
- clearTimeout(this.adoptable.timer);
928
- this.adoptable = null;
929
- void this.releaseQuietly(session.sessionId, "adoption-upstream-lost");
930
- }
931
- };
932
- upstream.once("close", onUpstreamLost);
933
- upstream.once("error", onUpstreamLost);
934
- }
935
- this.adoptable = {
936
- session,
937
- ...upstream ? { upstream } : {},
938
- ...onUpstreamLost ? { onUpstreamLost } : {},
939
- timer
940
- };
949
+ this.adoptable = { session, heldReason: reason, timer };
941
950
  }
942
951
  takeAdoptable() {
943
952
  const held = this.adoptable;
@@ -945,15 +954,8 @@ var BrowserbaseBroker = class {
945
954
  return null;
946
955
  }
947
956
  clearTimeout(held.timer);
948
- if (held.upstream && held.onUpstreamLost) {
949
- held.upstream.off("close", held.onUpstreamLost);
950
- held.upstream.off("error", held.onUpstreamLost);
951
- }
952
957
  this.adoptable = null;
953
- return {
954
- session: held.session,
955
- ...held.upstream ? { upstream: held.upstream } : {}
956
- };
958
+ return held.session;
957
959
  }
958
960
  // ------------------------------------------------------------- cleanup --
959
961
  async releaseQuietly(sessionId, reason) {
@@ -1019,24 +1021,31 @@ var BrowserbaseBroker = class {
1019
1021
  startedAt: new Date(active.startedAt).toISOString(),
1020
1022
  ...active.expiresAt ? { expiresAt: active.expiresAt } : {},
1021
1023
  idleMs: Date.now() - active.pipe.lastActivityAt
1022
- } : null
1024
+ } : null,
1025
+ held: this.adoptable ? { sessionId: this.adoptable.session.sessionId, reason: this.adoptable.heldReason } : null
1023
1026
  };
1024
1027
  }
1025
1028
  async getLiveViewUrls() {
1026
- const active = this.active;
1027
- if (!active || active.pipe.closed) {
1029
+ const sessionId = this.active && !this.active.pipe.closed ? this.active.sessionId : this.adoptable ? this.adoptable.session.sessionId : null;
1030
+ if (!sessionId) {
1028
1031
  return null;
1029
1032
  }
1030
- return this.client.getDebugUrls(active.sessionId);
1033
+ return this.client.getDebugUrls(sessionId);
1031
1034
  }
1032
1035
  /** Returns true when there was an active session to release. */
1033
1036
  releaseActiveSession() {
1034
1037
  const active = this.active;
1035
- if (!active || active.pipe.closed) {
1036
- return false;
1038
+ if (active && !active.pipe.closed) {
1039
+ active.pipe.close("manual-release");
1040
+ return true;
1037
1041
  }
1038
- active.pipe.close("manual-release");
1039
- return true;
1042
+ const held = this.takeAdoptable();
1043
+ if (held) {
1044
+ this.cooldownUntil = Date.now() + this.config.context.cooldownSeconds * 1e3;
1045
+ void this.releaseQuietly(held.sessionId, "manual-release");
1046
+ return true;
1047
+ }
1048
+ return false;
1040
1049
  }
1041
1050
  };
1042
1051
 
@@ -1063,7 +1072,7 @@ function createBrowserbaseTools(getBroker) {
1063
1072
  const status = broker.getStatus();
1064
1073
  if (!status.active) {
1065
1074
  const lines2 = [
1066
- NO_SESSION,
1075
+ status.held?.reason === "linger" ? `Browserbase session ${status.held.sessionId} is paused (keepAlive) for resume \u2014 the next browser action reconnects to it with your tabs and login state intact.` : NO_SESSION,
1067
1076
  `Region: ${status.region}. Recording: ${status.recordSession ? "on" : "off"}.`,
1068
1077
  status.contextId ? `Browser context: ${status.contextId}` : "Browser context: created on first use."
1069
1078
  ];
@@ -8,7 +8,6 @@
8
8
  "configSchema": {
9
9
  "type": "object",
10
10
  "additionalProperties": false,
11
- "required": ["projectId", "apiKey", "listenPort"],
12
11
  "properties": {
13
12
  "projectId": {
14
13
  "type": "string",
@@ -20,7 +19,12 @@
20
19
  },
21
20
  "region": {
22
21
  "type": "string",
23
- "enum": ["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"],
22
+ "enum": [
23
+ "us-west-2",
24
+ "us-east-1",
25
+ "eu-central-1",
26
+ "ap-southeast-1"
27
+ ],
24
28
  "default": "us-west-2"
25
29
  },
26
30
  "listenPort": {
@@ -38,7 +42,9 @@
38
42
  },
39
43
  "metadata": {
40
44
  "type": "object",
41
- "additionalProperties": { "type": "string" }
45
+ "additionalProperties": {
46
+ "type": "string"
47
+ }
42
48
  },
43
49
  "defaults": {
44
50
  "type": "object",
@@ -54,28 +60,63 @@
54
60
  "type": "object",
55
61
  "additionalProperties": false,
56
62
  "properties": {
57
- "width": { "type": "number", "minimum": 320, "maximum": 3840 },
58
- "height": { "type": "number", "minimum": 320, "maximum": 2160 }
63
+ "width": {
64
+ "type": "number",
65
+ "minimum": 320,
66
+ "maximum": 3840
67
+ },
68
+ "height": {
69
+ "type": "number",
70
+ "minimum": 320,
71
+ "maximum": 2160
72
+ }
59
73
  }
60
74
  },
61
- "solveCaptchas": { "type": "boolean", "default": true },
62
- "recordSession": { "type": "boolean", "default": true },
63
- "logSession": { "type": "boolean", "default": true },
64
- "ignoreCertificateErrors": { "type": "boolean", "default": false }
75
+ "solveCaptchas": {
76
+ "type": "boolean",
77
+ "default": true
78
+ },
79
+ "recordSession": {
80
+ "type": "boolean",
81
+ "default": true
82
+ },
83
+ "logSession": {
84
+ "type": "boolean",
85
+ "default": true
86
+ },
87
+ "ignoreCertificateErrors": {
88
+ "type": "boolean",
89
+ "default": false
90
+ }
65
91
  }
66
92
  },
67
93
  "context": {
68
94
  "type": "object",
69
95
  "additionalProperties": false,
70
96
  "properties": {
71
- "persist": { "type": "boolean", "default": true },
72
- "cooldownSeconds": { "type": "number", "default": 10, "minimum": 0, "maximum": 120 },
73
- "waitMs": { "type": "number", "default": 10000, "minimum": 0, "maximum": 60000 }
97
+ "persist": {
98
+ "type": "boolean",
99
+ "default": true
100
+ },
101
+ "cooldownSeconds": {
102
+ "type": "number",
103
+ "default": 3,
104
+ "minimum": 0,
105
+ "maximum": 120
106
+ },
107
+ "waitMs": {
108
+ "type": "number",
109
+ "default": 10000,
110
+ "minimum": 0,
111
+ "maximum": 60000
112
+ }
74
113
  }
75
114
  },
76
115
  "proxies": {
77
116
  "type": "array",
78
- "items": { "type": "object" },
117
+ "items": {
118
+ "type": "object"
119
+ },
79
120
  "default": []
80
121
  },
81
122
  "idleDisconnectMinutes": {
@@ -87,6 +128,12 @@
87
128
  "type": "number",
88
129
  "default": 5,
89
130
  "minimum": 0
131
+ },
132
+ "lingerSeconds": {
133
+ "type": "number",
134
+ "default": 60,
135
+ "minimum": 0,
136
+ "maximum": 600
90
137
  }
91
138
  }
92
139
  },
@@ -120,6 +167,10 @@
120
167
  "idleDisconnectMinutes": {
121
168
  "label": "Idle Disconnect (min)",
122
169
  "help": "Close and release the Browserbase session after this many minutes without CDP traffic. 0 disables. Login state survives via the persistent context."
170
+ },
171
+ "lingerSeconds": {
172
+ "label": "Linger (s)",
173
+ "help": "Keep the session and its connection alive after a disconnect so the next browser action re-attaches instantly instead of minting a new session. 0 releases immediately."
123
174
  }
124
175
  }
125
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velanir/openclaw-browserbase",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw plugin that brokers Browserbase sessions behind a loopback CDP endpoint: explicit session creation with per-coworker persistent contexts, deterministic release, and stale-session reaping.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",