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/cli.cjs CHANGED
@@ -1849,7 +1849,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1849
1849
  var init_constants = __esm({
1850
1850
  "src/constants.ts"() {
1851
1851
  init_pricing();
1852
- CASCADE_VERSION = "0.77.0";
1852
+ CASCADE_VERSION = "0.78.0";
1853
1853
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1854
1854
  CASCADE_DB_FILE = ".cascade/memory.db";
1855
1855
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -15540,6 +15540,20 @@ var ToolsConfigSchema = zod.z.object({
15540
15540
  shellBlocklist: zod.z.array(zod.z.string()).default(["rm -rf", "sudo rm", "format", "mkfs"]),
15541
15541
  requireApprovalFor: zod.z.array(zod.z.string()).default([]),
15542
15542
  browserEnabled: zod.z.boolean().default(false),
15543
+ /**
15544
+ * Let a run ACT on the page open in the desktop's built-in browser — click,
15545
+ * type, submit — not just read it.
15546
+ *
15547
+ * Distinct from `browserEnabled`, which turns on the headless Playwright
15548
+ * tool. That one drives a throwaway browser with an empty cookie jar; this
15549
+ * one drives the session the user is signed into, which is a different order
15550
+ * of consequence and so gets its own switch rather than riding on that one.
15551
+ *
15552
+ * Off by default. Turning it on does not hand anything over silently: the
15553
+ * tool still goes through the approval chain, and the browser panel carries a
15554
+ * stop control for the run that is acting.
15555
+ */
15556
+ agentBrowserControl: zod.z.boolean().default(false),
15543
15557
  mcpServers: zod.z.array(McpServerConfigSchema).optional(),
15544
15558
  mcpTrusted: zod.z.array(zod.z.string()).optional(),
15545
15559
  /** Web search backends — at least one should be configured for best results */
@@ -21464,6 +21478,23 @@ Now execute your subtask using this context where relevant.`
21464
21478
  this.setStatus("FAILED", finalOutput);
21465
21479
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput, "FAILED");
21466
21480
  return this.buildResult("ESCALATED", finalOutput, { checksRun, passed, failed }, issues, correctionAttempts);
21481
+ } finally {
21482
+ this.releaseBrowserControl();
21483
+ }
21484
+ }
21485
+ /**
21486
+ * End this worker's hold on the shared browser.
21487
+ *
21488
+ * Duck-typed rather than imported: `browser_control` is registered only by a
21489
+ * host that HAS a browser (the desktop), so in the CLI and hosted runs there
21490
+ * is no such tool and this is a no-op. Importing the class to instanceof-check
21491
+ * it would couple the worker to a tool that usually is not there.
21492
+ */
21493
+ releaseBrowserControl() {
21494
+ try {
21495
+ const tool = this.toolRegistry.getTool?.("browser_control");
21496
+ tool?.releaseActor?.(this.id);
21497
+ } catch {
21467
21498
  }
21468
21499
  }
21469
21500
  sendToPeer(toId, content) {
@@ -31267,6 +31298,127 @@ var CurrentPageTool = class extends BaseTool {
31267
31298
  }
31268
31299
  };
31269
31300
 
31301
+ // src/tools/browser-control.ts
31302
+ var BrowserControlTool = class extends BaseTool {
31303
+ name = "browser_control";
31304
+ 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.";
31305
+ inputSchema = {
31306
+ type: "object",
31307
+ properties: {
31308
+ action: {
31309
+ type: "string",
31310
+ enum: ["navigate", "click", "fill", "press", "wait_for", "extract_text"],
31311
+ description: "What to do."
31312
+ },
31313
+ url: { type: "string", description: "For navigate: the http(s) address to open." },
31314
+ selector: {
31315
+ type: "string",
31316
+ description: "CSS selector for click, fill and wait_for. Optional for extract_text (defaults to the whole page)."
31317
+ },
31318
+ value: { type: "string", description: "For fill: the text to type." },
31319
+ key: { type: "string", description: 'For press: a key name such as "Enter", "Tab" or "Escape".' },
31320
+ timeoutMs: { type: "number", description: "For wait_for: how long to wait before giving up (default 10000, max 30000)." }
31321
+ },
31322
+ required: ["action"]
31323
+ };
31324
+ controller;
31325
+ release;
31326
+ /** One-way. See `revoke`. */
31327
+ revoked = false;
31328
+ constructor(controller, release) {
31329
+ super();
31330
+ this.controller = controller;
31331
+ this.release = release;
31332
+ }
31333
+ /**
31334
+ * A worker has finished. Ends its hold on the browser.
31335
+ *
31336
+ * Called from the tier's terminal path rather than by the model, because a
31337
+ * model cannot be relied on to announce that it is done — which is why the
31338
+ * host cannot use idle time as the boundary instead.
31339
+ */
31340
+ releaseActor(actorId) {
31341
+ this.release?.(actorId);
31342
+ }
31343
+ /**
31344
+ * Permanently disable this tool, whatever the registry still holds.
31345
+ *
31346
+ * Registration-time gates cannot express "this run turned out to be
31347
+ * unattended": a host that wires the controller and only then declares the
31348
+ * run unattended has already registered the tool, and a check that ran at
31349
+ * registration has no second chance. Enforcing at EXECUTION time makes the
31350
+ * guarantee independent of call order, which is the whole point of stating
31351
+ * the invariant rather than relying on the scheduler's current sequence.
31352
+ *
31353
+ * One-way on purpose. A safety gate that can be re-opened by a later call is
31354
+ * a gate that a refactor re-opens by accident.
31355
+ */
31356
+ revoke() {
31357
+ this.revoked = true;
31358
+ }
31359
+ // Acting on a session the user is signed into is the highest-consequence
31360
+ // thing in the tool set — higher than shell, arguably, because the blast
31361
+ // radius is someone else's server rather than this machine.
31362
+ isDangerous() {
31363
+ return true;
31364
+ }
31365
+ async execute(input, options) {
31366
+ if (this.revoked) {
31367
+ return "Error: browser control is not available in this run. It requires a person watching who can stop it, and nobody is.";
31368
+ }
31369
+ const kind = input["action"];
31370
+ if (!kind) return "Error: action is required.";
31371
+ if (options?.signal?.aborted) return "Error: the run was cancelled before this action ran.";
31372
+ const missing = requiredFieldFor(kind, input);
31373
+ if (missing) return `Error: "${kind}" needs ${missing}.`;
31374
+ const action = {
31375
+ kind,
31376
+ ...typeof input["url"] === "string" ? { url: input["url"] } : {},
31377
+ ...typeof input["selector"] === "string" ? { selector: input["selector"] } : {},
31378
+ ...typeof input["value"] === "string" ? { value: input["value"] } : {},
31379
+ ...typeof input["key"] === "string" ? { key: input["key"] } : {},
31380
+ ...typeof input["timeoutMs"] === "number" ? { timeoutMs: Math.min(Math.max(input["timeoutMs"], 0), 3e4) } : {}
31381
+ };
31382
+ let outcome;
31383
+ try {
31384
+ outcome = await this.controller(action, {
31385
+ // `sessionId` is the run. The host scopes both revocation and its
31386
+ // single-owner lease on it, so passing it is not telemetry — drop it
31387
+ // and a Stop in one run silently stops every later one.
31388
+ sessionId: options?.sessionId ?? "",
31389
+ actorId: options?.tierId ?? options?.sessionId ?? "",
31390
+ ...options?.signal ? { signal: options.signal } : {}
31391
+ });
31392
+ } catch (err) {
31393
+ return `Error: the browser could not perform "${kind}" \u2014 ${err instanceof Error ? err.message : String(err)}`;
31394
+ }
31395
+ const where = outcome.url ? `
31396
+ Page: ${outcome.title ? `${outcome.title} \u2014 ` : ""}${outcome.url}` : "";
31397
+ return `${outcome.ok ? "" : "Failed: "}${outcome.detail}${where}`;
31398
+ }
31399
+ };
31400
+ function requiredFieldFor(kind, input) {
31401
+ const has = (k) => typeof input[k] === "string" && input[k].length > 0;
31402
+ const given = (k) => typeof input[k] === "string";
31403
+ switch (kind) {
31404
+ case "navigate":
31405
+ return has("url") ? null : "a url";
31406
+ case "click":
31407
+ return has("selector") ? null : "a selector";
31408
+ case "fill":
31409
+ return has("selector") ? given("value") ? null : "a value" : "a selector";
31410
+ case "press":
31411
+ return has("key") ? null : "a key";
31412
+ case "wait_for":
31413
+ return has("selector") ? null : "a selector";
31414
+ // extract_text works with or without a selector.
31415
+ case "extract_text":
31416
+ return null;
31417
+ default:
31418
+ return null;
31419
+ }
31420
+ }
31421
+
31270
31422
  // src/core/cascade.ts
31271
31423
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
31272
31424
  function buildContextualPrompt(prompt, history = []) {
@@ -31759,6 +31911,49 @@ ${input}` }],
31759
31911
  if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
31760
31912
  this.toolRegistry.register(new CurrentPageTool(provider));
31761
31913
  }
31914
+ /**
31915
+ * True when nobody is watching this run — a scheduled/cron task, which
31916
+ * auto-approves every tool because there is no one to ask.
31917
+ *
31918
+ * That auto-approval is fine for ordinary tools and disqualifying for this
31919
+ * one: `browser_control` drives the signed-in browser, and "every run asks
31920
+ * first" is the promise the Settings copy makes. The property used to hold
31921
+ * only because `runScheduledTask` happens not to call `setBrowserController`
31922
+ * — a guarantee resting on an ABSENT call, which is exactly what a later
31923
+ * refactor adds without noticing. Stated and enforced instead.
31924
+ */
31925
+ unattended = false;
31926
+ /**
31927
+ * Declare that no human is watching this run, so capabilities that depend on
31928
+ * a person being able to see and stop them are refused outright.
31929
+ */
31930
+ setUnattended(on) {
31931
+ this.unattended = on;
31932
+ if (!on) return;
31933
+ const tool = this.toolRegistry.getTool("browser_control");
31934
+ tool?.revoke?.();
31935
+ }
31936
+ /**
31937
+ * Wire the host's browser so a run can ACT on the open page, not just read it.
31938
+ *
31939
+ * Two gates before this registers anything, and both matter:
31940
+ *
31941
+ * - `tools.agentBrowserControl` must be on. Unlike read_current_page, which
31942
+ * reaches nothing the user has not already opened themselves, this drives
31943
+ * the session they are signed into. That is opt-in, not a default.
31944
+ * - `disabledTools` still removes it, the same as every other tool.
31945
+ * - The run must not be UNATTENDED. See `setUnattended`.
31946
+ *
31947
+ * Host-supplied like the page provider: the tool exists only where there is a
31948
+ * real browser to drive, so it never has to be refused in the CLI or a hosted
31949
+ * run — it is simply not there.
31950
+ */
31951
+ setBrowserController(controller, release) {
31952
+ if (this.unattended) return;
31953
+ if (!this.config.tools?.agentBrowserControl) return;
31954
+ if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31955
+ this.toolRegistry.register(new BrowserControlTool(controller, release));
31956
+ }
31762
31957
  registerMediaTools(workspacePath) {
31763
31958
  const denied = new Set(this.config.tools?.disabledTools ?? []);
31764
31959
  const permitted = (name) => !denied.has(name);
@@ -32364,6 +32559,7 @@ ${prompt}` : prompt;
32364
32559
  this.router.setRunSignal(options.signal);
32365
32560
  const startMs = Date.now();
32366
32561
  const taskId = crypto.randomUUID();
32562
+ this.emit("run:started", { taskId });
32367
32563
  this.decisionLog = [];
32368
32564
  this.currentRunCompleted = [];
32369
32565
  this.wroteCheckpointThisRun = false;
@@ -38261,6 +38457,7 @@ function applySettingsPayload(config, data) {
38261
38457
  if (["auto", "T1", "T2", "T3"].includes(a["forceTier"])) config.routing = { ...config.routing ?? {}, forceTier: a["forceTier"] };
38262
38458
  if (typeof a["benchmarksLive"] === "boolean") config.benchmarks = { ...config.benchmarks ?? {}, live: a["benchmarksLive"] };
38263
38459
  if (["isolate", "worker", "auto"].includes(a["dynamicToolSandbox"])) config.tools = { ...config.tools ?? {}, dynamicToolSandbox: a["dynamicToolSandbox"] };
38460
+ if (typeof a["agentBrowserControl"] === "boolean") config.tools = { ...config.tools ?? {}, agentBrowserControl: a["agentBrowserControl"] };
38264
38461
  if (typeof a["factsExtraction"] === "boolean") config.knowledge = { ...config.knowledge ?? {}, factsExtraction: a["factsExtraction"] };
38265
38462
  if (typeof a["rememberSessions"] === "boolean") config.memory = { ...config.memory ?? {}, rememberSessions: a["rememberSessions"] };
38266
38463
  if (typeof a["enableToolCreation"] === "boolean") config.enableToolCreation = a["enableToolCreation"];
@@ -38318,6 +38515,7 @@ function settingsSnapshot(config) {
38318
38515
  forceTier: config.routing?.forceTier,
38319
38516
  benchmarksLive: config.benchmarks?.live,
38320
38517
  dynamicToolSandbox: config.tools?.dynamicToolSandbox,
38518
+ agentBrowserControl: config.tools?.agentBrowserControl,
38321
38519
  factsExtraction: config.knowledge?.factsExtraction,
38322
38520
  rememberSessions: config.memory?.rememberSessions,
38323
38521
  enableToolCreation: config.enableToolCreation,
@@ -38828,6 +39026,14 @@ var DashboardServer = class {
38828
39026
  * always failing.
38829
39027
  */
38830
39028
  currentPageProvider;
39029
+ /**
39030
+ * Supplied by the desktop shell so a run can ACT on that page — click, type,
39031
+ * submit — not just read it. Same reasoning as the provider above: absent in
39032
+ * the CLI, so the tool is not registered rather than present and failing.
39033
+ * Cascade applies the `tools.agentBrowserControl` gate on top of this.
39034
+ */
39035
+ browserController;
39036
+ browserActorRelease;
38831
39037
  activeControllers = /* @__PURE__ */ new Map();
38832
39038
  /**
38833
39039
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -38889,6 +39095,7 @@ var DashboardServer = class {
38889
39095
  });
38890
39096
  this.socket.onConfigUpdate(async (data) => {
38891
39097
  const result = await commitSettings(this.config, data, (committed) => this.persistConfig(committed));
39098
+ if (result.ok) this.settingsChangeHook?.(this.config);
38892
39099
  const refused = result.refused.map((r) => ({
38893
39100
  type: r.type,
38894
39101
  reason: r.reason,
@@ -38912,6 +39119,7 @@ var DashboardServer = class {
38912
39119
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
38913
39120
  this.activeSessions.set(sessionId, cascade);
38914
39121
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
39122
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
38915
39123
  cascade.on("stream:token", (e) => {
38916
39124
  this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
38917
39125
  });
@@ -38935,13 +39143,16 @@ var DashboardServer = class {
38935
39143
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
38936
39144
  this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
38937
39145
  });
39146
+ cascade.on("run:started", (e) => {
39147
+ const id = e.taskId;
39148
+ if (id) this.recordSessionTask(sessionId, id);
39149
+ });
38938
39150
  try {
38939
39151
  const result = await cascade.run({
38940
39152
  prompt: runPrompt,
38941
39153
  signal: abortController.signal,
38942
39154
  approvalCallback: this.makeApprovalCallback(sessionId)
38943
39155
  });
38944
- this.recordSessionTask(sessionId, result.taskId);
38945
39156
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
38946
39157
  this.captureWhy(sessionId, cascade, result);
38947
39158
  this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
@@ -39007,6 +39218,49 @@ var DashboardServer = class {
39007
39218
  setCurrentPageProvider(provider) {
39008
39219
  this.currentPageProvider = provider;
39009
39220
  }
39221
+ /** Let runs act on that view, not just read it (desktop only). */
39222
+ setBrowserController(controller, release) {
39223
+ this.browserController = controller;
39224
+ this.browserActorRelease = release;
39225
+ }
39226
+ /**
39227
+ * Told whenever settings are committed through THIS server.
39228
+ *
39229
+ * Settings reach the live config by two routes — the desktop's Electron IPC
39230
+ * handler and this server's `config:update` socket — and both run the same
39231
+ * `commitSettings`. The desktop mirrors `tools.agentBrowserControl` into its
39232
+ * browser module after its own route, but had no way to learn about the
39233
+ * other, so a socket write left the module's copy stale: an enable the module
39234
+ * still refused, or worse a disable the module still honoured for a run
39235
+ * already holding the tool. This is the same sibling-writer drift
39236
+ * `settings-payload.ts` exists to prevent, one layer up.
39237
+ */
39238
+ settingsChangeHook;
39239
+ onSettingsChanged(hook) {
39240
+ this.settingsChangeHook = hook;
39241
+ }
39242
+ /**
39243
+ * Told when a run ends, so a host holding run-scoped state can release it.
39244
+ *
39245
+ * The desktop uses this to retire a run's browser Stop control: that has to
39246
+ * outlive each individual action (the gap between two actions is when the
39247
+ * user most wants it) but must not outlive the run.
39248
+ */
39249
+ /**
39250
+ * Fired when a run ends, carrying BOTH identifiers explicitly.
39251
+ *
39252
+ * They are different things and conflating them was a live bug: a chat
39253
+ * `sessionId` can contain many runs (hence `sessionTaskIds`), while
39254
+ * `Cascade.run()` mints a fresh random `taskId` per run — and it is the
39255
+ * taskId that tools see as `ToolExecuteOptions.sessionId`. Passing only the
39256
+ * chat id meant browser cleanup keyed on the task id matched nothing, so a
39257
+ * finished run kept its Stop banner armed and held its browser lease until
39258
+ * the deadlock ceiling.
39259
+ */
39260
+ runEndedHook;
39261
+ onRunEnded(hook) {
39262
+ this.runEndedHook = hook;
39263
+ }
39010
39264
  async start() {
39011
39265
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
39012
39266
  if (!isLoopback) {
@@ -39197,6 +39451,8 @@ var DashboardServer = class {
39197
39451
  }
39198
39452
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
39199
39453
  persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
39454
+ const endedTaskId = result?.taskId ?? this.sessionTaskIds.get(sessionId)?.at(-1);
39455
+ this.runEndedHook?.({ sessionId, ...endedTaskId ? { taskId: endedTaskId } : {} });
39200
39456
  try {
39201
39457
  if (reply && reply.trim()) {
39202
39458
  this.store.addMessage({ id: crypto.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
@@ -39290,6 +39546,7 @@ var DashboardServer = class {
39290
39546
  const prompt = task.prompt;
39291
39547
  const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
39292
39548
  const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
39549
+ cascade.setUnattended(true);
39293
39550
  this.activeSessions.set(sessionId, cascade);
39294
39551
  cascade.on("tier:status", (e) => {
39295
39552
  this.socket.broadcast("tier:status", { sessionId, ...e });
@@ -39297,13 +39554,16 @@ var DashboardServer = class {
39297
39554
  cascade.on("peer:message", (e) => {
39298
39555
  this.socket.emitPeerMessage(e);
39299
39556
  });
39557
+ cascade.on("run:started", (e) => {
39558
+ const id = e.taskId;
39559
+ if (id) this.recordSessionTask(sessionId, id);
39560
+ });
39300
39561
  try {
39301
39562
  const result = await cascade.run({
39302
39563
  prompt,
39303
39564
  identityId: task.identityId,
39304
39565
  approvalCallback: async () => ({ approved: true, always: false })
39305
39566
  });
39306
- this.recordSessionTask(sessionId, result.taskId);
39307
39567
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
39308
39568
  this.captureWhy(sessionId, cascade, result);
39309
39569
  this.socket.broadcast("session:complete", { sessionId, result });
@@ -39936,6 +40196,7 @@ ${prompt}`;
39936
40196
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
39937
40197
  this.activeSessions.set(sessionId, cascade);
39938
40198
  if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
40199
+ if (this.browserController) cascade.setBrowserController(this.browserController, this.browserActorRelease);
39939
40200
  cascade.on("stream:token", (e) => {
39940
40201
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
39941
40202
  });
@@ -39959,13 +40220,16 @@ ${prompt}`;
39959
40220
  if (ev.requestId) this.clearPendingEscalation(ev.requestId);
39960
40221
  this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
39961
40222
  });
40223
+ cascade.on("run:started", (e) => {
40224
+ const id = e.taskId;
40225
+ if (id) this.recordSessionTask(sessionId, id);
40226
+ });
39962
40227
  try {
39963
40228
  const result = await cascade.run({
39964
40229
  prompt: runPrompt,
39965
40230
  identityId: body.identityId,
39966
40231
  approvalCallback: this.makeApprovalCallback(sessionId)
39967
40232
  });
39968
- this.recordSessionTask(sessionId, result.taskId);
39969
40233
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
39970
40234
  this.captureWhy(sessionId, cascade, result);
39971
40235
  this.socket.broadcast("cost:update", {