cascade-ai 0.77.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.
@@ -312562,7 +312562,7 @@ function reconcilePrice(model, live) {
312562
312562
  }
312563
312563
 
312564
312564
  // src/constants.ts
312565
- var CASCADE_VERSION = "0.77.0";
312565
+ var CASCADE_VERSION = "0.79.0";
312566
312566
  var CASCADE_CONFIG_DIR = ".cascade";
312567
312567
  var CASCADE_MD_FILE = "CASCADE.md";
312568
312568
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -319144,15 +319144,15 @@ var ChatCompletionStream = class _ChatCompletionStream extends AbstractChatCompl
319144
319144
  if (!choice.logprobs) {
319145
319145
  choice.logprobs = Object.assign({}, logprobs);
319146
319146
  } else {
319147
- const { content: content2, refusal: refusal2, ...rest3 } = logprobs;
319147
+ const { content: content2, refusal: refusal3, ...rest3 } = logprobs;
319148
319148
  Object.assign(choice.logprobs, rest3);
319149
319149
  if (content2) {
319150
319150
  (_a5 = choice.logprobs).content ?? (_a5.content = []);
319151
319151
  choice.logprobs.content.push(...content2);
319152
319152
  }
319153
- if (refusal2) {
319153
+ if (refusal3) {
319154
319154
  (_b = choice.logprobs).refusal ?? (_b.refusal = []);
319155
- choice.logprobs.refusal.push(...refusal2);
319155
+ choice.logprobs.refusal.push(...refusal3);
319156
319156
  }
319157
319157
  }
319158
319158
  }
@@ -319170,10 +319170,10 @@ var ChatCompletionStream = class _ChatCompletionStream extends AbstractChatCompl
319170
319170
  Object.assign(choice, other);
319171
319171
  if (!delta)
319172
319172
  continue;
319173
- const { content, refusal, function_call, role, tool_calls, ...rest2 } = delta;
319173
+ const { content, refusal: refusal2, function_call, role, tool_calls, ...rest2 } = delta;
319174
319174
  Object.assign(choice.message, rest2);
319175
- if (refusal) {
319176
- choice.message.refusal = (choice.message.refusal || "") + refusal;
319175
+ if (refusal2) {
319176
+ choice.message.refusal = (choice.message.refusal || "") + refusal2;
319177
319177
  }
319178
319178
  if (role)
319179
319179
  choice.message.role = role;
@@ -346971,6 +346971,45 @@ var ToolsConfigSchema = external_exports.object({
346971
346971
  shellBlocklist: external_exports.array(external_exports.string()).default(["rm -rf", "sudo rm", "format", "mkfs"]),
346972
346972
  requireApprovalFor: external_exports.array(external_exports.string()).default([]),
346973
346973
  browserEnabled: external_exports.boolean().default(false),
346974
+ /**
346975
+ * Let a run ACT on the page open in the desktop's built-in browser — click,
346976
+ * type, submit — not just read it.
346977
+ *
346978
+ * Distinct from `browserEnabled`, which turns on the headless Playwright
346979
+ * tool. That one drives a throwaway browser with an empty cookie jar; this
346980
+ * one drives the session the user is signed into, which is a different order
346981
+ * of consequence and so gets its own switch rather than riding on that one.
346982
+ *
346983
+ * Off by default. Turning it on does not hand anything over silently: the
346984
+ * tool still goes through the approval chain, and the browser panel carries a
346985
+ * stop control for the run that is acting.
346986
+ */
346987
+ agentBrowserControl: external_exports.boolean().default(false),
346988
+ /**
346989
+ * Where a HOSTED run gets a browser, since it has none of its own.
346990
+ *
346991
+ * The desktop drives the Chromium the user is signed into and can watch. A
346992
+ * server has neither, and running one inside the container is wrong twice
346993
+ * over — page JavaScript issues requests no URL check ever sees, from inside
346994
+ * the trust boundary, and a session costs 200-400 MB. So this points at a
346995
+ * browser somebody else runs.
346996
+ *
346997
+ * Absent by default: no provider, no capability. There is nothing to turn
346998
+ * off because nothing is registered until an endpoint is supplied.
346999
+ */
347000
+ remoteBrowser: external_exports.object({
347001
+ /** `cdp` for an endpoint you run; `steel` for a Steel deployment. */
347002
+ provider: external_exports.enum(["cdp", "steel"]).optional(),
347003
+ /** The websocket endpoint for `cdp`, or the API base for `steel`. */
347004
+ url: external_exports.string().optional(),
347005
+ apiKey: external_exports.string().optional(),
347006
+ /**
347007
+ * Concurrent sessions. One by default and deliberately so: every session
347008
+ * is billed, and a wave of workers each opening their own is a cost the
347009
+ * user did not choose. Raising it is a decision, not a default.
347010
+ */
347011
+ maxSessions: external_exports.number().int().min(1).max(16).default(1)
347012
+ }).optional(),
346974
347013
  mcpServers: external_exports.array(McpServerConfigSchema).optional(),
346975
347014
  mcpTrusted: external_exports.array(external_exports.string()).optional(),
346976
347015
  /** Web search backends — at least one should be configured for best results */
@@ -351755,6 +351794,23 @@ Now execute your subtask using this context where relevant.`
351755
351794
  this.setStatus("FAILED", finalOutput);
351756
351795
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput, "FAILED");
351757
351796
  return this.buildResult("ESCALATED", finalOutput, { checksRun, passed, failed }, issues, correctionAttempts);
351797
+ } finally {
351798
+ this.releaseBrowserControl();
351799
+ }
351800
+ }
351801
+ /**
351802
+ * End this worker's hold on the shared browser.
351803
+ *
351804
+ * Duck-typed rather than imported: `browser_control` is registered only by a
351805
+ * host that HAS a browser (the desktop), so in the CLI and hosted runs there
351806
+ * is no such tool and this is a no-op. Importing the class to instanceof-check
351807
+ * it would couple the worker to a tool that usually is not there.
351808
+ */
351809
+ releaseBrowserControl() {
351810
+ try {
351811
+ const tool = this.toolRegistry.getTool?.("browser_control");
351812
+ tool?.releaseActor?.(this.id);
351813
+ } catch {
351758
351814
  }
351759
351815
  }
351760
351816
  sendToPeer(toId, content) {
@@ -397857,6 +397913,127 @@ var CurrentPageTool = class extends BaseTool {
397857
397913
  }
397858
397914
  };
397859
397915
 
397916
+ // src/tools/browser-control.ts
397917
+ var BrowserControlTool = class extends BaseTool {
397918
+ name = "browser_control";
397919
+ description = "Act on the web page the user has open in the built-in browser: navigate, click an element, type into a field, press a key, or wait for something to appear. This drives the REAL browser the user is signed into and can see, so actions have real consequences \u2014 prefer read_current_page when you only need to look. CSS selectors are matched against the live page; call extract_text first if you need to find one. There is one browser: if another part of this run is using it, this call waits its turn, so a slow reply means queued, not stuck.";
397920
+ inputSchema = {
397921
+ type: "object",
397922
+ properties: {
397923
+ action: {
397924
+ type: "string",
397925
+ enum: ["navigate", "click", "fill", "press", "wait_for", "extract_text"],
397926
+ description: "What to do."
397927
+ },
397928
+ url: { type: "string", description: "For navigate: the http(s) address to open." },
397929
+ selector: {
397930
+ type: "string",
397931
+ description: "CSS selector for click, fill and wait_for. Optional for extract_text (defaults to the whole page)."
397932
+ },
397933
+ value: { type: "string", description: "For fill: the text to type." },
397934
+ key: { type: "string", description: 'For press: a key name such as "Enter", "Tab" or "Escape".' },
397935
+ timeoutMs: { type: "number", description: "For wait_for: how long to wait before giving up (default 10000, max 30000)." }
397936
+ },
397937
+ required: ["action"]
397938
+ };
397939
+ controller;
397940
+ release;
397941
+ /** One-way. See `revoke`. */
397942
+ revoked = false;
397943
+ constructor(controller, release) {
397944
+ super();
397945
+ this.controller = controller;
397946
+ this.release = release;
397947
+ }
397948
+ /**
397949
+ * A worker has finished. Ends its hold on the browser.
397950
+ *
397951
+ * Called from the tier's terminal path rather than by the model, because a
397952
+ * model cannot be relied on to announce that it is done — which is why the
397953
+ * host cannot use idle time as the boundary instead.
397954
+ */
397955
+ releaseActor(actorId) {
397956
+ this.release?.(actorId);
397957
+ }
397958
+ /**
397959
+ * Permanently disable this tool, whatever the registry still holds.
397960
+ *
397961
+ * Registration-time gates cannot express "this run turned out to be
397962
+ * unattended": a host that wires the controller and only then declares the
397963
+ * run unattended has already registered the tool, and a check that ran at
397964
+ * registration has no second chance. Enforcing at EXECUTION time makes the
397965
+ * guarantee independent of call order, which is the whole point of stating
397966
+ * the invariant rather than relying on the scheduler's current sequence.
397967
+ *
397968
+ * One-way on purpose. A safety gate that can be re-opened by a later call is
397969
+ * a gate that a refactor re-opens by accident.
397970
+ */
397971
+ revoke() {
397972
+ this.revoked = true;
397973
+ }
397974
+ // Acting on a session the user is signed into is the highest-consequence
397975
+ // thing in the tool set — higher than shell, arguably, because the blast
397976
+ // radius is someone else's server rather than this machine.
397977
+ isDangerous() {
397978
+ return true;
397979
+ }
397980
+ async execute(input, options) {
397981
+ if (this.revoked) {
397982
+ return "Error: browser control is not available in this run. It requires a person watching who can stop it, and nobody is.";
397983
+ }
397984
+ const kind2 = input["action"];
397985
+ if (!kind2) return "Error: action is required.";
397986
+ if (options?.signal?.aborted) return "Error: the run was cancelled before this action ran.";
397987
+ const missing = requiredFieldFor(kind2, input);
397988
+ if (missing) return `Error: "${kind2}" needs ${missing}.`;
397989
+ const action = {
397990
+ kind: kind2,
397991
+ ...typeof input["url"] === "string" ? { url: input["url"] } : {},
397992
+ ...typeof input["selector"] === "string" ? { selector: input["selector"] } : {},
397993
+ ...typeof input["value"] === "string" ? { value: input["value"] } : {},
397994
+ ...typeof input["key"] === "string" ? { key: input["key"] } : {},
397995
+ ...typeof input["timeoutMs"] === "number" ? { timeoutMs: Math.min(Math.max(input["timeoutMs"], 0), 3e4) } : {}
397996
+ };
397997
+ let outcome;
397998
+ try {
397999
+ outcome = await this.controller(action, {
398000
+ // `sessionId` is the run. The host scopes both revocation and its
398001
+ // single-owner lease on it, so passing it is not telemetry — drop it
398002
+ // and a Stop in one run silently stops every later one.
398003
+ sessionId: options?.sessionId ?? "",
398004
+ actorId: options?.tierId ?? options?.sessionId ?? "",
398005
+ ...options?.signal ? { signal: options.signal } : {}
398006
+ });
398007
+ } catch (err) {
398008
+ return `Error: the browser could not perform "${kind2}" \u2014 ${err instanceof Error ? err.message : String(err)}`;
398009
+ }
398010
+ const where = outcome.url ? `
398011
+ Page: ${outcome.title ? `${outcome.title} \u2014 ` : ""}${outcome.url}` : "";
398012
+ return `${outcome.ok ? "" : "Failed: "}${outcome.detail}${where}`;
398013
+ }
398014
+ };
398015
+ function requiredFieldFor(kind2, input) {
398016
+ const has2 = (k4) => typeof input[k4] === "string" && input[k4].length > 0;
398017
+ const given = (k4) => typeof input[k4] === "string";
398018
+ switch (kind2) {
398019
+ case "navigate":
398020
+ return has2("url") ? null : "a url";
398021
+ case "click":
398022
+ return has2("selector") ? null : "a selector";
398023
+ case "fill":
398024
+ return has2("selector") ? given("value") ? null : "a value" : "a selector";
398025
+ case "press":
398026
+ return has2("key") ? null : "a key";
398027
+ case "wait_for":
398028
+ return has2("selector") ? null : "a selector";
398029
+ // extract_text works with or without a selector.
398030
+ case "extract_text":
398031
+ return null;
398032
+ default:
398033
+ return null;
398034
+ }
398035
+ }
398036
+
397860
398037
  // src/core/cascade.ts
397861
398038
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
397862
398039
  function buildContextualPrompt(prompt, history = []) {
@@ -398349,6 +398526,79 @@ ${input}` }],
398349
398526
  if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
398350
398527
  this.toolRegistry.register(new CurrentPageTool(provider));
398351
398528
  }
