cascade-ai 0.78.0 → 0.79.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.js CHANGED
@@ -11623,7 +11623,7 @@ function reconcilePrice(model, live) {
11623
11623
  }
11624
11624
 
11625
11625
  // src/constants.ts
11626
- var CASCADE_VERSION = "0.78.0";
11626
+ var CASCADE_VERSION = "0.79.0";
11627
11627
  var CASCADE_CONFIG_DIR = ".cascade";
11628
11628
  var CASCADE_MD_FILE = "CASCADE.md";
11629
11629
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -16098,6 +16098,31 @@ var ToolsConfigSchema = z.object({
16098
16098
  * stop control for the run that is acting.
16099
16099
  */
16100
16100
  agentBrowserControl: z.boolean().default(false),
16101
+ /**
16102
+ * Where a HOSTED run gets a browser, since it has none of its own.
16103
+ *
16104
+ * The desktop drives the Chromium the user is signed into and can watch. A
16105
+ * server has neither, and running one inside the container is wrong twice
16106
+ * over — page JavaScript issues requests no URL check ever sees, from inside
16107
+ * the trust boundary, and a session costs 200-400 MB. So this points at a
16108
+ * browser somebody else runs.
16109
+ *
16110
+ * Absent by default: no provider, no capability. There is nothing to turn
16111
+ * off because nothing is registered until an endpoint is supplied.
16112
+ */
16113
+ remoteBrowser: z.object({
16114
+ /** `cdp` for an endpoint you run; `steel` for a Steel deployment. */
16115
+ provider: z.enum(["cdp", "steel"]).optional(),
16116
+ /** The websocket endpoint for `cdp`, or the API base for `steel`. */
16117
+ url: z.string().optional(),
16118
+ apiKey: z.string().optional(),
16119
+ /**
16120
+ * Concurrent sessions. One by default and deliberately so: every session
16121
+ * is billed, and a wave of workers each opening their own is a cost the
16122
+ * user did not choose. Raising it is a decision, not a default.
16123
+ */
16124
+ maxSessions: z.number().int().min(1).max(16).default(1)
16125
+ }).optional(),
16101
16126
  mcpServers: z.array(McpServerConfigSchema).optional(),
16102
16127
  mcpTrusted: z.array(z.string()).optional(),
16103
16128
  /** Web search backends — at least one should be configured for best results */
@@ -31366,8 +31391,38 @@ ${input}` }],
31366
31391
  * run — it is simply not there.
31367
31392
  */
31368
31393
  setBrowserController(controller, release) {
31394
+ this.registerBrowserControl(controller, release, this.config.tools?.agentBrowserControl === true);
31395
+ }
31396
+ /**
31397
+ * Wire a REMOTE browser — one the host connects to rather than one the user
31398
+ * is signed into.
31399
+ *
31400
+ * A separate entry point because the enablement is a different question, and
31401
+ * conflating them made the hosted capability dead on arrival: this went out
31402
+ * calling `setBrowserController`, which gates on `agentBrowserControl`, and
31403
+ * that flag means "drive the browser I am signed into on this machine". It
31404
+ * defaults to false and has no meaning on a server, so the hosted tool was
31405
+ * never registered at all — the feature looked shipped and did nothing.
31406
+ *
31407
+ * Configuring a provider IS the opt-in here. It takes a deliberate act with
31408
+ * an endpoint and usually a paid account, and the browser it reaches is a
31409
+ * throwaway signed into nothing — a smaller consent than handing over the
31410
+ * session the user is already authenticated in, and one the desktop flag
31411
+ * cannot stand in for.
31412
+ */
31413
+ setRemoteBrowserController(controller, release) {
31414
+ this.registerBrowserControl(controller, release, Boolean(this.config.tools?.remoteBrowser?.provider));
31415
+ }
31416
+ /**
31417
+ * The gates every surface shares, with only enablement differing.
31418
+ *
31419
+ * One place, because the two callers above are exactly the sibling-writer
31420
+ * shape that has already drifted twice in this feature: a rule added to one
31421
+ * and not the other is silent on the surface that missed it.
31422
+ */
31423
+ registerBrowserControl(controller, release, enabled) {
31369
31424
  if (this.unattended) return;
31370
- if (!this.config.tools?.agentBrowserControl) return;
31425
+ if (!enabled) return;
31371
31426
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31372
31427
  this.toolRegistry.register(new BrowserControlTool(controller, release));
31373
31428
  }
@@ -32712,6 +32767,20 @@ function applySettingsPayload(config, data) {
32712
32767
  if (data.webSearch.tavilyApiKey) next.tavilyApiKey = data.webSearch.tavilyApiKey;
32713
32768
  config.tools.webSearch = next;
32714
32769
  }
32770
+ if (data.remoteBrowser && typeof data.remoteBrowser === "object") {
32771
+ const prior = config.tools.remoteBrowser ?? {};
32772
+ const next = { ...prior };
32773
+ if (data.remoteBrowser.provider === "cdp" || data.remoteBrowser.provider === "steel") {
32774
+ next.provider = data.remoteBrowser.provider;
32775
+ }
32776
+ if (typeof data.remoteBrowser.url === "string") next.url = data.remoteBrowser.url.trim() || void 0;
32777
+ if (data.remoteBrowser.apiKey) next.apiKey = data.remoteBrowser.apiKey;
32778
+ const n = data.remoteBrowser.maxSessions;
32779
+ if (typeof n === "number" && Number.isFinite(n)) {
32780
+ next.maxSessions = Math.min(Math.max(Math.floor(n), 1), 16);
32781
+ }
32782
+ config.tools.remoteBrowser = next;
32783
+ }
32715
32784
  if (data.advanced && typeof data.advanced === "object") {
32716
32785
  const a = data.advanced;
32717
32786
  const num = (v, min, max) => {
@@ -32758,6 +32827,7 @@ function settingsSnapshot(config) {
32758
32827
  if (p?.type && p?.baseUrl && p.type !== "azure") endpoints[p.type] = p.baseUrl;
32759
32828
  }
32760
32829
  const ws = config.tools?.webSearch ?? {};
32830
+ const rb = config.tools?.remoteBrowser ?? {};
32761
32831
  return {
32762
32832
  models: config.models ?? {},
32763
32833
  budget: {
@@ -32787,6 +32857,15 @@ function settingsSnapshot(config) {
32787
32857
  hasBraveKey: typeof ws.braveApiKey === "string" && ws.braveApiKey.length > 0,
32788
32858
  hasTavilyKey: typeof ws.tavilyApiKey === "string" && ws.tavilyApiKey.length > 0
32789
32859
  },
32860
+ // `hasApiKey`, never the key. The panel needs to show whether one is set;
32861
+ // sending the value back would put a live credential in every settings
32862
+ // response, where it reaches any client that can read them.
32863
+ remoteBrowser: {
32864
+ provider: rb.provider,
32865
+ url: rb.url,
32866
+ hasApiKey: typeof rb.apiKey === "string" && rb.apiKey.length > 0,
32867
+ maxSessions: rb.maxSessions
32868
+ },
32790
32869
  advanced: {
32791
32870
  autonomy: config.autonomy,
32792
32871
  planApproval: config.planApproval,
@@ -34897,6 +34976,694 @@ async function discoverMcpTools(servers) {
34897
34976
  return results;
34898
34977
  }
34899
34978
 
34979
+ // src/browser/lease.ts
34980
+ var LEASE_DEADLOCK_CEILING_MS = 3e5;
34981
+ var QUEUE_WAIT_MS = 18e4;
34982
+ var MAX_QUEUED = 8;
34983
+ var ACTION_HANDOFF_MS = 2e3;
34984
+ var ACTION_HANDOFF_POLL_MS = 25;
34985
+ var BrowserLease = class {
34986
+ actor = null;
34987
+ session = null;
34988
+ ceilingTimer = null;
34989
+ waiting = [];
34990
+ lifecycleWired = false;
34991
+ /**
34992
+ * The action holding the browser, if any.
34993
+ *
34994
+ * Needed alongside the lease for a reason the lease cannot cover: the lease
34995
+ * is RE-ENTRANT for its holder — that is what makes a sequence a sequence —
34996
+ * so nothing in it stops ONE actor running two actions at once. A retry
34997
+ * racing the call it retried is exactly that shape.
34998
+ *
34999
+ * A symbol because it identifies the individual call: only the action that
35000
+ * took the slot may release it.
35001
+ */
35002
+ action = null;
35003
+ isRevoked;
35004
+ onChange;
35005
+ constructor(options = {}) {
35006
+ this.isRevoked = options.isRevoked ?? (() => false);
35007
+ this.onChange = options.onChange ?? (() => {
35008
+ });
35009
+ }
35010
+ get holderActor() {
35011
+ return this.actor;
35012
+ }
35013
+ get holderSession() {
35014
+ return this.session;
35015
+ }
35016
+ get queueDepth() {
35017
+ return this.waiting.length;
35018
+ }
35019
+ get actionInFlight() {
35020
+ return this.action !== null;
35021
+ }
35022
+ /**
35023
+ * Declare that this host reports worker terminal states.
35024
+ *
35025
+ * When it does, timers stop being ownership boundaries entirely. When it does
35026
+ * not — an embedder that wired a controller but not the release — the
35027
+ * fallbacks stay, because something has to break a deadlock.
35028
+ */
35029
+ setLifecycleReleaseWired(on) {
35030
+ this.lifecycleWired = on;
35031
+ }
35032
+ /**
35033
+ * Take the browser for this actor, waiting in line if someone else holds it.
35034
+ *
35035
+ * Re-entrant for the actor that already holds it: that is the sequence case —
35036
+ * the same worker coming back for its next step must not queue behind itself.
35037
+ */
35038
+ async acquire(actorId, sessionId, signal) {
35039
+ if (this.actor === null || this.actor === actorId) {
35040
+ this.actor = actorId;
35041
+ this.session = sessionId;
35042
+ this.armCeiling();
35043
+ return "granted";
35044
+ }
35045
+ if (this.waiting.length >= MAX_QUEUED) return "busy";
35046
+ return this.enqueue(actorId, sessionId, signal);
35047
+ }
35048
+ /**
35049
+ * One worker has finished its whole sequence, so its hold ends.
35050
+ *
35051
+ * THIS is the semantic release. It comes from the worker's own terminal path,
35052
+ * so it means "this actor will never ask again" — which a timer can never
35053
+ * mean about a worker that is merely thinking.
35054
+ *
35055
+ * Idempotent, and safe for an actor that never touched the browser.
35056
+ */
35057
+ actorEnded(actorId) {
35058
+ if (!actorId) return;
35059
+ for (let i = this.waiting.length - 1; i >= 0; i--) {
35060
+ const w = this.waiting[i];
35061
+ if (w.actorId === actorId) w.settle("cancelled");
35062
+ }
35063
+ this.releaseIfHeldBy(actorId);
35064
+ this.onChange();
35065
+ }
35066
+ /**
35067
+ * Give the browser up when about to return without using it.
35068
+ *
35069
+ * Two guards, both load-bearing. Never while an action is in flight, because
35070
+ * the lease is RE-ENTRANT: the action still running may be this same actor's
35071
+ * previous step, and taking the lease off it hands the page to a queued
35072
+ * worker mid-sequence. And only while still the holder, because by the time a
35073
+ * refusal path runs a Stop may already have handed the lease on.
35074
+ */
35075
+ releaseIfHeldBy(actorId) {
35076
+ if (this.action) return;
35077
+ if (this.actor === actorId) this.release();
35078
+ }
35079
+ /** Drop every queued waiter belonging to a run, and the lease if it holds it. */
35080
+ dropRun(sessionId) {
35081
+ for (let i = this.waiting.length - 1; i >= 0; i--) {
35082
+ const w = this.waiting[i];
35083
+ if (w.sessionId === sessionId) w.settle("cancelled");
35084
+ }
35085
+ if (this.session === sessionId) this.release();
35086
+ }
35087
+ /** Turn everyone away — the capability itself has gone. */
35088
+ dropAll() {
35089
+ for (let i = this.waiting.length - 1; i >= 0; i--) this.waiting[i].settle("off");
35090
+ }
35091
+ /** Hand the browser to the next actor in line, if any. */
35092
+ release() {
35093
+ this.clearCeiling();
35094
+ this.actor = null;
35095
+ this.session = null;
35096
+ let next = this.waiting.shift();
35097
+ while (next && this.isRevoked(next.sessionId)) {
35098
+ next.settle("cancelled");
35099
+ next = this.waiting.shift();
35100
+ }
35101
+ if (next) {
35102
+ this.actor = next.actorId;
35103
+ this.session = next.sessionId;
35104
+ this.armCeiling();
35105
+ next.settle("granted");
35106
+ }
35107
+ this.onChange();
35108
+ }
35109
+ /** Arm the deadlock ceiling. Not a release schedule — see the constant. */
35110
+ armCeiling() {
35111
+ this.clearCeiling();
35112
+ if (this.lifecycleWired) return;
35113
+ this.ceilingTimer = setTimeout(() => this.release(), LEASE_DEADLOCK_CEILING_MS);
35114
+ this.ceilingTimer.unref?.();
35115
+ }
35116
+ clearCeiling() {
35117
+ if (this.ceilingTimer) {
35118
+ clearTimeout(this.ceilingTimer);
35119
+ this.ceilingTimer = null;
35120
+ }
35121
+ }
35122
+ /** Take the per-action slot, or null when one is already running. */
35123
+ beginAction() {
35124
+ if (this.action) return null;
35125
+ const token = /* @__PURE__ */ Symbol("browser-action");
35126
+ this.action = token;
35127
+ return token;
35128
+ }
35129
+ /** Release the slot, but only for the action that took it. */
35130
+ endAction(token) {
35131
+ if (this.action !== token) return false;
35132
+ this.action = null;
35133
+ return true;
35134
+ }
35135
+ /**
35136
+ * Wait, briefly, for an outgoing action to unwind.
35137
+ *
35138
+ * A release can hand the browser on while the previous holder's abort is
35139
+ * still propagating through an awaited navigation. That is a handoff, not a
35140
+ * conflict, so it is waited out rather than refused — but bounded, because a
35141
+ * wedged action must not pin the queue.
35142
+ */
35143
+ async awaitActionSlot() {
35144
+ for (let waited = 0; waited < ACTION_HANDOFF_MS; waited += ACTION_HANDOFF_POLL_MS) {
35145
+ if (!this.action) return true;
35146
+ await new Promise((r) => setTimeout(r, ACTION_HANDOFF_POLL_MS));
35147
+ }
35148
+ return this.action === null;
35149
+ }
35150
+ /**
35151
+ * Queue one actor, resolving when it gets the browser or is turned away.
35152
+ *
35153
+ * `settle` is wrapped so it runs at most once and always takes the waiter out
35154
+ * of the line: the timeout, an abort and a handoff all race, and settling
35155
+ * twice would silently leave a dead entry holding a queue slot.
35156
+ */
35157
+ enqueue(actorId, sessionId, signal) {
35158
+ return new Promise((resolve2) => {
35159
+ let done = false;
35160
+ const waiter = {
35161
+ actorId,
35162
+ sessionId,
35163
+ settle: (result) => {
35164
+ if (done) return;
35165
+ done = true;
35166
+ clearTimeout(timer);
35167
+ signal?.removeEventListener("abort", giveUp);
35168
+ const i = this.waiting.indexOf(waiter);
35169
+ if (i >= 0) this.waiting.splice(i, 1);
35170
+ resolve2(result);
35171
+ }
35172
+ };
35173
+ const timer = this.lifecycleWired ? void 0 : setTimeout(() => waiter.settle("busy"), QUEUE_WAIT_MS);
35174
+ timer?.unref?.();
35175
+ const giveUp = () => waiter.settle("cancelled");
35176
+ signal?.addEventListener("abort", giveUp, { once: true });
35177
+ this.waiting.push(waiter);
35178
+ });
35179
+ }
35180
+ };
35181
+
35182
+ // src/browser/remote/controller.ts
35183
+ var ACTION_TIMEOUT_MS = 3e4;
35184
+ var RemoteBrowserController = class {
35185
+ provider;
35186
+ maxSessions;
35187
+ /**
35188
+ * Live-view listeners, one per run.
35189
+ *
35190
+ * A single callback was fine while each run had its own controller. Now the
35191
+ * controller is shared across a deployment, so one callback would send run
35192
+ * A's live-view URL to whichever run registered last — and that URL is a
35193
+ * bearer capability for a browser somebody else is driving.
35194
+ */
35195
+ liveViewListeners = /* @__PURE__ */ new Map();
35196
+ /** An embedder that wants every run's live view, told which run each is. */
35197
+ onLiveViewAll;
35198
+ runs = /* @__PURE__ */ new Map();
35199
+ /**
35200
+ * Runs that have a pool slot but no browser yet.
35201
+ *
35202
+ * Counted alongside `runs` against the cap. Without it the limit was a
35203
+ * check-then-act across four awaits, and simultaneous first uses both won.
35204
+ */
35205
+ opening = /* @__PURE__ */ new Set();
35206
+ /**
35207
+ * A run's stop signal, created when its slot is reserved.
35208
+ *
35209
+ * Before this, Stop looked only in `runs` — so during the first `open()`,
35210
+ * while the run existed only in `opening` and four awaits were in flight, it
35211
+ * found nothing and returned. The open then completed, built a FRESH
35212
+ * unaborted controller, and the click the user had stopped went ahead. The
35213
+ * signal has to exist before the browser does.
35214
+ */
35215
+ aborts = /* @__PURE__ */ new Map();
35216
+ /**
35217
+ * Runs whose provider session is being released right now.
35218
+ *
35219
+ * `stopRun`, `endRun`, `releaseIfIdle`, and the stale-page branch of `open`
35220
+ * all delete a run from `runs` synchronously and only THEN await
35221
+ * `disposeRun` settling — the browser disconnect, and `provider.endSession`,
35222
+ * which for Steel is a real HTTP `/release` call. Between that delete and
35223
+ * the call actually finishing, the admission check in `open()` — which
35224
+ * counts only `runs` and `opening` — saw the slot as free, so a second run
35225
+ * could pass admission and call `createSession` before the first run's
35226
+ * session was truly released. At `maxSessions: 1` that is two live, BILLED
35227
+ * provider sessions at once, exactly what the pool exists to prevent.
35228
+ * Counting this set alongside the other two closes the window.
35229
+ *
35230
+ * Keyed by run id, not a counter: every one of those four call sites checks
35231
+ * the run is still IN `runs` before deleting it and starting teardown, and
35232
+ * that delete is synchronous, so at most one teardown can ever be in flight
35233
+ * for a given run id at a time — a second caller finds the entry already
35234
+ * gone and never starts a competing one. The entry is removed only once its
35235
+ * own teardown settles, so a later, genuine re-open of the same run id
35236
+ * never collides with one still in flight.
35237
+ */
35238
+ closing = /* @__PURE__ */ new Set();
35239
+ /** Runs the user has stopped, by run id — same meaning as on the desktop. */
35240
+ revoked = /* @__PURE__ */ new Set();
35241
+ /**
35242
+ * One lease PER RUN, because there is one browser per run.
35243
+ *
35244
+ * The desktop has a single lease because it has a single browser. Here each
35245
+ * run gets its own session, so a global lease would make run B queue behind
35246
+ * run A for a browser it was never going to use — and with no queue timeout,
35247
+ * queue for as long as A lasts. What limits runs against each other is the
35248
+ * session POOL, not the lease.
35249
+ */
35250
+ leases = /* @__PURE__ */ new Map();
35251
+ constructor(options) {
35252
+ this.provider = options.provider;
35253
+ const asked = Math.max(1, options.maxSessions ?? 1);
35254
+ this.maxSessions = options.provider.isolatesSessions ? asked : 1;
35255
+ this.onLiveViewAll = options.onLiveView;
35256
+ }
35257
+ leaseFor(runId) {
35258
+ let lease = this.leases.get(runId);
35259
+ if (!lease) {
35260
+ lease = new BrowserLease({ isRevoked: (id) => this.revoked.has(id) });
35261
+ lease.setLifecycleReleaseWired(true);
35262
+ this.leases.set(runId, lease);
35263
+ }
35264
+ return lease;
35265
+ }
35266
+ /** The controller to hand to `Cascade.setBrowserController`. */
35267
+ get controller() {
35268
+ return (action, context) => this.act(action, context);
35269
+ }
35270
+ /** Watch one run's browser. The URL never goes to any other run's listener. */
35271
+ onLiveViewFor(runKey, listener) {
35272
+ this.liveViewListeners.set(runKey, listener);
35273
+ }
35274
+ offLiveViewFor(runKey) {
35275
+ this.liveViewListeners.delete(runKey);
35276
+ }
35277
+ /**
35278
+ * Tell a run's listener what its browser situation is.
35279
+ *
35280
+ * `active` is separate from the URL and both are needed: a provider with no
35281
+ * live view is attached-but-unwatchable, and a finished run is not attached
35282
+ * at all. Both have no URL, so a listener given only the URL cannot tell them
35283
+ * apart — and would drop the Stop control for the first as if it were the
35284
+ * second.
35285
+ */
35286
+ announceLiveView(runId, liveViewUrl, active) {
35287
+ this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
35288
+ this.onLiveViewAll?.(runId, liveViewUrl);
35289
+ }
35290
+ /**
35291
+ * Hand back a session the run turned out not to want.
35292
+ *
35293
+ * A browser opened for an action that is then refused would otherwise stay
35294
+ * allocated and billed until the whole run ended, for a run that has just
35295
+ * been stopped.
35296
+ */
35297
+ async releaseIfIdle(runId) {
35298
+ const held = this.runs.get(runId);
35299
+ if (!held) return;
35300
+ this.runs.delete(runId);
35301
+ this.closing.add(runId);
35302
+ await this.teardown(runId, held);
35303
+ }
35304
+ /** Release every run's session. For when the deployment's config changes. */
35305
+ async dispose() {
35306
+ const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening]);
35307
+ await Promise.all([...ids].map((runId) => this.endRun(runId)));
35308
+ }
35309
+ /** A worker finished; it will never ask for the browser again. */
35310
+ actorEnded(actorId) {
35311
+ for (const lease of this.leases.values()) lease.actorEnded(actorId);
35312
+ }
35313
+ /** The user stopped this run. Refuses further actions and clears its queue. */
35314
+ stopRun(runId) {
35315
+ this.revoked.add(runId);
35316
+ this.leases.get(runId)?.dropRun(runId);
35317
+ this.aborts.get(runId)?.abort();
35318
+ const held = this.runs.get(runId);
35319
+ if (!held) return;
35320
+ this.runs.delete(runId);
35321
+ this.closing.add(runId);
35322
+ void this.teardown(runId, held);
35323
+ }
35324
+ /** A run ended: release its browser rather than pay for an idle session. */
35325
+ async endRun(runId) {
35326
+ this.leases.get(runId)?.dropRun(runId);
35327
+ this.aborts.get(runId)?.abort();
35328
+ const held = this.runs.get(runId);
35329
+ if (!held) {
35330
+ this.forgetRun(runId);
35331
+ return;
35332
+ }
35333
+ this.runs.delete(runId);
35334
+ this.closing.add(runId);
35335
+ await this.teardown(runId, held);
35336
+ this.forgetRun(runId);
35337
+ }
35338
+ /** Forget a finished run entirely, so nothing accumulates per run. */
35339
+ forgetRun(runId) {
35340
+ this.revoked.delete(runId);
35341
+ this.leases.delete(runId);
35342
+ this.aborts.delete(runId);
35343
+ this.liveViewListeners.delete(runId);
35344
+ }
35345
+ /**
35346
+ * Let go of one run's browser.
35347
+ *
35348
+ * Best-effort throughout: a run is already over by the time this runs, and a
35349
+ * provider that is briefly unreachable must not turn that into a failure the
35350
+ * user sees. The provider's own idle timeout collects anything left.
35351
+ */
35352
+ async disposeRun(runId, held) {
35353
+ held.abort.abort();
35354
+ this.announceLiveView(runId, void 0, false);
35355
+ await held.ownedContext?.close().catch(() => {
35356
+ });
35357
+ await held.browser.close().catch(() => {
35358
+ });
35359
+ await this.provider.endSession(held.session.id).catch(() => {
35360
+ });
35361
+ }
35362
+ /**
35363
+ * Run `disposeRun`, with the run id counted in `closing` until it settles.
35364
+ *
35365
+ * The caller adds the id to `closing` itself, synchronously, in the same
35366
+ * breath as deleting the run from `runs` — see the field comment. This just
35367
+ * guarantees the removal happens, success or failure, so a disposal that
35368
+ * throws cannot leave the slot counted against the pool forever.
35369
+ */
35370
+ async teardown(runId, held) {
35371
+ try {
35372
+ await this.disposeRun(runId, held);
35373
+ } finally {
35374
+ this.closing.delete(runId);
35375
+ }
35376
+ }
35377
+ async act(action, context) {
35378
+ const runId = context.sessionId;
35379
+ if (!runId) {
35380
+ return { ok: false, detail: "This browser action arrived without a run identity, so it was not run." };
35381
+ }
35382
+ const actor = context.actorId || runId;
35383
+ if (this.revoked.has(runId)) {
35384
+ return { ok: false, detail: "The user stopped browser control for this run." };
35385
+ }
35386
+ if (context.signal?.aborted) {
35387
+ return { ok: false, detail: "The run was cancelled." };
35388
+ }
35389
+ const planned = this.runs.get(runId)?.generation ?? 0;
35390
+ const lease_ = this.leaseFor(runId);
35391
+ const lease = await lease_.acquire(actor, runId, context.signal);
35392
+ if (lease !== "granted") {
35393
+ return { ok: false, detail: refusal(lease) };
35394
+ }
35395
+ if (this.revoked.has(runId)) {
35396
+ lease_.releaseIfHeldBy(actor);
35397
+ return { ok: false, detail: "The user stopped browser control for this run." };
35398
+ }
35399
+ if (context.signal?.aborted) {
35400
+ lease_.releaseIfHeldBy(actor);
35401
+ return { ok: false, detail: "The run was cancelled." };
35402
+ }
35403
+ if (!await lease_.awaitActionSlot()) {
35404
+ lease_.releaseIfHeldBy(actor);
35405
+ return { ok: false, detail: "Another browser action is still finishing. Try again." };
35406
+ }
35407
+ const token = lease_.beginAction();
35408
+ if (!token) {
35409
+ return { ok: false, detail: "Another browser action is already running. Wait for it to finish." };
35410
+ }
35411
+ try {
35412
+ const held = await this.open(runId, context.signal);
35413
+ if (this.revoked.has(runId)) {
35414
+ await this.releaseIfIdle(runId);
35415
+ return { ok: false, detail: "The user stopped browser control for this run." };
35416
+ }
35417
+ if (context.signal?.aborted || held.abort.signal.aborted) {
35418
+ await this.releaseIfIdle(runId);
35419
+ return { ok: false, detail: "The run was cancelled." };
35420
+ }
35421
+ return await this.perform(held, action, planned);
35422
+ } catch (err) {
35423
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
35424
+ } finally {
35425
+ lease_.endAction(token);
35426
+ if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
35427
+ }
35428
+ }
35429
+ /**
35430
+ * The run's browser, opened on first use.
35431
+ *
35432
+ * The ONE place the session cap is enforced. An earlier version also checked
35433
+ * it before taking the lease, on the reasoning that a run which cannot get a
35434
+ * session would otherwise queue forever — true when every run shared one
35435
+ * lease, and false once each run got its own, because a second run no longer
35436
+ * queues behind the first at all. Its own revert-check proved it dead: taking
35437
+ * it out changed no test. Two copies of a cap invite them to disagree.
35438
+ */
35439
+ async open(runId, signal) {
35440
+ const existing = this.runs.get(runId);
35441
+ if (existing && !existing.page.isClosed()) return existing;
35442
+ if (existing) {
35443
+ this.runs.delete(runId);
35444
+ this.closing.add(runId);
35445
+ await this.teardown(runId, existing);
35446
+ }
35447
+ if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
35448
+ throw new Error(
35449
+ `All ${this.maxSessions} browser session${this.maxSessions === 1 ? "" : "s"} are in use by other runs. Try again when one finishes, or raise the session limit in settings.`
35450
+ );
35451
+ }
35452
+ this.opening.add(runId);
35453
+ if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
35454
+ try {
35455
+ return await this.openReserved(runId, signal);
35456
+ } finally {
35457
+ this.opening.delete(runId);
35458
+ }
35459
+ }
35460
+ /** The part that may fail, with the pool slot already reserved. */
35461
+ async openReserved(runId, signal) {
35462
+ const playwright = await loadPlaywright();
35463
+ const abort = this.aborts.get(runId)?.signal;
35464
+ const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
35465
+ const session = await this.provider.createSession(creationSignal);
35466
+ this.announceLiveView(runId, session.liveViewUrl, true);
35467
+ let browser;
35468
+ let owned;
35469
+ try {
35470
+ browser = await playwright.chromium.connectOverCDP(session.cdpUrl);
35471
+ owned = this.provider.isolatesSessions ? void 0 : await browser.newContext();
35472
+ const context = owned ?? browser.contexts()[0] ?? await browser.newContext();
35473
+ const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
35474
+ const abort2 = this.aborts.get(runId);
35475
+ if (!abort2) throw new Error("The run ended while its browser was still opening.");
35476
+ const held = { session, browser, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
35477
+ page2.on("framenavigated", ((frame) => {
35478
+ if (frame !== page2.mainFrame()) return;
35479
+ held.generation += 1;
35480
+ }));
35481
+ this.runs.set(runId, held);
35482
+ return held;
35483
+ } catch (err) {
35484
+ this.announceLiveView(runId, void 0, false);
35485
+ await owned?.close().catch(() => {
35486
+ });
35487
+ await browser?.close().catch(() => {
35488
+ });
35489
+ await this.provider.endSession(session.id).catch(() => {
35490
+ });
35491
+ throw err;
35492
+ }
35493
+ }
35494
+ async perform(held, action, planned) {
35495
+ const { page: page2 } = held;
35496
+ const stoppable = async (work) => {
35497
+ if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
35498
+ let onAbort;
35499
+ try {
35500
+ return await Promise.race([
35501
+ work,
35502
+ new Promise((_, reject) => {
35503
+ onAbort = () => {
35504
+ void page2.close().catch(() => {
35505
+ });
35506
+ reject(new Error("The user stopped browser control for this run."));
35507
+ };
35508
+ held.abort.signal.addEventListener("abort", onAbort, { once: true });
35509
+ })
35510
+ ]);
35511
+ } finally {
35512
+ held.abort.signal.removeEventListener("abort", onAbort);
35513
+ }
35514
+ };
35515
+ const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
35516
+ const where = async () => ({
35517
+ url: page2.url(),
35518
+ title: await page2.title().catch(() => "")
35519
+ });
35520
+ if (action.kind !== "navigate" && held.generation !== planned) {
35521
+ return {
35522
+ ok: false,
35523
+ detail: "The page changed while this action was waiting for the browser, so it was not run. Look at the page again before retrying.",
35524
+ ...await where()
35525
+ };
35526
+ }
35527
+ switch (action.kind) {
35528
+ case "navigate": {
35529
+ const target = action.url ?? "";
35530
+ if (!/^https?:\/\//i.test(target)) {
35531
+ return { ok: false, detail: "Only http and https addresses can be opened." };
35532
+ }
35533
+ await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
35534
+ return { ok: true, detail: `Opened ${target}`, ...await where() };
35535
+ }
35536
+ case "click":
35537
+ await stoppable(page2.click(action.selector, { timeout }));
35538
+ return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
35539
+ case "fill":
35540
+ await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
35541
+ return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
35542
+ case "press": {
35543
+ if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
35544
+ else await stoppable(page2.keyboard.press(action.key));
35545
+ return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
35546
+ }
35547
+ case "wait_for":
35548
+ await stoppable(page2.waitForSelector(action.selector, { timeout }));
35549
+ return { ok: true, detail: `${action.selector} appeared`, ...await where() };
35550
+ case "extract_text": {
35551
+ const text = await stoppable(page2.innerText(action.selector ?? "body", { timeout }));
35552
+ return { ok: true, detail: text.slice(0, 2e5), ...await where() };
35553
+ }
35554
+ default:
35555
+ return { ok: false, detail: `Unsupported action: ${String(action.kind)}` };
35556
+ }
35557
+ }
35558
+ };
35559
+ function refusal(result) {
35560
+ if (result === "off") return "Browser control was turned off while this action waited for the browser.";
35561
+ if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
35562
+ return "The browser is in use by another part of this run and did not come free in time. Try again, or do something else first.";
35563
+ }
35564
+ async function loadPlaywright() {
35565
+ try {
35566
+ return await import('playwright');
35567
+ } catch {
35568
+ throw new Error("Playwright is not installed. Run: npm install playwright (no browser download is needed \u2014 this connects to a remote browser).");
35569
+ }
35570
+ }
35571
+
35572
+ // src/browser/remote/generic-cdp.ts
35573
+ var GenericCdpProvider = class {
35574
+ name = "CDP endpoint";
35575
+ // The endpoint is a single browser. There is no session to allocate, so two
35576
+ // runs against it share a page — see the seam.
35577
+ isolatesSessions = false;
35578
+ endpoint;
35579
+ constructor(endpoint) {
35580
+ this.endpoint = endpoint;
35581
+ }
35582
+ /**
35583
+ * There is no session to create — the endpoint is the session.
35584
+ *
35585
+ * `id` is a constant rather than a generated one because there is nothing to
35586
+ * look up later: `endSession` has nothing to call. Inventing a unique id
35587
+ * would imply a lifecycle this provider does not have.
35588
+ */
35589
+ async createSession() {
35590
+ return { id: "cdp", cdpUrl: this.endpoint };
35591
+ }
35592
+ /**
35593
+ * Deliberately nothing.
35594
+ *
35595
+ * The endpoint belongs to whoever runs it and outlives any single run, so
35596
+ * "ending" it is not ours to do — closing it would take the browser away from
35597
+ * every other user of a shared endpoint. The driver still closes its own
35598
+ * connection and its pages; this is only about the browser's lifetime.
35599
+ */
35600
+ async endSession() {
35601
+ }
35602
+ };
35603
+ function isCdpEndpoint(url) {
35604
+ try {
35605
+ const u = new URL(url);
35606
+ return u.protocol === "ws:" || u.protocol === "wss:";
35607
+ } catch {
35608
+ return false;
35609
+ }
35610
+ }
35611
+
35612
+ // src/browser/remote/steel.ts
35613
+ var DEFAULT_BASE = "https://api.steel.dev";
35614
+ var CREATE_TIMEOUT_MS = 6e4;
35615
+ var SteelProvider = class {
35616
+ name = "Steel";
35617
+ // POST /v1/sessions allocates a browser per call, so concurrent runs are
35618
+ // genuinely separate.
35619
+ isolatesSessions = true;
35620
+ base;
35621
+ apiKey;
35622
+ constructor(opts = {}) {
35623
+ let base = opts.url || DEFAULT_BASE;
35624
+ while (base.endsWith("/")) base = base.slice(0, -1);
35625
+ this.base = base;
35626
+ this.apiKey = opts.apiKey;
35627
+ }
35628
+ async createSession(signal) {
35629
+ const details = await this.call("POST", "/v1/sessions", signal, {});
35630
+ if (!details.id || !details.websocketUrl) {
35631
+ throw new Error("Steel returned a session with no id or websocket URL.");
35632
+ }
35633
+ return {
35634
+ id: details.id,
35635
+ cdpUrl: details.websocketUrl,
35636
+ ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
35637
+ };
35638
+ }
35639
+ async endSession(id) {
35640
+ try {
35641
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
35642
+ } catch {
35643
+ }
35644
+ }
35645
+ async call(method, path30, signal, body) {
35646
+ const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
35647
+ const composed = signal ? AbortSignal.any([signal, timeout]) : timeout;
35648
+ const res = await fetch(`${this.base}${path30}`, {
35649
+ method,
35650
+ headers: {
35651
+ "content-type": "application/json",
35652
+ // Sent only when configured — the self-hosted API takes no key, and an
35653
+ // empty header is worse than none.
35654
+ ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
35655
+ },
35656
+ body: JSON.stringify(body),
35657
+ signal: composed
35658
+ });
35659
+ if (!res.ok) {
35660
+ const detail = await res.text().then((t) => t.slice(0, 300)).catch(() => "");
35661
+ throw new Error(`Steel ${method} ${path30} failed: ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
35662
+ }
35663
+ return await res.json();
35664
+ }
35665
+ };
35666
+
34900
35667
  // src/retrieval/chunk.ts
