cascade-ai 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -11669,7 +11669,7 @@ function reconcilePrice(model, live) {
11669
11669
  }
11670
11670
 
11671
11671
  // src/constants.ts
11672
- var CASCADE_VERSION = "0.77.0";
11672
+ var CASCADE_VERSION = "0.78.0";
11673
11673
  var CASCADE_CONFIG_DIR = ".cascade";
11674
11674
  var CASCADE_MD_FILE = "CASCADE.md";
11675
11675
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -16130,6 +16130,20 @@ var ToolsConfigSchema = zod.z.object({
16130
16130
  shellBlocklist: zod.z.array(zod.z.string()).default(["rm -rf", "sudo rm", "format", "mkfs"]),
16131
16131
  requireApprovalFor: zod.z.array(zod.z.string()).default([]),
16132
16132
  browserEnabled: zod.z.boolean().default(false),
16133
+ /**
16134
+ * Let a run ACT on the page open in the desktop's built-in browser — click,
16135
+ * type, submit — not just read it.
16136
+ *
16137
+ * Distinct from `browserEnabled`, which turns on the headless Playwright
16138
+ * tool. That one drives a throwaway browser with an empty cookie jar; this
16139
+ * one drives the session the user is signed into, which is a different order
16140
+ * of consequence and so gets its own switch rather than riding on that one.
16141
+ *
16142
+ * Off by default. Turning it on does not hand anything over silently: the
16143
+ * tool still goes through the approval chain, and the browser panel carries a
16144
+ * stop control for the run that is acting.
16145
+ */
16146
+ agentBrowserControl: zod.z.boolean().default(false),
16133
16147
  mcpServers: zod.z.array(McpServerConfigSchema).optional(),
16134
16148
  mcpTrusted: zod.z.array(zod.z.string()).optional(),
16135
16149
  /** Web search backends — at least one should be configured for best results */
@@ -20914,6 +20928,23 @@ Now execute your subtask using this context where relevant.`
20914
20928
  this.setStatus("FAILED", finalOutput);
20915
20929
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput, "FAILED");
20916
20930
  return this.buildResult("ESCALATED", finalOutput, { checksRun, passed, failed }, issues, correctionAttempts);
20931
+ } finally {
20932
+ this.releaseBrowserControl();
20933
+ }
20934
+ }
20935
+ /**
20936
+ * End this worker's hold on the shared browser.
20937
+ *
20938
+ * Duck-typed rather than imported: `browser_control` is registered only by a
20939
+ * host that HAS a browser (the desktop), so in the CLI and hosted runs there
20940
+ * is no such tool and this is a no-op. Importing the class to instanceof-check
20941
+ * it would couple the worker to a tool that usually is not there.
20942
+ */
20943
+ releaseBrowserControl() {
20944
+ try {
20945
+ const tool = this.toolRegistry.getTool?.("browser_control");
20946
+ tool?.releaseActor?.(this.id);
20947
+ } catch {
20917
20948
  }
20918
20949
  }
20919
20950
  sendToPeer(toId, content) {
@@ -30730,6 +30761,127 @@ var CurrentPageTool = class extends BaseTool {
30730
30761
  }
30731
30762
  };
30732
30763
 
30764
+ // src/tools/browser-control.ts
30765
+ var BrowserControlTool = class extends BaseTool {
30766
+ name = "browser_control";
30767
+ 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.";
30768
+ inputSchema = {
30769
+ type: "object",
30770
+ properties: {
30771
+ action: {
30772
+ type: "string",
30773
+ enum: ["navigate", "click", "fill", "press", "wait_for", "extract_text"],
30774
+ description: "What to do."
30775
+ },
30776
+ url: { type: "string", description: "For navigate: the http(s) address to open." },
30777
+ selector: {
30778
+ type: "string",
30779
+ description: "CSS selector for click, fill and wait_for. Optional for extract_text (defaults to the whole page)."
30780
+ },
30781
+ value: { type: "string", description: "For fill: the text to type." },
30782
+ key: { type: "string", description: 'For press: a key name such as "Enter", "Tab" or "Escape".' },
30783
+ timeoutMs: { type: "number", description: "For wait_for: how long to wait before giving up (default 10000, max 30000)." }
30784
+ },
30785
+ required: ["action"]
30786
+ };
30787
+ controller;
30788
+ release;
30789
+ /** One-way. See `revoke`. */
30790
+ revoked = false;
30791
+ constructor(controller, release) {
30792
+ super();
30793
+ this.controller = controller;
30794
+ this.release = release;
30795
+ }
30796
+ /**
30797
+ * A worker has finished. Ends its hold on the browser.
30798
+ *
30799
+ * Called from the tier's terminal path rather than by the model, because a
30800
+ * model cannot be relied on to announce that it is done — which is why the
30801
+ * host cannot use idle time as the boundary instead.
30802
+ */
30803
+ releaseActor(actorId) {
30804
+ this.release?.(actorId);
30805
+ }
30806
+ /**
30807
+ * Permanently disable this tool, whatever the registry still holds.
30808
+ *
30809
+ * Registration-time gates cannot express "this run turned out to be
30810
+ * unattended": a host that wires the controller and only then declares the
30811
+ * run unattended has already registered the tool, and a check that ran at
30812
+ * registration has no second chance. Enforcing at EXECUTION time makes the
30813
+ * guarantee independent of call order, which is the whole point of stating
30814
+ * the invariant rather than relying on the scheduler's current sequence.
30815
+ *
30816
+ * One-way on purpose. A safety gate that can be re-opened by a later call is
30817
+ * a gate that a refactor re-opens by accident.
30818
+ */
30819
+ revoke() {
30820
+ this.revoked = true;
30821
+ }
30822
+ // Acting on a session the user is signed into is the highest-consequence
30823
+ // thing in the tool set — higher than shell, arguably, because the blast
30824
+ // radius is someone else's server rather than this machine.
30825
+ isDangerous() {
30826
+ return true;
30827
+ }
30828
+ async execute(input, options) {
30829
+ if (this.revoked) {
30830
+ return "Error: browser control is not available in this run. It requires a person watching who can stop it, and nobody is.";
30831
+ }
30832
+ const kind = input["action"];
30833
+ if (!kind) return "Error: action is required.";
30834
+ if (options?.signal?.aborted) return "Error: the run was cancelled before this action ran.";
30835
+ const missing = requiredFieldFor(kind, input);
30836
+ if (missing) return `Error: "${kind}" needs ${missing}.`;
30837
+ const action = {
30838
+ kind,
30839
+ ...typeof input["url"] === "string" ? { url: input["url"] } : {},
30840
+ ...typeof input["selector"] === "string" ? { selector: input["selector"] } : {},
30841
+ ...typeof input["value"] === "string" ? { value: input["value"] } : {},
30842
+ ...typeof input["key"] === "string" ? { key: input["key"] } : {},
30843
+ ...typeof input["timeoutMs"] === "number" ? { timeoutMs: Math.min(Math.max(input["timeoutMs"], 0), 3e4) } : {}
30844
+ };
30845
+ let outcome;
30846
+ try {
30847
+ outcome = await this.controller(action, {
30848
+ // `sessionId` is the run. The host scopes both revocation and its
30849
+ // single-owner lease on it, so passing it is not telemetry — drop it
30850
+ // and a Stop in one run silently stops every later one.
30851
+ sessionId: options?.sessionId ?? "",
30852
+ actorId: options?.tierId ?? options?.sessionId ?? "",
30853
+ ...options?.signal ? { signal: options.signal } : {}
30854
+ });
30855
+ } catch (err) {
30856
+ return `Error: the browser could not perform "${kind}" \u2014 ${err instanceof Error ? err.message : String(err)}`;
30857
+ }
30858
+ const where = outcome.url ? `
30859
+ Page: ${outcome.title ? `${outcome.title} \u2014 ` : ""}${outcome.url}` : "";
30860
+ return `${outcome.ok ? "" : "Failed: "}${outcome.detail}${where}`;
30861
+ }
30862
+ };
30863
+ function requiredFieldFor(kind, input) {
30864
+ const has = (k) => typeof input[k] === "string" && input[k].length > 0;
30865
+ const given = (k) => typeof input[k] === "string";
30866
+ switch (kind) {
30867
+ case "navigate":
30868
+ return has("url") ? null : "a url";
30869
+ case "click":
30870
+ return has("selector") ? null : "a selector";
30871
+ case "fill":
30872
+ return has("selector") ? given("value") ? null : "a value" : "a selector";
30873
+ case "press":
30874
+ return has("key") ? null : "a key";
30875
+ case "wait_for":
30876
+ return has("selector") ? null : "a selector";
30877
+ // extract_text works with or without a selector.
30878
+ case "extract_text":
30879
+ return null;
30880
+ default:
30881
+ return null;
30882
+ }
30883
+ }
30884
+
30733
30885
  // src/core/cascade.ts
30734
30886
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
30735
30887
  function buildContextualPrompt(prompt, history = []) {
@@ -31222,6 +31374,49 @@ ${input}` }],
31222
31374
  if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