398529
+ /**
398530
+ * True when nobody is watching this run — a scheduled/cron task, which
398531
+ * auto-approves every tool because there is no one to ask.
398532
+ *
398533
+ * That auto-approval is fine for ordinary tools and disqualifying for this
398534
+ * one: `browser_control` drives the signed-in browser, and "every run asks
398535
+ * first" is the promise the Settings copy makes. The property used to hold
398536
+ * only because `runScheduledTask` happens not to call `setBrowserController`
398537
+ * — a guarantee resting on an ABSENT call, which is exactly what a later
398538
+ * refactor adds without noticing. Stated and enforced instead.
398539
+ */
398540
+ unattended = false;
398541
+ /**
398542
+ * Declare that no human is watching this run, so capabilities that depend on
398543
+ * a person being able to see and stop them are refused outright.
398544
+ */
398545
+ setUnattended(on) {
398546
+ this.unattended = on;
398547
+ if (!on) return;
398548
+ const tool = this.toolRegistry.getTool("browser_control");
398549
+ tool?.revoke?.();
398550
+ }
398551
+ /**
398552
+ * Wire the host's browser so a run can ACT on the open page, not just read it.
398553
+ *
398554
+ * Two gates before this registers anything, and both matter:
398555
+ *
398556
+ * - `tools.agentBrowserControl` must be on. Unlike read_current_page, which
398557
+ * reaches nothing the user has not already opened themselves, this drives
398558
+ * the session they are signed into. That is opt-in, not a default.
398559
+ * - `disabledTools` still removes it, the same as every other tool.
398560
+ * - The run must not be UNATTENDED. See `setUnattended`.
398561
+ *
398562
+ * Host-supplied like the page provider: the tool exists only where there is a
398563
+ * real browser to drive, so it never has to be refused in the CLI or a hosted
398564
+ * run — it is simply not there.
398565
+ */
398566
+ setBrowserController(controller, release) {
398567
+ this.registerBrowserControl(controller, release, this.config.tools?.agentBrowserControl === true);
398568
+ }
398569
+ /**
398570
+ * Wire a REMOTE browser — one the host connects to rather than one the user
398571
+ * is signed into.
398572
+ *
398573
+ * A separate entry point because the enablement is a different question, and
398574
+ * conflating them made the hosted capability dead on arrival: this went out
398575
+ * calling `setBrowserController`, which gates on `agentBrowserControl`, and
398576
+ * that flag means "drive the browser I am signed into on this machine". It
398577
+ * defaults to false and has no meaning on a server, so the hosted tool was
398578
+ * never registered at all — the feature looked shipped and did nothing.
398579
+ *
398580
+ * Configuring a provider IS the opt-in here. It takes a deliberate act with
398581
+ * an endpoint and usually a paid account, and the browser it reaches is a
398582
+ * throwaway signed into nothing — a smaller consent than handing over the
398583
+ * session the user is already authenticated in, and one the desktop flag
398584
+ * cannot stand in for.
398585
+ */
398586
+ setRemoteBrowserController(controller, release) {
398587
+ this.registerBrowserControl(controller, release, Boolean(this.config.tools?.remoteBrowser?.provider));
398588
+ }
398589
+ /**
398590
+ * The gates every surface shares, with only enablement differing.
398591
+ *
398592
+ * One place, because the two callers above are exactly the sibling-writer
398593
+ * shape that has already drifted twice in this feature: a rule added to one
398594
+ * and not the other is silent on the surface that missed it.
398595
+ */
398596
+ registerBrowserControl(controller, release, enabled) {
398597
+ if (this.unattended) return;
398598
+ if (!enabled) return;
398599
+ if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
398600
+ this.toolRegistry.register(new BrowserControlTool(controller, release));
398601
+ }
398352
398602
  registerMediaTools(workspacePath) {
398353
398603
  const denied = new Set(this.config.tools?.disabledTools ?? []);
398354
398604
  const permitted = (name) => !denied.has(name);
@@ -398954,6 +399204,7 @@ ${prompt}` : prompt;
398954
399204
  this.router.setRunSignal(options.signal);
398955
399205
  const startMs = Date.now();
398956
399206
  const taskId = crypto2.randomUUID();
399207
+ this.emit("run:started", { taskId });
398957
399208
  this.decisionLog = [];
398958
399209
  this.currentRunCompleted = [];
398959
399210
  this.wroteCheckpointThisRun = false;
@@ -399689,6 +399940,20 @@ function applySettingsPayload(config2, data) {
399689
399940
  if (data.webSearch.tavilyApiKey) next.tavilyApiKey = data.webSearch.tavilyApiKey;
399690
399941
  config2.tools.webSearch = next;
399691
399942
  }
399943
+ if (data.remoteBrowser && typeof data.remoteBrowser === "object") {
399944
+ const prior = config2.tools.remoteBrowser ?? {};
399945
+ const next = { ...prior };
399946
+ if (data.remoteBrowser.provider === "cdp" || data.remoteBrowser.provider === "steel") {
399947
+ next.provider = data.remoteBrowser.provider;
399948
+ }
399949
+ if (typeof data.remoteBrowser.url === "string") next.url = data.remoteBrowser.url.trim() || void 0;
399950
+ if (data.remoteBrowser.apiKey) next.apiKey = data.remoteBrowser.apiKey;
399951
+ const n8 = data.remoteBrowser.maxSessions;
399952
+ if (typeof n8 === "number" && Number.isFinite(n8)) {
399953
+ next.maxSessions = Math.min(Math.max(Math.floor(n8), 1), 16);
399954
+ }
399955
+ config2.tools.remoteBrowser = next;
399956
+ }
399692
399957
  if (data.advanced && typeof data.advanced === "object") {
399693
399958
  const a2 = data.advanced;
399694
399959
  const num = (v6, min, max) => {
@@ -399719,6 +399984,7 @@ function applySettingsPayload(config2, data) {
399719
399984
  if (["auto", "T1", "T2", "T3"].includes(a2["forceTier"])) config2.routing = { ...config2.routing ?? {}, forceTier: a2["forceTier"] };
399720
399985
  if (typeof a2["benchmarksLive"] === "boolean") config2.benchmarks = { ...config2.benchmarks ?? {}, live: a2["benchmarksLive"] };
399721
399986
  if (["isolate", "worker", "auto"].includes(a2["dynamicToolSandbox"])) config2.tools = { ...config2.tools ?? {}, dynamicToolSandbox: a2["dynamicToolSandbox"] };
399987
+ if (typeof a2["agentBrowserControl"] === "boolean") config2.tools = { ...config2.tools ?? {}, agentBrowserControl: a2["agentBrowserControl"] };
399722
399988
  if (typeof a2["factsExtraction"] === "boolean") config2.knowledge = { ...config2.knowledge ?? {}, factsExtraction: a2["factsExtraction"] };
399723
399989
  if (typeof a2["rememberSessions"] === "boolean") config2.memory = { ...config2.memory ?? {}, rememberSessions: a2["rememberSessions"] };
399724
399990
  if (typeof a2["enableToolCreation"] === "boolean") config2.enableToolCreation = a2["enableToolCreation"];
@@ -399734,6 +400000,7 @@ function settingsSnapshot(config2) {
399734
400000
  if (p3?.type && p3?.baseUrl && p3.type !== "azure") endpoints[p3.type] = p3.baseUrl;
399735
400001
  }
399736
400002
  const ws2 = config2.tools?.webSearch ?? {};
400003
+ const rb = config2.tools?.remoteBrowser ?? {};
399737
400004
  return {
399738
400005
  models: config2.models ?? {},
399739
400006
  budget: {
@@ -399763,6 +400030,15 @@ function settingsSnapshot(config2) {
399763
400030
  hasBraveKey: typeof ws2.braveApiKey === "string" && ws2.braveApiKey.length > 0,
399764
400031
  hasTavilyKey: typeof ws2.tavilyApiKey === "string" && ws2.tavilyApiKey.length > 0
399765
400032
  },
400033
+ // `hasApiKey`, never the key. The panel needs to show whether one is set;
400034
+ // sending the value back would put a live credential in every settings
400035
+ // response, where it reaches any client that can read them.
400036
+ remoteBrowser: {
400037
+ provider: rb.provider,
400038
+ url: rb.url,
400039
+ hasApiKey: typeof rb.apiKey === "string" && rb.apiKey.length > 0,
400040
+ maxSessions: rb.maxSessions
400041
+ },
399766
400042
  advanced: {
399767
400043
  autonomy: config2.autonomy,
399768
400044
  planApproval: config2.planApproval,
@@ -399776,6 +400052,7 @@ function settingsSnapshot(config2) {
399776
400052
  forceTier: config2.routing?.forceTier,
399777
400053
  benchmarksLive: config2.benchmarks?.live,
399778
400054
  dynamicToolSandbox: config2.tools?.dynamicToolSandbox,
400055
+ agentBrowserControl: config2.tools?.agentBrowserControl,
399779
400056
  factsExtraction: config2.knowledge?.factsExtraction,
399780
400057
  rememberSessions: config2.memory?.rememberSessions,
399781
400058
  enableToolCreation: config2.enableToolCreation,
@@ -401064,6 +401341,14 @@ var DashboardServer = class {
401064
401341
  * always failing.
401065
401342
  */
401066
401343
  currentPageProvider;
401344
+ /**
401345
+ * Supplied by the desktop shell so a run can ACT on that page — click, type,
401346
+ * submit — not just read it. Same reasoning as the provider above: absent in
401347
+ * the CLI, so the tool is not registered rather than present and failing.
401348
+ * Cascade applies the `tools.agentBrowserControl` gate on top of this.
401349
+ */
401350
+ browserController;
401351
+ browserActorRelease;
401067
401352
  activeControllers = /* @__PURE__ */ new Map();
401068
401353
  /**
401069
401354
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -401125,6 +401410,7 @@ var DashboardServer = class {
401125
401410
  });
401126
401411
  this.socket.onConfigUpdate(async (data) => {
401127
401412
  const result = await commitSettings(this.config, data, (committed) => this.persistConfig(committed));
401413
+ if (result.ok) this.settingsChangeHook?.(this.config);
401128
401414
  const refused = result.refused.map((r4) => ({
401129
401415
  type: r4.type,
401130
401416
  reason: r4.reason,
@@ -401148,6 +401434,7 @@ var DashboardServer = class {
401148
401434
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
401149
401435
  this.activeSessions.set(sessionId, cascade);
401150
401436
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
401437
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
401151
401438
  cascade.on("stream:token", (e3) => {
401152
401439
  this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e3.tierId, text: e3.text, primary: e3.primary });
401153
401440
  });
@@ -401171,13 +401458,16 @@ var DashboardServer = class {
401171
401458
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
401172
401459
  this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e3 });
401173
401460
  });
401461
+ cascade.on("run:started", (e3) => {
401462
+ const id = e3.taskId;
401463
+ if (id) this.recordSessionTask(sessionId, id);
401464
+ });
401174
401465
  try {
401175
401466
  const result = await cascade.run({
401176
401467
  prompt: runPrompt,
401177
401468
  signal: abortController.signal,
401178
401469
  approvalCallback: this.makeApprovalCallback(sessionId)
401179
401470
  });
401180
- this.recordSessionTask(sessionId, result.taskId);
401181
401471
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
401182
401472
  this.captureWhy(sessionId, cascade, result);
401183
401473
  this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
@@ -401243,6 +401533,49 @@ var DashboardServer = class {
401243
401533
  setCurrentPageProvider(provider) {
401244
401534
  this.currentPageProvider = provider;
401245
401535
  }
401536
+ /** Let runs act on that view, not just read it (desktop only). */
401537
+ setBrowserController(controller, release) {
401538
+ this.browserController = controller;
401539
+ this.browserActorRelease = release;
401540
+ }
401541
+ /**
401542
+ * Told whenever settings are committed through THIS server.
401543
+ *
401544
+ * Settings reach the live config by two routes — the desktop's Electron IPC
401545
+ * handler and this server's `config:update` socket — and both run the same
401546
+ * `commitSettings`. The desktop mirrors `tools.agentBrowserControl` into its
401547
+ * browser module after its own route, but had no way to learn about the
401548
+ * other, so a socket write left the module's copy stale: an enable the module
401549
+ * still refused, or worse a disable the module still honoured for a run
401550
+ * already holding the tool. This is the same sibling-writer drift
401551
+ * `settings-payload.ts` exists to prevent, one layer up.
401552
+ */
401553
+ settingsChangeHook;
401554
+ onSettingsChanged(hook) {
401555
+ this.settingsChangeHook = hook;
401556
+ }
401557
+ /**
401558
+ * Told when a run ends, so a host holding run-scoped state can release it.
401559
+ *
401560
+ * The desktop uses this to retire a run's browser Stop control: that has to
401561
+ * outlive each individual action (the gap between two actions is when the
401562
+ * user most wants it) but must not outlive the run.
401563
+ */
401564
+ /**
401565
+ * Fired when a run ends, carrying BOTH identifiers explicitly.
401566
+ *
401567
+ * They are different things and conflating them was a live bug: a chat
401568
+ * `sessionId` can contain many runs (hence `sessionTaskIds`), while
401569
+ * `Cascade.run()` mints a fresh random `taskId` per run — and it is the
401570
+ * taskId that tools see as `ToolExecuteOptions.sessionId`. Passing only the
401571
+ * chat id meant browser cleanup keyed on the task id matched nothing, so a
401572
+ * finished run kept its Stop banner armed and held its browser lease until
401573
+ * the deadlock ceiling.
401574
+ */
401575
+ runEndedHook;
401576
+ onRunEnded(hook) {
401577
+ this.runEndedHook = hook;
401578
+ }
401246
401579
  async start() {
401247
401580
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
401248
401581
  if (!isLoopback) {
@@ -401433,6 +401766,8 @@ var DashboardServer = class {
401433
401766
  }
401434
401767
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
401435
401768
  persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
401769
+ const endedTaskId = result?.taskId ?? this.sessionTaskIds.get(sessionId)?.at(-1);
401770
+ this.runEndedHook?.({ sessionId, ...endedTaskId ? { taskId: endedTaskId } : {} });
401436
401771
  try {
401437
401772
  if (reply && reply.trim()) {
401438
401773
  this.store.addMessage({ id: crypto2.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
@@ -401526,6 +401861,7 @@ var DashboardServer = class {
401526
401861
  const prompt = task.prompt;
401527
401862
  const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
401528
401863
  const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
401864
+ cascade.setUnattended(true);
401529
401865
  this.activeSessions.set(sessionId, cascade);
401530
401866
  cascade.on("tier:status", (e3) => {
401531
401867
  this.socket.broadcast("tier:status", { sessionId, ...e3 });
@@ -401533,13 +401869,16 @@ var DashboardServer = class {
401533
401869
  cascade.on("peer:message", (e3) => {
401534
401870
  this.socket.emitPeerMessage(e3);
401535
401871
  });
401872
+ cascade.on("run:started", (e3) => {
401873
+ const id = e3.taskId;
401874
+ if (id) this.recordSessionTask(sessionId, id);
401875
+ });
401536
401876
  try {
401537
401877
  const result = await cascade.run({
401538
401878
  prompt,
401539
401879
  identityId: task.identityId,
401540
401880
  approvalCallback: async () => ({ approved: true, always: false })
401541
401881
  });
401542
- this.recordSessionTask(sessionId, result.taskId);
401543
401882
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
401544
401883
  this.captureWhy(sessionId, cascade, result);
401545
401884
  this.socket.broadcast("session:complete", { sessionId, result });
@@ -402172,6 +402511,7 @@ ${prompt}`;
402172
402511
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
402173
402512
  this.activeSessions.set(sessionId, cascade);
402174
402513
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
402514
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
402175
402515
  cascade.on("stream:token", (e3) => {
402176
402516
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e3.tierId, text: e3.text, primary: e3.primary });
402177
402517
  });