34901
35668
  var DEFAULTS2 = { targetChars: 2e3, overlapChars: 200, minChars: 80 };
34902
35669
  function splitLongBlock(block, max) {
@@ -35548,6 +36315,6 @@ function applySyncBundle(bundle, config, cleanup) {
35548
36315
  return next;
35549
36316
  }
35550
36317
 
35551
- export { AZURE_BASE_MODELS, AZURE_BASE_URL_TEMPLATE, AuditLogger, BrowserControlTool, CASCADE_AUDIT_FILE, CASCADE_CONFIG_DIR, CASCADE_CONFIG_FILE, CASCADE_DASHBOARD_SECRET_FILE, CASCADE_DB_FILE, CASCADE_IGNORE_FILE, CASCADE_KEYSTORE_FILE, CASCADE_MD_FILE, CASCADE_VERSION, CHARS_PER_TOKEN2 as CHARS_PER_TOKEN, COMPLEXITY_T2_COUNT, Cascade, CascadeCancelledError, CascadeConfigSchema, CascadeIgnore, CascadeRouter, CascadeToolError, CloudClient, CodeSearchTool, ConfigManager, CurrentPageTool, DEFAULT_API_PORT, DEFAULT_APPROVAL_REQUIRED, DEFAULT_AUTO_SUMMARIZE_AT, DEFAULT_CLOUD_URL, DEFAULT_CONTEXT_LIMIT, DEFAULT_DASHBOARD_PORT, DEFAULT_MAX_SESSION_MESSAGES, DEFAULT_RETENTION_DAYS, DEFAULT_THEME, DOCUMENT_MIME, DashboardServer, DependencyScheduler, FileMcpOAuthStore, GLOBAL_CONFIG_DIR, GLOBAL_CREDENTIALS_FILE, GLOBAL_DB_FILE, GLOBAL_KEYSTORE_FILE, GLOBAL_RUNTIME_DB_FILE, GenerateDocumentTool, GraphRetriever, GraphSearchTool, HooksRunner, Keystore, LLMReranker, LM_STUDIO_BASE_URL, MAX_PAGE_TEXT_CHARS, MCP_TOOL_PREFIX, MODELS, McpClient, McpOAuthProvider, MemoryStore, OLLAMA_BASE_URL, OpenAICompatibleEmbedder, PROVIDER_DISPLAY_NAMES, RESUME_SCHEMA_VERSION, ResumeStore, Retriever, SqliteVectorStore, T1Administrator, T1_MODEL_PRIORITY, T2Manager, T2_MODEL_PRIORITY, T3Worker, T3_MODEL_PRIORITY, THEME_NAMES, TOOL_NAMES, TaskGraphValidationError, TaskScheduler, Telemetry, ToolRegistry, VISION_MODEL_PRIORITY, WorkspaceIndex, applyEndpointEdit, applyProviderApiKey, applyProviderCredential, applySettingsCredentials, applySettingsPayload, applySyncBundle, azureModelForDeployment, beginMcpOAuth, buildDocumentTools, buildManifest, buildSessionTranscript, bytesToBase64, cagCharBudget, chartKind, chartToTableRows, chatCompleterFromProviders, chunkCode, chunkText2 as chunkText, commitSettings, compileSectionGraph, compileSubtaskGraph, compileTaskGraph, completeMcpOAuth, connectMcpWithLoopbackOAuth, createCascade, credentialDispositionForEdit, credentialEndpointIdentity, decryptJSON as decryptSyncBlob, diffManifest, disambiguateMcpServerNames, discoverMcpAuthServer, discoverMcpTools, distillSessionFacts, embedderFromProviders, encryptJSON as encryptSyncBlob, endpointFromSettingsPayload, evaluateAcceptance, explainRefusal, extractCharts, failures, fileExt, fileOAuthProvider, gatherSyncBundle, hasDefaultEndpoint, hasProviderCredential, hasUsableProvider, hashContent, heuristicCodeChunker, inferAzureBaseModel, inlineRuns, isDocumentFormat, isMcpToolName, matchImageLine, mcpServerPrefix, mcpToolName, nodeHttpFetch, normalizeEndpoint, parseBlocks, parseChartSpec, parseDelimited, parseRankOrder, parseSlide, planRetrieval, preferIpv4Host, priorAzureRow, reciprocalRankFusion, refreshMcpToken, removeMcpServerDenials, renderDocument, renderDocx, renderPptx, renderXlsx, runCascade, sameAzureEndpoint, sameCredentialEndpoint, sameEndpoint, sessionWorthRemembering, settingsSnapshot, sniffImage, splitSlides, streamCascade, stripInline, summarizeCompleted, undecided, uniqueMcpServerName };
36318
+ export { AZURE_BASE_MODELS, AZURE_BASE_URL_TEMPLATE, AuditLogger, BrowserControlTool, BrowserLease, CASCADE_AUDIT_FILE, CASCADE_CONFIG_DIR, CASCADE_CONFIG_FILE, CASCADE_DASHBOARD_SECRET_FILE, CASCADE_DB_FILE, CASCADE_IGNORE_FILE, CASCADE_KEYSTORE_FILE, CASCADE_MD_FILE, CASCADE_VERSION, CHARS_PER_TOKEN2 as CHARS_PER_TOKEN, COMPLEXITY_T2_COUNT, Cascade, CascadeCancelledError, CascadeConfigSchema, CascadeIgnore, CascadeRouter, CascadeToolError, CloudClient, CodeSearchTool, ConfigManager, CurrentPageTool, DEFAULT_API_PORT, DEFAULT_APPROVAL_REQUIRED, DEFAULT_AUTO_SUMMARIZE_AT, DEFAULT_CLOUD_URL, DEFAULT_CONTEXT_LIMIT, DEFAULT_DASHBOARD_PORT, DEFAULT_MAX_SESSION_MESSAGES, DEFAULT_RETENTION_DAYS, DEFAULT_THEME, DOCUMENT_MIME, DashboardServer, DependencyScheduler, FileMcpOAuthStore, GLOBAL_CONFIG_DIR, GLOBAL_CREDENTIALS_FILE, GLOBAL_DB_FILE, GLOBAL_KEYSTORE_FILE, GLOBAL_RUNTIME_DB_FILE, GenerateDocumentTool, GenericCdpProvider, GraphRetriever, GraphSearchTool, HooksRunner, Keystore, LLMReranker, LM_STUDIO_BASE_URL, MAX_PAGE_TEXT_CHARS, MCP_TOOL_PREFIX, MODELS, McpClient, McpOAuthProvider, MemoryStore, OLLAMA_BASE_URL, OpenAICompatibleEmbedder, PROVIDER_DISPLAY_NAMES, RESUME_SCHEMA_VERSION, RemoteBrowserController, ResumeStore, Retriever, SqliteVectorStore, SteelProvider, T1Administrator, T1_MODEL_PRIORITY, T2Manager, T2_MODEL_PRIORITY, T3Worker, T3_MODEL_PRIORITY, THEME_NAMES, TOOL_NAMES, TaskGraphValidationError, TaskScheduler, Telemetry, ToolRegistry, VISION_MODEL_PRIORITY, WorkspaceIndex, applyEndpointEdit, applyProviderApiKey, applyProviderCredential, applySettingsCredentials, applySettingsPayload, applySyncBundle, azureModelForDeployment, beginMcpOAuth, buildDocumentTools, buildManifest, buildSessionTranscript, bytesToBase64, cagCharBudget, chartKind, chartToTableRows, chatCompleterFromProviders, chunkCode, chunkText2 as chunkText, commitSettings, compileSectionGraph, compileSubtaskGraph, compileTaskGraph, completeMcpOAuth, connectMcpWithLoopbackOAuth, createCascade, credentialDispositionForEdit, credentialEndpointIdentity, decryptJSON as decryptSyncBlob, diffManifest, disambiguateMcpServerNames, discoverMcpAuthServer, discoverMcpTools, distillSessionFacts, embedderFromProviders, encryptJSON as encryptSyncBlob, endpointFromSettingsPayload, evaluateAcceptance, explainRefusal, extractCharts, failures, fileExt, fileOAuthProvider, gatherSyncBundle, hasDefaultEndpoint, hasProviderCredential, hasUsableProvider, hashContent, heuristicCodeChunker, inferAzureBaseModel, inlineRuns, isCdpEndpoint, isDocumentFormat, isMcpToolName, matchImageLine, mcpServerPrefix, mcpToolName, nodeHttpFetch, normalizeEndpoint, parseBlocks, parseChartSpec, parseDelimited, parseRankOrder, parseSlide, planRetrieval, preferIpv4Host, priorAzureRow, reciprocalRankFusion, refreshMcpToken, removeMcpServerDenials, renderDocument, renderDocx, renderPptx, renderXlsx, runCascade, sameAzureEndpoint, sameCredentialEndpoint, sameEndpoint, sessionWorthRemembering, settingsSnapshot, sniffImage, splitSlides, streamCascade, stripInline, summarizeCompleted, undecided, uniqueMcpServerName };
35552
36319
  //# sourceMappingURL=index.js.map
35553
36320
  //# sourceMappingURL=index.js.map