31223
31375
  this.toolRegistry.register(new CurrentPageTool(provider));
31224
31376
  }
31377
+ /**
31378
+ * True when nobody is watching this run — a scheduled/cron task, which
31379
+ * auto-approves every tool because there is no one to ask.
31380
+ *
31381
+ * That auto-approval is fine for ordinary tools and disqualifying for this
31382
+ * one: `browser_control` drives the signed-in browser, and "every run asks
31383
+ * first" is the promise the Settings copy makes. The property used to hold
31384
+ * only because `runScheduledTask` happens not to call `setBrowserController`
31385
+ * — a guarantee resting on an ABSENT call, which is exactly what a later
31386
+ * refactor adds without noticing. Stated and enforced instead.
31387
+ */
31388
+ unattended = false;
31389
+ /**
31390
+ * Declare that no human is watching this run, so capabilities that depend on
31391
+ * a person being able to see and stop them are refused outright.
31392
+ */
31393
+ setUnattended(on) {
31394
+ this.unattended = on;
31395
+ if (!on) return;
31396
+ const tool = this.toolRegistry.getTool("browser_control");
31397
+ tool?.revoke?.();
31398
+ }
31399
+ /**
31400
+ * Wire the host's browser so a run can ACT on the open page, not just read it.
31401
+ *
31402
+ * Two gates before this registers anything, and both matter:
31403
+ *
31404
+ * - `tools.agentBrowserControl` must be on. Unlike read_current_page, which
31405
+ * reaches nothing the user has not already opened themselves, this drives
31406
+ * the session they are signed into. That is opt-in, not a default.
31407
+ * - `disabledTools` still removes it, the same as every other tool.
31408
+ * - The run must not be UNATTENDED. See `setUnattended`.
31409
+ *
31410
+ * Host-supplied like the page provider: the tool exists only where there is a
31411
+ * real browser to drive, so it never has to be refused in the CLI or a hosted
31412
+ * run — it is simply not there.
31413
+ */
31414
+ setBrowserController(controller, release) {
31415
+ if (this.unattended) return;
31416
+ if (!this.config.tools?.agentBrowserControl) return;
31417
+ if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31418
+ this.toolRegistry.register(new BrowserControlTool(controller, release));
31419
+ }
31225
31420
  registerMediaTools(workspacePath) {
31226
31421
  const denied = new Set(this.config.tools?.disabledTools ?? []);
31227
31422
  const permitted = (name) => !denied.has(name);
@@ -31827,6 +32022,7 @@ ${prompt}` : prompt;
31827
32022
  this.router.setRunSignal(options.signal);
31828
32023
  const startMs = Date.now();
31829
32024
  const taskId = crypto.randomUUID();
32025
+ this.emit("run:started", { taskId });
31830
32026
  this.decisionLog = [];
31831
32027
  this.currentRunCompleted = [];
31832
32028
  this.wroteCheckpointThisRun = false;
@@ -32592,6 +32788,7 @@ function applySettingsPayload(config, data) {
32592
32788
  if (["auto", "T1", "T2", "T3"].includes(a["forceTier"])) config.routing = { ...config.routing ?? {}, forceTier: a["forceTier"] };
32593
32789
  if (typeof a["benchmarksLive"] === "boolean") config.benchmarks = { ...config.benchmarks ?? {}, live: a["benchmarksLive"] };
32594
32790
  if (["isolate", "worker", "auto"].includes(a["dynamicToolSandbox"])) config.tools = { ...config.tools ?? {}, dynamicToolSandbox: a["dynamicToolSandbox"] };
32791
+ if (typeof a["agentBrowserControl"] === "boolean") config.tools = { ...config.tools ?? {}, agentBrowserControl: a["agentBrowserControl"] };
32595
32792
  if (typeof a["factsExtraction"] === "boolean") config.knowledge = { ...config.knowledge ?? {}, factsExtraction: a["factsExtraction"] };
32596
32793
  if (typeof a["rememberSessions"] === "boolean") config.memory = { ...config.memory ?? {}, rememberSessions: a["rememberSessions"] };
32597
32794
  if (typeof a["enableToolCreation"] === "boolean") config.enableToolCreation = a["enableToolCreation"];
@@ -32649,6 +32846,7 @@ function settingsSnapshot(config) {
32649
32846
  forceTier: config.routing?.forceTier,
32650
32847
  benchmarksLive: config.benchmarks?.live,
32651
32848
  dynamicToolSandbox: config.tools?.dynamicToolSandbox,
32849
+ agentBrowserControl: config.tools?.agentBrowserControl,
32652
32850
  factsExtraction: config.knowledge?.factsExtraction,
32653
32851
  rememberSessions: config.memory?.rememberSessions,
32654
32852
  enableToolCreation: config.enableToolCreation,
@@ -33153,6 +33351,14 @@ var DashboardServer = class {
33153
33351
  * always failing.
33154
33352
  */
33155
33353
  currentPageProvider;
33354
+ /**
33355
+ * Supplied by the desktop shell so a run can ACT on that page — click, type,
33356
+ * submit — not just read it. Same reasoning as the provider above: absent in
33357
+ * the CLI, so the tool is not registered rather than present and failing.
33358
+ * Cascade applies the `tools.agentBrowserControl` gate on top of this.
33359
+ */
33360
+ browserController;
33361
+ browserActorRelease;
33156
33362
  activeControllers = /* @__PURE__ */ new Map();
33157
33363
  /**
33158
33364
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -33214,6 +33420,7 @@ var DashboardServer = class {
33214
33420
  });
33215
33421
  this.socket.onConfigUpdate(async (data) => {
33216
33422
  const result = await commitSettings(this.config, data, (committed) => this.persistConfig(committed));
33423
+ if (result.ok) this.settingsChangeHook?.(this.config);
33217
33424
  const refused = result.refused.map((r) => ({
33218
33425
  type: r.type,
33219
33426
  reason: r.reason,
@@ -33237,6 +33444,7 @@ var DashboardServer = class {
33237
33444
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
33238
33445
  this.activeSessions.set(sessionId, cascade);
33239
33446
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
33447
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
33240
33448
  cascade.on("stream:token", (e) => {
33241
33449
  this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
33242
33450
  });
@@ -33260,13 +33468,16 @@ var DashboardServer = class {
33260
33468
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
33261
33469
  this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
33262
33470
  });
33471
+ cascade.on("run:started", (e) => {
33472
+ const id = e.taskId;
33473
+ if (id) this.recordSessionTask(sessionId, id);
33474
+ });
33263
33475
  try {
33264
33476
  const result = await cascade.run({
33265
33477
  prompt: runPrompt,
33266
33478
  signal: abortController.signal,
33267
33479
  approvalCallback: this.makeApprovalCallback(sessionId)
33268
33480
  });
33269
- this.recordSessionTask(sessionId, result.taskId);
33270
33481
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
33271
33482
  this.captureWhy(sessionId, cascade, result);
33272
33483
  this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
@@ -33332,6 +33543,49 @@ var DashboardServer = class {
33332
33543
  setCurrentPageProvider(provider) {
33333
33544
  this.currentPageProvider = provider;
33334
33545
  }
33546
+ /** Let runs act on that view, not just read it (desktop only). */
33547
+ setBrowserController(controller, release) {
33548
+ this.browserController = controller;
33549
+ this.browserActorRelease = release;
33550
+ }
33551
+ /**
33552
+ * Told whenever settings are committed through THIS server.
33553
+ *
33554
+ * Settings reach the live config by two routes — the desktop's Electron IPC
33555
+ * handler and this server's `config:update` socket — and both run the same
33556
+ * `commitSettings`. The desktop mirrors `tools.agentBrowserControl` into its
33557
+ * browser module after its own route, but had no way to learn about the
33558
+ * other, so a socket write left the module's copy stale: an enable the module
33559
+ * still refused, or worse a disable the module still honoured for a run
33560
+ * already holding the tool. This is the same sibling-writer drift
33561
+ * `settings-payload.ts` exists to prevent, one layer up.
33562
+ */
33563
+ settingsChangeHook;
33564
+ onSettingsChanged(hook) {
33565
+ this.settingsChangeHook = hook;
33566
+ }
33567
+ /**
33568
+ * Told when a run ends, so a host holding run-scoped state can release it.
33569
+ *
33570
+ * The desktop uses this to retire a run's browser Stop control: that has to
33571
+ * outlive each individual action (the gap between two actions is when the
33572
+ * user most wants it) but must not outlive the run.
33573
+ */
33574
+ /**
33575
+ * Fired when a run ends, carrying BOTH identifiers explicitly.
33576
+ *
33577
+ * They are different things and conflating them was a live bug: a chat
33578
+ * `sessionId` can contain many runs (hence `sessionTaskIds`), while
33579
+ * `Cascade.run()` mints a fresh random `taskId` per run — and it is the
33580
+ * taskId that tools see as `ToolExecuteOptions.sessionId`. Passing only the
33581
+ * chat id meant browser cleanup keyed on the task id matched nothing, so a
33582
+ * finished run kept its Stop banner armed and held its browser lease until
33583
+ * the deadlock ceiling.
33584
+ */
33585
+ runEndedHook;
33586
+ onRunEnded(hook) {
33587
+ this.runEndedHook = hook;
33588
+ }
33335
33589
  async start() {
33336
33590
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
33337
33591
  if (!isLoopback) {
@@ -33522,6 +33776,8 @@ var DashboardServer = class {
33522
33776
  }
33523
33777
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
33524
33778
  persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
33779
+ const endedTaskId = result?.taskId ?? this.sessionTaskIds.get(sessionId)?.at(-1);
33780
+ this.runEndedHook?.({ sessionId, ...endedTaskId ? { taskId: endedTaskId } : {} });
33525
33781
  try {
33526
33782
  if (reply && reply.trim()) {
33527
33783
  this.store.addMessage({ id: crypto.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
@@ -33615,6 +33871,7 @@ var DashboardServer = class {
33615
33871
  const prompt = task.prompt;
33616
33872
  const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
33617
33873
  const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
33874
+ cascade.setUnattended(true);
33618
33875
  this.activeSessions.set(sessionId, cascade);
33619
33876
  cascade.on("tier:status", (e) => {
33620
33877
  this.socket.broadcast("tier:status", { sessionId, ...e });
@@ -33622,13 +33879,16 @@ var DashboardServer = class {
33622
33879
  cascade.on("peer:message", (e) => {
33623
33880
  this.socket.emitPeerMessage(e);
33624
33881
  });
33882
+ cascade.on("run:started", (e) => {
33883
+ const id = e.taskId;
33884
+ if (id) this.recordSessionTask(sessionId, id);
33885
+ });
33625
33886
  try {
33626
33887
  const result = await cascade.run({
33627
33888
  prompt,
33628
33889
  identityId: task.identityId,
33629
33890
  approvalCallback: async () => ({ approved: true, always: false })
33630
33891
  });
33631
- this.recordSessionTask(sessionId, result.taskId);
33632
33892
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
33633
33893
  this.captureWhy(sessionId, cascade, result);
33634
33894
  this.socket.broadcast("session:complete", { sessionId, result });
@@ -34261,6 +34521,7 @@ ${prompt}`;
34261
34521
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
34262
34522
  this.activeSessions.set(sessionId, cascade);
34263
34523
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
34524
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
34264
34525
  cascade.on("stream:token", (e) => {
34265
34526
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
34266
34527
  });
@@ -34284,13 +34545,16 @@ ${prompt}`;
34284
34545
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
34285
34546
  this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
34286
34547
  });
34548
+ cascade.on("run:started", (e) => {
34549
+ const id = e.taskId;
34550
+ if (id) this.recordSessionTask(sessionId, id);
34551
+ });
34287
34552
  try {
34288
34553
  const result = await cascade.run({
34289
34554
  prompt: runPrompt,
34290
34555
  identityId: body.identityId,
34291
34556
  approvalCallback: this.makeApprovalCallback(sessionId)
34292
34557
  });
34293
- this.recordSessionTask(sessionId, result.taskId);
34294
34558
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
34295
34559
  this.captureWhy(sessionId, cascade, result);
34296
34560
  this.socket.broadcast("cost:update", {
@@ -35333,6 +35597,7 @@ function applySyncBundle(bundle, config, cleanup) {
35333
35597
  exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
35334
35598
  exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
35335
35599
  exports.AuditLogger = AuditLogger;
35600
+ exports.BrowserControlTool = BrowserControlTool;
35336
35601
  exports.CASCADE_AUDIT_FILE = CASCADE_AUDIT_FILE;
35337
35602
  exports.CASCADE_CONFIG_DIR = CASCADE_CONFIG_DIR;
35338
35603
  exports.CASCADE_CONFIG_FILE = CASCADE_CONFIG_FILE;