@@ -402195,13 +402535,16 @@ ${prompt}`;
402195
402535
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
402196
402536
  this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e3 });
402197
402537
  });
402538
+ cascade.on("run:started", (e3) => {
402539
+ const id = e3.taskId;
402540
+ if (id) this.recordSessionTask(sessionId, id);
402541
+ });
402198
402542
  try {
402199
402543
  const result = await cascade.run({
402200
402544
  prompt: runPrompt,
402201
402545
  identityId: body.identityId,
402202
402546
  approvalCallback: this.makeApprovalCallback(sessionId)
402203
402547
  });
402204
- this.recordSessionTask(sessionId, result.taskId);
402205
402548
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
402206
402549
  this.captureWhy(sessionId, cascade, result);
402207
402550
  this.socket.broadcast("cost:update", {
@@ -402590,6 +402933,694 @@ async function discoverMcpTools(servers) {
402590
402933
  return results;
402591
402934
  }
402592
402935
 
402936
+ // src/browser/lease.ts
402937
+ var LEASE_DEADLOCK_CEILING_MS = 3e5;
402938
+ var QUEUE_WAIT_MS = 18e4;
402939
+ var MAX_QUEUED = 8;
402940
+ var ACTION_HANDOFF_MS = 2e3;
402941
+ var ACTION_HANDOFF_POLL_MS = 25;
402942
+ var BrowserLease = class {
402943
+ actor = null;
402944
+ session = null;
402945
+ ceilingTimer = null;
402946
+ waiting = [];
402947
+ lifecycleWired = false;
402948
+ /**
402949
+ * The action holding the browser, if any.
402950
+ *
402951
+ * Needed alongside the lease for a reason the lease cannot cover: the lease
402952
+ * is RE-ENTRANT for its holder — that is what makes a sequence a sequence —
402953
+ * so nothing in it stops ONE actor running two actions at once. A retry
402954
+ * racing the call it retried is exactly that shape.
402955
+ *
402956
+ * A symbol because it identifies the individual call: only the action that
402957
+ * took the slot may release it.
402958
+ */
402959
+ action = null;
402960
+ isRevoked;
402961
+ onChange;
402962
+ constructor(options = {}) {
402963
+ this.isRevoked = options.isRevoked ?? (() => false);
402964
+ this.onChange = options.onChange ?? (() => {
402965
+ });
402966
+ }
402967
+ get holderActor() {
402968
+ return this.actor;
402969
+ }
402970
+ get holderSession() {
402971
+ return this.session;
402972
+ }
402973
+ get queueDepth() {
402974
+ return this.waiting.length;
402975
+ }
402976
+ get actionInFlight() {
402977
+ return this.action !== null;
402978
+ }
402979
+ /**
402980
+ * Declare that this host reports worker terminal states.
402981
+ *
402982
+ * When it does, timers stop being ownership boundaries entirely. When it does
402983
+ * not — an embedder that wired a controller but not the release — the
402984
+ * fallbacks stay, because something has to break a deadlock.
402985
+ */
402986
+ setLifecycleReleaseWired(on) {
402987
+ this.lifecycleWired = on;
402988
+ }
402989
+ /**
402990
+ * Take the browser for this actor, waiting in line if someone else holds it.
402991
+ *
402992
+ * Re-entrant for the actor that already holds it: that is the sequence case —
402993
+ * the same worker coming back for its next step must not queue behind itself.
402994
+ */
402995
+ async acquire(actorId, sessionId, signal) {
402996
+ if (this.actor === null || this.actor === actorId) {
402997
+ this.actor = actorId;
402998
+ this.session = sessionId;
402999
+ this.armCeiling();
403000
+ return "granted";
403001
+ }
403002
+ if (this.waiting.length >= MAX_QUEUED) return "busy";
403003
+ return this.enqueue(actorId, sessionId, signal);
403004
+ }
403005
+ /**
403006
+ * One worker has finished its whole sequence, so its hold ends.
403007
+ *
403008
+ * THIS is the semantic release. It comes from the worker's own terminal path,
403009
+ * so it means "this actor will never ask again" — which a timer can never
403010
+ * mean about a worker that is merely thinking.
403011
+ *
403012
+ * Idempotent, and safe for an actor that never touched the browser.
403013
+ */
403014
+ actorEnded(actorId) {
403015
+ if (!actorId) return;
403016
+ for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) {
403017
+ const w2 = this.waiting[i4];
403018
+ if (w2.actorId === actorId) w2.settle("cancelled");
403019
+ }
403020
+ this.releaseIfHeldBy(actorId);
403021
+ this.onChange();
403022
+ }
403023
+ /**
403024
+ * Give the browser up when about to return without using it.
403025
+ *
403026
+ * Two guards, both load-bearing. Never while an action is in flight, because
403027
+ * the lease is RE-ENTRANT: the action still running may be this same actor's
403028
+ * previous step, and taking the lease off it hands the page to a queued
403029
+ * worker mid-sequence. And only while still the holder, because by the time a
403030
+ * refusal path runs a Stop may already have handed the lease on.
403031
+ */
403032
+ releaseIfHeldBy(actorId) {
403033
+ if (this.action) return;
403034
+ if (this.actor === actorId) this.release();
403035
+ }
403036
+ /** Drop every queued waiter belonging to a run, and the lease if it holds it. */
403037
+ dropRun(sessionId) {
403038
+ for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) {
403039
+ const w2 = this.waiting[i4];
403040
+ if (w2.sessionId === sessionId) w2.settle("cancelled");
403041
+ }
403042
+ if (this.session === sessionId) this.release();
403043
+ }
403044
+ /** Turn everyone away — the capability itself has gone. */
403045
+ dropAll() {
403046
+ for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) this.waiting[i4].settle("off");
403047
+ }
403048
+ /** Hand the browser to the next actor in line, if any. */
403049
+ release() {
403050
+ this.clearCeiling();
403051
+ this.actor = null;
403052
+ this.session = null;
403053
+ let next = this.waiting.shift();
403054
+ while (next && this.isRevoked(next.sessionId)) {
403055
+ next.settle("cancelled");
403056
+ next = this.waiting.shift();
403057
+ }
403058
+ if (next) {
403059
+ this.actor = next.actorId;
403060
+ this.session = next.sessionId;
403061
+ this.armCeiling();
403062
+ next.settle("granted");
403063
+ }
403064
+ this.onChange();
403065
+ }
403066
+ /** Arm the deadlock ceiling. Not a release schedule — see the constant. */
403067
+ armCeiling() {
403068
+ this.clearCeiling();
403069
+ if (this.lifecycleWired) return;
403070
+ this.ceilingTimer = setTimeout(() => this.release(), LEASE_DEADLOCK_CEILING_MS);
403071
+ this.ceilingTimer.unref?.();
403072
+ }
403073
+ clearCeiling() {
403074
+ if (this.ceilingTimer) {
403075
+ clearTimeout(this.ceilingTimer);
403076
+ this.ceilingTimer = null;
403077
+ }
403078
+ }
403079
+ /** Take the per-action slot, or null when one is already running. */
403080
+ beginAction() {
403081
+ if (this.action) return null;
403082
+ const token = /* @__PURE__ */ Symbol("browser-action");
403083
+ this.action = token;
403084
+ return token;
403085
+ }
403086
+ /** Release the slot, but only for the action that took it. */
403087
+ endAction(token) {
403088
+ if (this.action !== token) return false;
403089
+ this.action = null;
403090
+ return true;
403091
+ }
403092
+ /**
403093
+ * Wait, briefly, for an outgoing action to unwind.
403094
+ *
403095
+ * A release can hand the browser on while the previous holder's abort is
403096
+ * still propagating through an awaited navigation. That is a handoff, not a
403097
+ * conflict, so it is waited out rather than refused — but bounded, because a
403098
+ * wedged action must not pin the queue.
403099
+ */
403100
+ async awaitActionSlot() {
403101
+ for (let waited = 0; waited < ACTION_HANDOFF_MS; waited += ACTION_HANDOFF_POLL_MS) {
403102
+ if (!this.action) return true;
403103
+ await new Promise((r4) => setTimeout(r4, ACTION_HANDOFF_POLL_MS));
403104
+ }
403105
+ return this.action === null;
403106
+ }
403107
+ /**
403108
+ * Queue one actor, resolving when it gets the browser or is turned away.
403109
+ *
403110
+ * `settle` is wrapped so it runs at most once and always takes the waiter out
403111
+ * of the line: the timeout, an abort and a handoff all race, and settling
403112
+ * twice would silently leave a dead entry holding a queue slot.
403113
+ */
403114
+ enqueue(actorId, sessionId, signal) {
403115
+ return new Promise((resolve2) => {
403116
+ let done = false;
403117
+ const waiter = {
403118
+ actorId,
403119
+ sessionId,
403120
+ settle: (result) => {
403121
+ if (done) return;
403122
+ done = true;
403123
+ clearTimeout(timer);
403124
+ signal?.removeEventListener("abort", giveUp);
403125
+ const i4 = this.waiting.indexOf(waiter);
403126
+ if (i4 >= 0) this.waiting.splice(i4, 1);
403127
+ resolve2(result);
403128
+ }
403129
+ };
403130
+ const timer = this.lifecycleWired ? void 0 : setTimeout(() => waiter.settle("busy"), QUEUE_WAIT_MS);
403131
+ timer?.unref?.();
403132
+ const giveUp = () => waiter.settle("cancelled");
403133
+ signal?.addEventListener("abort", giveUp, { once: true });
403134
+ this.waiting.push(waiter);
403135
+ });
403136
+ }
403137
+ };
403138
+
403139
+ // src/browser/remote/controller.ts
403140
+ var ACTION_TIMEOUT_MS = 3e4;
403141
+ var RemoteBrowserController = class {
403142
+ provider;
403143
+ maxSessions;
403144
+ /**
403145
+ * Live-view listeners, one per run.
403146
+ *
403147
+ * A single callback was fine while each run had its own controller. Now the
403148
+ * controller is shared across a deployment, so one callback would send run
403149
+ * A's live-view URL to whichever run registered last — and that URL is a
403150
+ * bearer capability for a browser somebody else is driving.
403151
+ */
403152
+ liveViewListeners = /* @__PURE__ */ new Map();
403153
+ /** An embedder that wants every run's live view, told which run each is. */
403154
+ onLiveViewAll;
403155
+ runs = /* @__PURE__ */ new Map();
403156
+ /**
403157
+ * Runs that have a pool slot but no browser yet.
403158
+ *
403159
+ * Counted alongside `runs` against the cap. Without it the limit was a
403160
+ * check-then-act across four awaits, and simultaneous first uses both won.
403161
+ */
403162
+ opening = /* @__PURE__ */ new Set();
403163
+ /**
403164
+ * A run's stop signal, created when its slot is reserved.
403165
+ *
403166
+ * Before this, Stop looked only in `runs` — so during the first `open()`,
403167
+ * while the run existed only in `opening` and four awaits were in flight, it
403168
+ * found nothing and returned. The open then completed, built a FRESH
403169
+ * unaborted controller, and the click the user had stopped went ahead. The
403170
+ * signal has to exist before the browser does.
403171
+ */
403172
+ aborts = /* @__PURE__ */ new Map();
403173
+ /**
403174
+ * Runs whose provider session is being released right now.
403175
+ *
403176
+ * `stopRun`, `endRun`, `releaseIfIdle`, and the stale-page branch of `open`
403177
+ * all delete a run from `runs` synchronously and only THEN await
403178
+ * `disposeRun` settling — the browser disconnect, and `provider.endSession`,
403179
+ * which for Steel is a real HTTP `/release` call. Between that delete and
403180
+ * the call actually finishing, the admission check in `open()` — which
403181
+ * counts only `runs` and `opening` — saw the slot as free, so a second run
403182
+ * could pass admission and call `createSession` before the first run's
403183
+ * session was truly released. At `maxSessions: 1` that is two live, BILLED
403184
+ * provider sessions at once, exactly what the pool exists to prevent.
403185
+ * Counting this set alongside the other two closes the window.
403186
+ *
403187
+ * Keyed by run id, not a counter: every one of those four call sites checks
403188
+ * the run is still IN `runs` before deleting it and starting teardown, and
403189
+ * that delete is synchronous, so at most one teardown can ever be in flight
403190
+ * for a given run id at a time — a second caller finds the entry already
403191
+ * gone and never starts a competing one. The entry is removed only once its
403192
+ * own teardown settles, so a later, genuine re-open of the same run id
403193
+ * never collides with one still in flight.
403194
+ */
403195
+ closing = /* @__PURE__ */ new Set();
403196
+ /** Runs the user has stopped, by run id — same meaning as on the desktop. */
403197
+ revoked = /* @__PURE__ */ new Set();
403198
+ /**
403199
+ * One lease PER RUN, because there is one browser per run.
403200
+ *
403201
+ * The desktop has a single lease because it has a single browser. Here each
403202
+ * run gets its own session, so a global lease would make run B queue behind
403203
+ * run A for a browser it was never going to use — and with no queue timeout,
403204
+ * queue for as long as A lasts. What limits runs against each other is the
403205
+ * session POOL, not the lease.
403206
+ */
403207
+ leases = /* @__PURE__ */ new Map();
403208
+ constructor(options) {
403209
+ this.provider = options.provider;
403210
+ const asked = Math.max(1, options.maxSessions ?? 1);
403211
+ this.maxSessions = options.provider.isolatesSessions ? asked : 1;
403212
+ this.onLiveViewAll = options.onLiveView;
403213
+ }
403214
+ leaseFor(runId) {
403215
+ let lease = this.leases.get(runId);
403216
+ if (!lease) {
403217
+ lease = new BrowserLease({ isRevoked: (id) => this.revoked.has(id) });
403218
+ lease.setLifecycleReleaseWired(true);
403219
+ this.leases.set(runId, lease);
403220
+ }
403221
+ return lease;
403222
+ }
403223
+ /** The controller to hand to `Cascade.setBrowserController`. */
403224
+ get controller() {
403225
+ return (action, context) => this.act(action, context);
403226
+ }
403227
+ /** Watch one run's browser. The URL never goes to any other run's listener. */
403228
+ onLiveViewFor(runKey, listener) {
403229
+ this.liveViewListeners.set(runKey, listener);
403230
+ }
403231
+ offLiveViewFor(runKey) {
403232
+ this.liveViewListeners.delete(runKey);
403233
+ }
403234
+ /**
403235
+ * Tell a run's listener what its browser situation is.
403236
+ *
403237
+ * `active` is separate from the URL and both are needed: a provider with no
403238
+ * live view is attached-but-unwatchable, and a finished run is not attached
403239
+ * at all. Both have no URL, so a listener given only the URL cannot tell them
403240
+ * apart — and would drop the Stop control for the first as if it were the
403241
+ * second.
403242
+ */
403243
+ announceLiveView(runId, liveViewUrl, active) {
403244
+ this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
403245
+ this.onLiveViewAll?.(runId, liveViewUrl);
403246
+ }
403247
+ /**
403248
+ * Hand back a session the run turned out not to want.
403249
+ *
403250
+ * A browser opened for an action that is then refused would otherwise stay
403251
+ * allocated and billed until the whole run ended, for a run that has just
403252
+ * been stopped.
403253
+ */
403254
+ async releaseIfIdle(runId) {
403255
+ const held = this.runs.get(runId);
403256
+ if (!held) return;
403257
+ this.runs.delete(runId);
403258
+ this.closing.add(runId);
403259
+ await this.teardown(runId, held);
403260
+ }
403261
+ /** Release every run's session. For when the deployment's config changes. */
403262
+ async dispose() {
403263
+ const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening]);
403264
+ await Promise.all([...ids].map((runId) => this.endRun(runId)));
403265
+ }
403266
+ /** A worker finished; it will never ask for the browser again. */
403267
+ actorEnded(actorId) {
403268
+ for (const lease of this.leases.values()) lease.actorEnded(actorId);
403269
+ }
403270
+ /** The user stopped this run. Refuses further actions and clears its queue. */
403271
+ stopRun(runId) {
403272
+ this.revoked.add(runId);
403273
+ this.leases.get(runId)?.dropRun(runId);
403274
+ this.aborts.get(runId)?.abort();
403275
+ const held = this.runs.get(runId);
403276
+ if (!held) return;
403277
+ this.runs.delete(runId);
403278
+ this.closing.add(runId);
403279
+ void this.teardown(runId, held);
403280
+ }
403281
+ /** A run ended: release its browser rather than pay for an idle session. */
403282
+ async endRun(runId) {
403283
+ this.leases.get(runId)?.dropRun(runId);
403284
+ this.aborts.get(runId)?.abort();
403285
+ const held = this.runs.get(runId);
403286
+ if (!held) {
403287
+ this.forgetRun(runId);
403288
+ return;
403289
+ }
403290
+ this.runs.delete(runId);
403291
+ this.closing.add(runId);
403292
+ await this.teardown(runId, held);
403293
+ this.forgetRun(runId);
403294
+ }
403295
+ /** Forget a finished run entirely, so nothing accumulates per run. */
403296
+ forgetRun(runId) {
403297
+ this.revoked.delete(runId);
403298
+ this.leases.delete(runId);
403299
+ this.aborts.delete(runId);
403300
+ this.liveViewListeners.delete(runId);
403301
+ }
403302
+ /**
403303
+ * Let go of one run's browser.
403304
+ *
403305
+ * Best-effort throughout: a run is already over by the time this runs, and a
403306
+ * provider that is briefly unreachable must not turn that into a failure the
403307
+ * user sees. The provider's own idle timeout collects anything left.
403308
+ */
403309
+ async disposeRun(runId, held) {
403310
+ held.abort.abort();
403311
+ this.announceLiveView(runId, void 0, false);
403312
+ await held.ownedContext?.close().catch(() => {
403313
+ });
403314
+ await held.browser.close().catch(() => {
403315
+ });
403316
+ await this.provider.endSession(held.session.id).catch(() => {
403317
+ });
403318
+ }
403319
+ /**
403320
+ * Run `disposeRun`, with the run id counted in `closing` until it settles.
403321
+ *
403322
+ * The caller adds the id to `closing` itself, synchronously, in the same
403323
+ * breath as deleting the run from `runs` — see the field comment. This just
403324
+ * guarantees the removal happens, success or failure, so a disposal that
403325
+ * throws cannot leave the slot counted against the pool forever.
403326
+ */
403327
+ async teardown(runId, held) {
403328
+ try {
403329
+ await this.disposeRun(runId, held);
403330
+ } finally {
403331
+ this.closing.delete(runId);
403332
+ }
403333
+ }
403334
+ async act(action, context) {
403335
+ const runId = context.sessionId;
403336
+ if (!runId) {
403337
+ return { ok: false, detail: "This browser action arrived without a run identity, so it was not run." };
403338
+ }
403339
+ const actor = context.actorId || runId;
403340
+ if (this.revoked.has(runId)) {
403341
+ return { ok: false, detail: "The user stopped browser control for this run." };
403342
+ }
403343
+ if (context.signal?.aborted) {
403344
+ return { ok: false, detail: "The run was cancelled." };
403345
+ }
403346
+ const planned = this.runs.get(runId)?.generation ?? 0;
403347
+ const lease_ = this.leaseFor(runId);
403348
+ const lease = await lease_.acquire(actor, runId, context.signal);
403349
+ if (lease !== "granted") {
403350
+ return { ok: false, detail: refusal(lease) };
403351
+ }
403352
+ if (this.revoked.has(runId)) {
403353
+ lease_.releaseIfHeldBy(actor);
403354
+ return { ok: false, detail: "The user stopped browser control for this run." };
403355
+ }
403356
+ if (context.signal?.aborted) {
403357
+ lease_.releaseIfHeldBy(actor);
403358
+ return { ok: false, detail: "The run was cancelled." };
403359
+ }
403360
+ if (!await lease_.awaitActionSlot()) {
403361
+ lease_.releaseIfHeldBy(actor);
403362
+ return { ok: false, detail: "Another browser action is still finishing. Try again." };
403363
+ }
403364
+ const token = lease_.beginAction();
403365
+ if (!token) {
403366
+ return { ok: false, detail: "Another browser action is already running. Wait for it to finish." };
403367
+ }
403368
+ try {
403369
+ const held = await this.open(runId, context.signal);
403370
+ if (this.revoked.has(runId)) {
403371
+ await this.releaseIfIdle(runId);
403372
+ return { ok: false, detail: "The user stopped browser control for this run." };
403373
+ }
403374
+ if (context.signal?.aborted || held.abort.signal.aborted) {
403375
+ await this.releaseIfIdle(runId);
403376
+ return { ok: false, detail: "The run was cancelled." };
403377
+ }
403378
+ return await this.perform(held, action, planned);
403379
+ } catch (err) {
403380
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
403381
+ } finally {
403382
+ lease_.endAction(token);
403383
+ if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
403384
+ }
403385
+ }
403386
+ /**
403387
+ * The run's browser, opened on first use.
403388
+ *
403389
+ * The ONE place the session cap is enforced. An earlier version also checked
403390
+ * it before taking the lease, on the reasoning that a run which cannot get a
403391
+ * session would otherwise queue forever — true when every run shared one
403392
+ * lease, and false once each run got its own, because a second run no longer
403393
+ * queues behind the first at all. Its own revert-check proved it dead: taking
403394
+ * it out changed no test. Two copies of a cap invite them to disagree.
403395
+ */
403396
+ async open(runId, signal) {
403397
+ const existing = this.runs.get(runId);
403398
+ if (existing && !existing.page.isClosed()) return existing;
403399
+ if (existing) {
403400
+ this.runs.delete(runId);
403401
+ this.closing.add(runId);
403402
+ await this.teardown(runId, existing);
403403
+ }
403404
+ if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
403405
+ throw new Error(
403406
+ `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.`
403407
+ );
403408
+ }
403409
+ this.opening.add(runId);
403410
+ if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
403411
+ try {
403412
+ return await this.openReserved(runId, signal);
403413
+ } finally {
403414
+ this.opening.delete(runId);
403415
+ }
403416
+ }
403417
+ /** The part that may fail, with the pool slot already reserved. */
403418
+ async openReserved(runId, signal) {
403419
+ const playwright = await loadPlaywright();
403420
+ const abort = this.aborts.get(runId)?.signal;
403421
+ const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
403422
+ const session = await this.provider.createSession(creationSignal);
403423
+ this.announceLiveView(runId, session.liveViewUrl, true);
403424
+ let browser2;
403425
+ let owned;
403426
+ try {
403427
+ browser2 = await playwright.chromium.connectOverCDP(session.cdpUrl);
403428
+ owned = this.provider.isolatesSessions ? void 0 : await browser2.newContext();
403429
+ const context = owned ?? browser2.contexts()[0] ?? await browser2.newContext();
403430
+ const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
403431
+ const abort2 = this.aborts.get(runId);
403432
+ if (!abort2) throw new Error("The run ended while its browser was still opening.");
403433
+ const held = { session, browser: browser2, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
403434
+ page2.on("framenavigated", ((frame) => {
403435
+ if (frame !== page2.mainFrame()) return;
403436
+ held.generation += 1;
403437
+ }));
403438
+ this.runs.set(runId, held);
403439
+ return held;
403440
+ } catch (err) {
403441
+ this.announceLiveView(runId, void 0, false);
403442
+ await owned?.close().catch(() => {
403443
+ });
403444
+ await browser2?.close().catch(() => {
403445
+ });
403446
+ await this.provider.endSession(session.id).catch(() => {
403447
+ });
403448
+ throw err;
403449
+ }
403450
+ }
403451
+ async perform(held, action, planned) {
403452
+ const { page: page2 } = held;
403453
+ const stoppable = async (work) => {
403454
+ if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
403455
+ let onAbort;
403456
+ try {
403457
+ return await Promise.race([
403458
+ work,
403459
+ new Promise((_4, reject) => {
403460
+ onAbort = () => {
403461
+ void page2.close().catch(() => {
403462
+ });
403463
+ reject(new Error("The user stopped browser control for this run."));
403464
+ };
403465
+ held.abort.signal.addEventListener("abort", onAbort, { once: true });
403466
+ })
403467
+ ]);
403468
+ } finally {
403469
+ held.abort.signal.removeEventListener("abort", onAbort);
403470
+ }
403471
+ };
403472
+ const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
403473
+ const where = async () => ({
403474
+ url: page2.url(),
403475
+ title: await page2.title().catch(() => "")
403476
+ });
403477
+ if (action.kind !== "navigate" && held.generation !== planned) {
403478
+ return {
403479
+ ok: false,
403480
+ detail: "The page changed while this action was waiting for the browser, so it was not run. Look at the page again before retrying.",
403481
+ ...await where()
403482
+ };
403483
+ }
403484
+ switch (action.kind) {
403485
+ case "navigate": {
403486
+ const target = action.url ?? "";
403487
+ if (!/^https?:\/\//i.test(target)) {
403488
+ return { ok: false, detail: "Only http and https addresses can be opened." };
403489
+ }
403490
+ await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
403491
+ return { ok: true, detail: `Opened ${target}`, ...await where() };
403492
+ }
403493
+ case "click":
403494
+ await stoppable(page2.click(action.selector, { timeout }));
403495
+ return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
403496
+ case "fill":
403497
+ await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
403498
+ return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
403499
+ case "press": {
403500
+ if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
403501
+ else await stoppable(page2.keyboard.press(action.key));
403502
+ return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
403503
+ }
403504
+ case "wait_for":
403505
+ await stoppable(page2.waitForSelector(action.selector, { timeout }));
403506
+ return { ok: true, detail: `${action.selector} appeared`, ...await where() };
403507
+ case "extract_text": {
403508
+ const text = await stoppable(page2.innerText(action.selector ?? "body", { timeout }));
403509
+ return { ok: true, detail: text.slice(0, 2e5), ...await where() };
403510
+ }
403511
+ default:
403512
+ return { ok: false, detail: `Unsupported action: ${String(action.kind)}` };
403513
+ }
403514
+ }
403515
+ };
403516
+ function refusal(result) {
403517
+ if (result === "off") return "Browser control was turned off while this action waited for the browser.";
403518
+ if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
403519
+ 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.";
403520
+ }
403521
+ async function loadPlaywright() {
403522
+ try {
403523
+ return await import('playwright');
403524
+ } catch {
403525
+ throw new Error("Playwright is not installed. Run: npm install playwright (no browser download is needed \u2014 this connects to a remote browser).");
403526
+ }
403527
+ }
403528
+
403529
+ // src/browser/remote/generic-cdp.ts
403530
+ var GenericCdpProvider = class {
403531
+ name = "CDP endpoint";
403532
+ // The endpoint is a single browser. There is no session to allocate, so two
403533
+ // runs against it share a page — see the seam.
403534
+ isolatesSessions = false;
403535
+ endpoint;
403536
+ constructor(endpoint) {
403537
+ this.endpoint = endpoint;
403538
+ }
403539
+ /**
403540
+ * There is no session to create — the endpoint is the session.
403541
+ *
403542
+ * `id` is a constant rather than a generated one because there is nothing to
403543
+ * look up later: `endSession` has nothing to call. Inventing a unique id
403544
+ * would imply a lifecycle this provider does not have.
403545
+ */
403546
+ async createSession() {
403547
+ return { id: "cdp", cdpUrl: this.endpoint };
403548
+ }
403549
+ /**
403550
+ * Deliberately nothing.
403551
+ *
403552
+ * The endpoint belongs to whoever runs it and outlives any single run, so
403553
+ * "ending" it is not ours to do — closing it would take the browser away from
403554
+ * every other user of a shared endpoint. The driver still closes its own
403555
+ * connection and its pages; this is only about the browser's lifetime.
403556
+ */
403557
+ async endSession() {
403558
+ }
403559
+ };
403560
+ function isCdpEndpoint(url2) {
403561
+ try {
403562
+ const u2 = new URL(url2);
403563
+ return u2.protocol === "ws:" || u2.protocol === "wss:";
403564
+ } catch {
403565
+ return false;
403566
+ }
403567
+ }
403568
+
403569
+ // src/browser/remote/steel.ts
403570
+ var DEFAULT_BASE = "https://api.steel.dev";
403571
+ var CREATE_TIMEOUT_MS = 6e4;
403572
+ var SteelProvider = class {
403573
+ name = "Steel";
403574
+ // POST /v1/sessions allocates a browser per call, so concurrent runs are
403575
+ // genuinely separate.
403576
+ isolatesSessions = true;
403577
+ base;
403578
+ apiKey;
403579
+ constructor(opts = {}) {
403580
+ let base = opts.url || DEFAULT_BASE;
403581
+ while (base.endsWith("/")) base = base.slice(0, -1);
403582
+ this.base = base;
403583
+ this.apiKey = opts.apiKey;
403584
+ }
403585
+ async createSession(signal) {
403586
+ const details = await this.call("POST", "/v1/sessions", signal, {});
403587
+ if (!details.id || !details.websocketUrl) {
403588
+ throw new Error("Steel returned a session with no id or websocket URL.");
403589
+ }
403590
+ return {
403591
+ id: details.id,
403592
+ cdpUrl: details.websocketUrl,
403593
+ ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
403594
+ };
403595
+ }
403596
+ async endSession(id) {
403597
+ try {
403598
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
403599
+ } catch {
403600
+ }
403601
+ }
403602
+ async call(method, path32, signal, body) {
403603
+ const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
403604
+ const composed = signal ? AbortSignal.any([signal, timeout]) : timeout;
403605
+ const res = await fetch(`${this.base}${path32}`, {
403606
+ method,
403607
+ headers: {
403608
+ "content-type": "application/json",
403609
+ // Sent only when configured — the self-hosted API takes no key, and an
403610
+ // empty header is worse than none.
403611
+ ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
403612
+ },
403613
+ body: JSON.stringify(body),
403614
+ signal: composed
403615
+ });
403616
+ if (!res.ok) {
403617
+ const detail = await res.text().then((t4) => t4.slice(0, 300)).catch(() => "");
403618
+ throw new Error(`Steel ${method} ${path32} failed: ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
403619
+ }
403620
+ return await res.json();
403621
+ }
403622
+ };
403623
+
402593
403624
  // src/retrieval/chunk.ts
