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.
package/dist/cli.js CHANGED
@@ -1796,7 +1796,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1796
1796
  var init_constants = __esm({
1797
1797
  "src/constants.ts"() {
1798
1798
  init_pricing();
1799
- CASCADE_VERSION = "0.77.0";
1799
+ CASCADE_VERSION = "0.79.0";
1800
1800
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1801
1801
  CASCADE_DB_FILE = ".cascade/memory.db";
1802
1802
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -15487,6 +15487,45 @@ var ToolsConfigSchema = z.object({
15487
15487
  shellBlocklist: z.array(z.string()).default(["rm -rf", "sudo rm", "format", "mkfs"]),
15488
15488
  requireApprovalFor: z.array(z.string()).default([]),
15489
15489
  browserEnabled: z.boolean().default(false),
15490
+ /**
15491
+ * Let a run ACT on the page open in the desktop's built-in browser — click,
15492
+ * type, submit — not just read it.
15493
+ *
15494
+ * Distinct from `browserEnabled`, which turns on the headless Playwright
15495
+ * tool. That one drives a throwaway browser with an empty cookie jar; this
15496
+ * one drives the session the user is signed into, which is a different order
15497
+ * of consequence and so gets its own switch rather than riding on that one.
15498
+ *
15499
+ * Off by default. Turning it on does not hand anything over silently: the
15500
+ * tool still goes through the approval chain, and the browser panel carries a
15501
+ * stop control for the run that is acting.
15502
+ */
15503
+ agentBrowserControl: z.boolean().default(false),
15504
+ /**
15505
+ * Where a HOSTED run gets a browser, since it has none of its own.
15506
+ *
15507
+ * The desktop drives the Chromium the user is signed into and can watch. A
15508
+ * server has neither, and running one inside the container is wrong twice
15509
+ * over — page JavaScript issues requests no URL check ever sees, from inside
15510
+ * the trust boundary, and a session costs 200-400 MB. So this points at a
15511
+ * browser somebody else runs.
15512
+ *
15513
+ * Absent by default: no provider, no capability. There is nothing to turn
15514
+ * off because nothing is registered until an endpoint is supplied.
15515
+ */
15516
+ remoteBrowser: z.object({
15517
+ /** `cdp` for an endpoint you run; `steel` for a Steel deployment. */
15518
+ provider: z.enum(["cdp", "steel"]).optional(),
15519
+ /** The websocket endpoint for `cdp`, or the API base for `steel`. */
15520
+ url: z.string().optional(),
15521
+ apiKey: z.string().optional(),
15522
+ /**
15523
+ * Concurrent sessions. One by default and deliberately so: every session
15524
+ * is billed, and a wave of workers each opening their own is a cost the
15525
+ * user did not choose. Raising it is a decision, not a default.
15526
+ */
15527
+ maxSessions: z.number().int().min(1).max(16).default(1)
15528
+ }).optional(),
15490
15529
  mcpServers: z.array(McpServerConfigSchema).optional(),
15491
15530
  mcpTrusted: z.array(z.string()).optional(),
15492
15531
  /** Web search backends — at least one should be configured for best results */
@@ -21411,6 +21450,23 @@ Now execute your subtask using this context where relevant.`
21411
21450
  this.setStatus("FAILED", finalOutput);
21412
21451
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput, "FAILED");
21413
21452
  return this.buildResult("ESCALATED", finalOutput, { checksRun, passed, failed }, issues, correctionAttempts);
21453
+ } finally {
21454
+ this.releaseBrowserControl();
21455
+ }
21456
+ }
21457
+ /**
21458
+ * End this worker's hold on the shared browser.
21459
+ *
21460
+ * Duck-typed rather than imported: `browser_control` is registered only by a
21461
+ * host that HAS a browser (the desktop), so in the CLI and hosted runs there
21462
+ * is no such tool and this is a no-op. Importing the class to instanceof-check
21463
+ * it would couple the worker to a tool that usually is not there.
21464
+ */
21465
+ releaseBrowserControl() {
21466
+ try {
21467
+ const tool = this.toolRegistry.getTool?.("browser_control");
21468
+ tool?.releaseActor?.(this.id);
21469
+ } catch {
21414
21470
  }
21415
21471
  }
21416
21472
  sendToPeer(toId, content) {
@@ -31214,6 +31270,127 @@ var CurrentPageTool = class extends BaseTool {
31214
31270
  }
31215
31271
  };
31216
31272
 
31273
+ // src/tools/browser-control.ts
31274
+ var BrowserControlTool = class extends BaseTool {
31275
+ name = "browser_control";
31276
+ 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.";
31277
+ inputSchema = {
31278
+ type: "object",
31279
+ properties: {
31280
+ action: {
31281
+ type: "string",
31282
+ enum: ["navigate", "click", "fill", "press", "wait_for", "extract_text"],
31283
+ description: "What to do."
31284
+ },
31285
+ url: { type: "string", description: "For navigate: the http(s) address to open." },
31286
+ selector: {
31287
+ type: "string",
31288
+ description: "CSS selector for click, fill and wait_for. Optional for extract_text (defaults to the whole page)."
31289
+ },
31290
+ value: { type: "string", description: "For fill: the text to type." },
31291
+ key: { type: "string", description: 'For press: a key name such as "Enter", "Tab" or "Escape".' },
31292
+ timeoutMs: { type: "number", description: "For wait_for: how long to wait before giving up (default 10000, max 30000)." }
31293
+ },
31294
+ required: ["action"]
31295
+ };
31296
+ controller;
31297
+ release;
31298
+ /** One-way. See `revoke`. */
31299
+ revoked = false;
31300
+ constructor(controller, release) {
31301
+ super();
31302
+ this.controller = controller;
31303
+ this.release = release;
31304
+ }
31305
+ /**
31306
+ * A worker has finished. Ends its hold on the browser.
31307
+ *
31308
+ * Called from the tier's terminal path rather than by the model, because a
31309
+ * model cannot be relied on to announce that it is done — which is why the
31310
+ * host cannot use idle time as the boundary instead.
31311
+ */
31312
+ releaseActor(actorId) {
31313
+ this.release?.(actorId);
31314
+ }
31315
+ /**
31316
+ * Permanently disable this tool, whatever the registry still holds.
31317
+ *
31318
+ * Registration-time gates cannot express "this run turned out to be
31319
+ * unattended": a host that wires the controller and only then declares the
31320
+ * run unattended has already registered the tool, and a check that ran at
31321
+ * registration has no second chance. Enforcing at EXECUTION time makes the
31322
+ * guarantee independent of call order, which is the whole point of stating
31323
+ * the invariant rather than relying on the scheduler's current sequence.
31324
+ *
31325
+ * One-way on purpose. A safety gate that can be re-opened by a later call is
31326
+ * a gate that a refactor re-opens by accident.
31327
+ */
31328
+ revoke() {
31329
+ this.revoked = true;
31330
+ }
31331
+ // Acting on a session the user is signed into is the highest-consequence
31332
+ // thing in the tool set — higher than shell, arguably, because the blast
31333
+ // radius is someone else's server rather than this machine.
31334
+ isDangerous() {
31335
+ return true;
31336
+ }
31337
+ async execute(input, options) {
31338
+ if (this.revoked) {
31339
+ return "Error: browser control is not available in this run. It requires a person watching who can stop it, and nobody is.";
31340
+ }
31341
+ const kind = input["action"];
31342
+ if (!kind) return "Error: action is required.";
31343
+ if (options?.signal?.aborted) return "Error: the run was cancelled before this action ran.";
31344
+ const missing = requiredFieldFor(kind, input);
31345
+ if (missing) return `Error: "${kind}" needs ${missing}.`;
31346
+ const action = {
31347
+ kind,
31348
+ ...typeof input["url"] === "string" ? { url: input["url"] } : {},
31349
+ ...typeof input["selector"] === "string" ? { selector: input["selector"] } : {},
31350
+ ...typeof input["value"] === "string" ? { value: input["value"] } : {},
31351
+ ...typeof input["key"] === "string" ? { key: input["key"] } : {},
31352
+ ...typeof input["timeoutMs"] === "number" ? { timeoutMs: Math.min(Math.max(input["timeoutMs"], 0), 3e4) } : {}
31353
+ };
31354
+ let outcome;
31355
+ try {
31356
+ outcome = await this.controller(action, {
31357
+ // `sessionId` is the run. The host scopes both revocation and its
31358
+ // single-owner lease on it, so passing it is not telemetry — drop it
31359
+ // and a Stop in one run silently stops every later one.
31360
+ sessionId: options?.sessionId ?? "",
31361
+ actorId: options?.tierId ?? options?.sessionId ?? "",
31362
+ ...options?.signal ? { signal: options.signal } : {}
31363
+ });
31364
+ } catch (err) {
31365
+ return `Error: the browser could not perform "${kind}" \u2014 ${err instanceof Error ? err.message : String(err)}`;
31366
+ }
31367
+ const where = outcome.url ? `
31368
+ Page: ${outcome.title ? `${outcome.title} \u2014 ` : ""}${outcome.url}` : "";
31369
+ return `${outcome.ok ? "" : "Failed: "}${outcome.detail}${where}`;
31370
+ }
31371
+ };
31372
+ function requiredFieldFor(kind, input) {
31373
+ const has = (k) => typeof input[k] === "string" && input[k].length > 0;
31374
+ const given = (k) => typeof input[k] === "string";
31375
+ switch (kind) {
31376
+ case "navigate":
31377
+ return has("url") ? null : "a url";
31378
+ case "click":
31379
+ return has("selector") ? null : "a selector";
31380
+ case "fill":
31381
+ return has("selector") ? given("value") ? null : "a value" : "a selector";
31382
+ case "press":
31383
+ return has("key") ? null : "a key";
31384
+ case "wait_for":
31385
+ return has("selector") ? null : "a selector";
31386
+ // extract_text works with or without a selector.
31387
+ case "extract_text":
31388
+ return null;
31389
+ default:
31390
+ return null;
31391
+ }
31392
+ }
31393
+
31217
31394
  // src/core/cascade.ts
31218
31395
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
31219
31396
  function buildContextualPrompt(prompt, history = []) {
@@ -31706,6 +31883,79 @@ ${input}` }],
31706
31883
  if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
31707
31884
  this.toolRegistry.register(new CurrentPageTool(provider));
31708
31885
  }
31886
+ /**
31887
+ * True when nobody is watching this run — a scheduled/cron task, which
31888
+ * auto-approves every tool because there is no one to ask.
31889
+ *
31890
+ * That auto-approval is fine for ordinary tools and disqualifying for this
31891
+ * one: `browser_control` drives the signed-in browser, and "every run asks
31892
+ * first" is the promise the Settings copy makes. The property used to hold
31893
+ * only because `runScheduledTask` happens not to call `setBrowserController`
31894
+ * — a guarantee resting on an ABSENT call, which is exactly what a later
31895
+ * refactor adds without noticing. Stated and enforced instead.
31896
+ */
31897
+ unattended = false;
31898
+ /**
31899
+ * Declare that no human is watching this run, so capabilities that depend on
31900
+ * a person being able to see and stop them are refused outright.
31901
+ */
31902
+ setUnattended(on) {
31903
+ this.unattended = on;
31904
+ if (!on) return;
31905
+ const tool = this.toolRegistry.getTool("browser_control");
31906
+ tool?.revoke?.();
31907
+ }
31908
+ /**
31909
+ * Wire the host's browser so a run can ACT on the open page, not just read it.
31910
+ *
31911
+ * Two gates before this registers anything, and both matter:
31912
+ *
31913
+ * - `tools.agentBrowserControl` must be on. Unlike read_current_page, which
31914
+ * reaches nothing the user has not already opened themselves, this drives
31915
+ * the session they are signed into. That is opt-in, not a default.
31916
+ * - `disabledTools` still removes it, the same as every other tool.
31917
+ * - The run must not be UNATTENDED. See `setUnattended`.
31918
+ *
31919
+ * Host-supplied like the page provider: the tool exists only where there is a
31920
+ * real browser to drive, so it never has to be refused in the CLI or a hosted
31921
+ * run — it is simply not there.
31922
+ */
31923
+ setBrowserController(controller, release) {
31924
+ this.registerBrowserControl(controller, release, this.config.tools?.agentBrowserControl === true);
31925
+ }
31926
+ /**
31927
+ * Wire a REMOTE browser — one the host connects to rather than one the user
31928
+ * is signed into.
31929
+ *
31930
+ * A separate entry point because the enablement is a different question, and
31931
+ * conflating them made the hosted capability dead on arrival: this went out
31932
+ * calling `setBrowserController`, which gates on `agentBrowserControl`, and
31933
+ * that flag means "drive the browser I am signed into on this machine". It
31934
+ * defaults to false and has no meaning on a server, so the hosted tool was
31935
+ * never registered at all — the feature looked shipped and did nothing.
31936
+ *
31937
+ * Configuring a provider IS the opt-in here. It takes a deliberate act with
31938
+ * an endpoint and usually a paid account, and the browser it reaches is a
31939
+ * throwaway signed into nothing — a smaller consent than handing over the
31940
+ * session the user is already authenticated in, and one the desktop flag
31941
+ * cannot stand in for.
31942
+ */
31943
+ setRemoteBrowserController(controller, release) {
31944
+ this.registerBrowserControl(controller, release, Boolean(this.config.tools?.remoteBrowser?.provider));
31945
+ }
31946
+ /**
31947
+ * The gates every surface shares, with only enablement differing.
31948
+ *
31949
+ * One place, because the two callers above are exactly the sibling-writer
31950
+ * shape that has already drifted twice in this feature: a rule added to one
31951
+ * and not the other is silent on the surface that missed it.
31952
+ */
31953
+ registerBrowserControl(controller, release, enabled) {
31954
+ if (this.unattended) return;
31955
+ if (!enabled) return;
31956
+ if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31957
+ this.toolRegistry.register(new BrowserControlTool(controller, release));
31958
+ }
31709
31959
  registerMediaTools(workspacePath) {
31710
31960
  const denied = new Set(this.config.tools?.disabledTools ?? []);
31711
31961
  const permitted = (name) => !denied.has(name);
@@ -32311,6 +32561,7 @@ ${prompt}` : prompt;
32311
32561
  this.router.setRunSignal(options.signal);
32312
32562
  const startMs = Date.now();
32313
32563
  const taskId = randomUUID();
32564
+ this.emit("run:started", { taskId });
32314
32565
  this.decisionLog = [];
32315
32566
  this.currentRunCompleted = [];
32316
32567
  this.wroteCheckpointThisRun = false;
@@ -38178,6 +38429,20 @@ function applySettingsPayload(config, data) {
38178
38429
  if (data.webSearch.tavilyApiKey) next.tavilyApiKey = data.webSearch.tavilyApiKey;
38179
38430
  config.tools.webSearch = next;
38180
38431
  }
38432
+ if (data.remoteBrowser && typeof data.remoteBrowser === "object") {
38433
+ const prior = config.tools.remoteBrowser ?? {};
38434
+ const next = { ...prior };
38435
+ if (data.remoteBrowser.provider === "cdp" || data.remoteBrowser.provider === "steel") {
38436
+ next.provider = data.remoteBrowser.provider;
38437
+ }
38438
+ if (typeof data.remoteBrowser.url === "string") next.url = data.remoteBrowser.url.trim() || void 0;
38439
+ if (data.remoteBrowser.apiKey) next.apiKey = data.remoteBrowser.apiKey;
38440
+ const n = data.remoteBrowser.maxSessions;
38441
+ if (typeof n === "number" && Number.isFinite(n)) {
38442
+ next.maxSessions = Math.min(Math.max(Math.floor(n), 1), 16);
38443
+ }
38444
+ config.tools.remoteBrowser = next;
38445
+ }
38181
38446
  if (data.advanced && typeof data.advanced === "object") {
38182
38447
  const a = data.advanced;
38183
38448
  const num = (v, min, max) => {
@@ -38208,6 +38473,7 @@ function applySettingsPayload(config, data) {
38208
38473
  if (["auto", "T1", "T2", "T3"].includes(a["forceTier"])) config.routing = { ...config.routing ?? {}, forceTier: a["forceTier"] };
38209
38474
  if (typeof a["benchmarksLive"] === "boolean") config.benchmarks = { ...config.benchmarks ?? {}, live: a["benchmarksLive"] };
38210
38475
  if (["isolate", "worker", "auto"].includes(a["dynamicToolSandbox"])) config.tools = { ...config.tools ?? {}, dynamicToolSandbox: a["dynamicToolSandbox"] };
38476
+ if (typeof a["agentBrowserControl"] === "boolean") config.tools = { ...config.tools ?? {}, agentBrowserControl: a["agentBrowserControl"] };
38211
38477
  if (typeof a["factsExtraction"] === "boolean") config.knowledge = { ...config.knowledge ?? {}, factsExtraction: a["factsExtraction"] };
38212
38478
  if (typeof a["rememberSessions"] === "boolean") config.memory = { ...config.memory ?? {}, rememberSessions: a["rememberSessions"] };
38213
38479
  if (typeof a["enableToolCreation"] === "boolean") config.enableToolCreation = a["enableToolCreation"];
@@ -38223,6 +38489,7 @@ function settingsSnapshot(config) {
38223
38489
  if (p?.type && p?.baseUrl && p.type !== "azure") endpoints[p.type] = p.baseUrl;
38224
38490
  }
38225
38491
  const ws = config.tools?.webSearch ?? {};
38492
+ const rb = config.tools?.remoteBrowser ?? {};
38226
38493
  return {
38227
38494
  models: config.models ?? {},
38228
38495
  budget: {
@@ -38252,6 +38519,15 @@ function settingsSnapshot(config) {
38252
38519
  hasBraveKey: typeof ws.braveApiKey === "string" && ws.braveApiKey.length > 0,
38253
38520
  hasTavilyKey: typeof ws.tavilyApiKey === "string" && ws.tavilyApiKey.length > 0
38254
38521
  },
38522
+ // `hasApiKey`, never the key. The panel needs to show whether one is set;
38523
+ // sending the value back would put a live credential in every settings
38524
+ // response, where it reaches any client that can read them.
38525
+ remoteBrowser: {
38526
+ provider: rb.provider,
38527
+ url: rb.url,
38528
+ hasApiKey: typeof rb.apiKey === "string" && rb.apiKey.length > 0,
38529
+ maxSessions: rb.maxSessions
38530
+ },
38255
38531
  advanced: {
38256
38532
  autonomy: config.autonomy,
38257
38533
  planApproval: config.planApproval,
@@ -38265,6 +38541,7 @@ function settingsSnapshot(config) {
38265
38541
  forceTier: config.routing?.forceTier,
38266
38542
  benchmarksLive: config.benchmarks?.live,
38267
38543
  dynamicToolSandbox: config.tools?.dynamicToolSandbox,
38544
+ agentBrowserControl: config.tools?.agentBrowserControl,
38268
38545
  factsExtraction: config.knowledge?.factsExtraction,
38269
38546
  rememberSessions: config.memory?.rememberSessions,
38270
38547
  enableToolCreation: config.enableToolCreation,
@@ -38775,6 +39052,14 @@ var DashboardServer = class {
38775
39052
  * always failing.
38776
39053
  */
38777
39054
  currentPageProvider;
39055
+ /**
39056
+ * Supplied by the desktop shell so a run can ACT on that page — click, type,
39057
+ * submit — not just read it. Same reasoning as the provider above: absent in
39058
+ * the CLI, so the tool is not registered rather than present and failing.
39059
+ * Cascade applies the `tools.agentBrowserControl` gate on top of this.
39060
+ */
39061
+ browserController;
39062
+ browserActorRelease;
38778
39063
  activeControllers = /* @__PURE__ */ new Map();
38779
39064
  /**
38780
39065
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -38836,6 +39121,7 @@ var DashboardServer = class {
38836
39121
  });
38837
39122
  this.socket.onConfigUpdate(async (data) => {
38838
39123
  const result = await commitSettings(this.config, data, (committed) => this.persistConfig(committed));
39124
+ if (result.ok) this.settingsChangeHook?.(this.config);
38839
39125
  const refused = result.refused.map((r) => ({
38840
39126
  type: r.type,
38841
39127
  reason: r.reason,
@@ -38859,6 +39145,7 @@ var DashboardServer = class {
38859
39145
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
38860
39146
  this.activeSessions.set(sessionId, cascade);
38861
39147
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
39148
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
38862
39149
  cascade.on("stream:token", (e) => {
38863
39150
  this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
38864
39151
  });
@@ -38882,13 +39169,16 @@ var DashboardServer = class {
38882
39169
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
38883
39170
  this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
38884
39171
  });
39172
+ cascade.on("run:started", (e) => {
39173
+ const id = e.taskId;
39174
+ if (id) this.recordSessionTask(sessionId, id);
39175
+ });
38885
39176
  try {
38886
39177
  const result = await cascade.run({
38887
39178
  prompt: runPrompt,
38888
39179
  signal: abortController.signal,
38889
39180
  approvalCallback: this.makeApprovalCallback(sessionId)
38890
39181
  });
38891
- this.recordSessionTask(sessionId, result.taskId);
38892
39182
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
38893
39183
  this.captureWhy(sessionId, cascade, result);
38894
39184
  this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
@@ -38954,6 +39244,49 @@ var DashboardServer = class {
38954
39244
  setCurrentPageProvider(provider) {
38955
39245
  this.currentPageProvider = provider;
38956
39246
  }
39247
+ /** Let runs act on that view, not just read it (desktop only). */
39248
+ setBrowserController(controller, release) {
39249
+ this.browserController = controller;
39250
+ this.browserActorRelease = release;
39251
+ }
39252
+ /**
39253
+ * Told whenever settings are committed through THIS server.
39254
+ *
39255
+ * Settings reach the live config by two routes — the desktop's Electron IPC
39256
+ * handler and this server's `config:update` socket — and both run the same
39257
+ * `commitSettings`. The desktop mirrors `tools.agentBrowserControl` into its
39258
+ * browser module after its own route, but had no way to learn about the
39259
+ * other, so a socket write left the module's copy stale: an enable the module
39260
+ * still refused, or worse a disable the module still honoured for a run
39261
+ * already holding the tool. This is the same sibling-writer drift
39262
+ * `settings-payload.ts` exists to prevent, one layer up.
39263
+ */
39264
+ settingsChangeHook;
39265
+ onSettingsChanged(hook) {
39266
+ this.settingsChangeHook = hook;
39267
+ }
39268
+ /**
39269
+ * Told when a run ends, so a host holding run-scoped state can release it.
39270
+ *
39271
+ * The desktop uses this to retire a run's browser Stop control: that has to
39272
+ * outlive each individual action (the gap between two actions is when the
39273
+ * user most wants it) but must not outlive the run.
39274
+ */
39275
+ /**
39276
+ * Fired when a run ends, carrying BOTH identifiers explicitly.
39277
+ *
39278
+ * They are different things and conflating them was a live bug: a chat
39279
+ * `sessionId` can contain many runs (hence `sessionTaskIds`), while
39280
+ * `Cascade.run()` mints a fresh random `taskId` per run — and it is the
39281
+ * taskId that tools see as `ToolExecuteOptions.sessionId`. Passing only the
39282
+ * chat id meant browser cleanup keyed on the task id matched nothing, so a
39283
+ * finished run kept its Stop banner armed and held its browser lease until
39284
+ * the deadlock ceiling.
39285
+ */
39286
+ runEndedHook;
39287
+ onRunEnded(hook) {
39288
+ this.runEndedHook = hook;
39289
+ }
38957
39290
  async start() {
38958
39291
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
38959
39292
  if (!isLoopback) {
@@ -39144,6 +39477,8 @@ var DashboardServer = class {
39144
39477
  }
39145
39478
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
39146
39479
  persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
39480
+ const endedTaskId = result?.taskId ?? this.sessionTaskIds.get(sessionId)?.at(-1);
39481
+ this.runEndedHook?.({ sessionId, ...endedTaskId ? { taskId: endedTaskId } : {} });
39147
39482
  try {
39148
39483
  if (reply && reply.trim()) {
39149
39484
  this.store.addMessage({ id: randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
@@ -39237,6 +39572,7 @@ var DashboardServer = class {
39237
39572
  const prompt = task.prompt;
39238
39573
  const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
39239
39574
  const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
39575
+ cascade.setUnattended(true);
39240
39576
  this.activeSessions.set(sessionId, cascade);
39241
39577
  cascade.on("tier:status", (e) => {
39242
39578
  this.socket.broadcast("tier:status", { sessionId, ...e });
@@ -39244,13 +39580,16 @@ var DashboardServer = class {
39244
39580
  cascade.on("peer:message", (e) => {
39245
39581
  this.socket.emitPeerMessage(e);
39246
39582
  });
39583
+ cascade.on("run:started", (e) => {
39584
+ const id = e.taskId;
39585
+ if (id) this.recordSessionTask(sessionId, id);
39586
+ });
39247
39587
  try {
39248
39588
  const result = await cascade.run({
39249
39589
  prompt,
39250
39590
  identityId: task.identityId,
39251
39591
  approvalCallback: async () => ({ approved: true, always: false })
39252
39592
  });
39253
- this.recordSessionTask(sessionId, result.taskId);
39254
39593
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
39255
39594
  this.captureWhy(sessionId, cascade, result);
39256
39595
  this.socket.broadcast("session:complete", { sessionId, result });
@@ -39883,6 +40222,7 @@ ${prompt}`;
39883
40222
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
39884
40223
  this.activeSessions.set(sessionId, cascade);
39885
40224
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
40225
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
39886
40226
  cascade.on("stream:token", (e) => {
39887
40227
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
39888
40228
  });
@@ -39906,13 +40246,16 @@ ${prompt}`;
39906
40246
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
39907
40247
  this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
39908
40248
  });
40249
+ cascade.on("run:started", (e) => {
40250
+ const id = e.taskId;
40251
+ if (id) this.recordSessionTask(sessionId, id);
40252
+ });
39909
40253
  try {
39910
40254
  const result = await cascade.run({
39911
40255
  prompt: runPrompt,
39912
40256
  identityId: body.identityId,
39913
40257
  approvalCallback: this.makeApprovalCallback(sessionId)
39914
40258
  });
39915
- this.recordSessionTask(sessionId, result.taskId);
39916
40259
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
39917
40260
  this.captureWhy(sessionId, cascade, result);
39918
40261
  this.socket.broadcast("cost:update", {