402594
403625
  var DEFAULTS2 = { targetChars: 2e3, overlapChars: 200, minChars: 80 };
402595
403626
  function splitLongBlock(block, max) {
@@ -403673,6 +404704,8 @@ object-assign/index.js:
403673
404704
  exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
403674
404705
  exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
403675
404706
  exports.AuditLogger = AuditLogger;
404707
+ exports.BrowserControlTool = BrowserControlTool;
404708
+ exports.BrowserLease = BrowserLease;
403676
404709
  exports.CASCADE_AUDIT_FILE = CASCADE_AUDIT_FILE;
403677
404710
  exports.CASCADE_CONFIG_DIR = CASCADE_CONFIG_DIR;
403678
404711
  exports.CASCADE_CONFIG_FILE = CASCADE_CONFIG_FILE;
@@ -403713,6 +404746,7 @@ exports.GLOBAL_DB_FILE = GLOBAL_DB_FILE;
403713
404746
  exports.GLOBAL_KEYSTORE_FILE = GLOBAL_KEYSTORE_FILE;
403714
404747
  exports.GLOBAL_RUNTIME_DB_FILE = GLOBAL_RUNTIME_DB_FILE;
403715
404748
  exports.GenerateDocumentTool = GenerateDocumentTool;
404749
+ exports.GenericCdpProvider = GenericCdpProvider;
403716
404750
  exports.GraphRetriever = GraphRetriever;
403717
404751
  exports.GraphSearchTool = GraphSearchTool;
403718
404752
  exports.HooksRunner = HooksRunner;
@@ -403729,9 +404763,11 @@ exports.OLLAMA_BASE_URL = OLLAMA_BASE_URL;
403729
404763
  exports.OpenAICompatibleEmbedder = OpenAICompatibleEmbedder;
403730
404764
  exports.PROVIDER_DISPLAY_NAMES = PROVIDER_DISPLAY_NAMES;
403731
404765
  exports.RESUME_SCHEMA_VERSION = RESUME_SCHEMA_VERSION;
404766
+ exports.RemoteBrowserController = RemoteBrowserController;
403732
404767
  exports.ResumeStore = ResumeStore;
403733
404768
  exports.Retriever = Retriever;
403734
404769
  exports.SqliteVectorStore = SqliteVectorStore;
404770
+ exports.SteelProvider = SteelProvider;
403735
404771
  exports.T1Administrator = T1Administrator;
403736
404772
  exports.T1_MODEL_PRIORITY = T1_MODEL_PRIORITY;
403737
404773
  exports.T2Manager = T2Manager;
@@ -403796,6 +404832,7 @@ exports.hashContent = hashContent;
403796
404832
  exports.heuristicCodeChunker = heuristicCodeChunker;
403797
404833
  exports.inferAzureBaseModel = inferAzureBaseModel;
403798
404834
  exports.inlineRuns = inlineRuns;
404835
+ exports.isCdpEndpoint = isCdpEndpoint;
403799
404836
  exports.isDocumentFormat = isDocumentFormat;
403800
404837
  exports.isMcpToolName = isMcpToolName;
403801
404838
  exports.matchImageLine = matchImageLine;