cascade-ai 0.15.2 → 0.17.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
@@ -43,6 +43,7 @@ var bcrypt = require('bcryptjs');
43
43
  var socket_io = require('socket.io');
44
44
  var parser = require('socket.io-msgpack-parser');
45
45
  var jwt = require('jsonwebtoken');
46
+ var cron = require('node-cron');
46
47
 
47
48
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
48
49
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -93,6 +94,7 @@ var rateLimit__default = /*#__PURE__*/_interopDefault(rateLimit);
93
94
  var bcrypt__default = /*#__PURE__*/_interopDefault(bcrypt);
94
95
  var parser__default = /*#__PURE__*/_interopDefault(parser);
95
96
  var jwt__default = /*#__PURE__*/_interopDefault(jwt);
97
+ var cron__default = /*#__PURE__*/_interopDefault(cron);
96
98
 
97
99
  // Cascade AI — Multi-tier AI Orchestration System
98
100
  var __defProp = Object.defineProperty;
@@ -109,7 +111,7 @@ var __export = (target, all) => {
109
111
  var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SECRET_FILE, GLOBAL_CONFIG_DIR, GLOBAL_DB_FILE, GLOBAL_KEYSTORE_FILE, GLOBAL_RUNTIME_DB_FILE, DEFAULT_DASHBOARD_PORT, DEFAULT_CONTEXT_LIMIT, DEFAULT_AUTO_SUMMARIZE_AT, MODELS, T1_MODEL_PRIORITY, T2_MODEL_PRIORITY, T3_MODEL_PRIORITY, VISION_MODEL_PRIORITY, COMPLEXITY_T2_COUNT, THEME_NAMES, DEFAULT_THEME, OLLAMA_BASE_URL, LM_STUDIO_BASE_URL, AZURE_BASE_URL_TEMPLATE, TOOL_NAMES, DEFAULT_APPROVAL_REQUIRED;
110
112
  var init_constants = __esm({
111
113
  "src/constants.ts"() {
112
- CASCADE_VERSION = "0.15.2";
114
+ CASCADE_VERSION = "0.17.0";
113
115
  CASCADE_CONFIG_FILE = ".cascade/config.json";
114
116
  CASCADE_DB_FILE = ".cascade/memory.db";
115
117
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -3230,6 +3232,11 @@ function validateConfig(raw) {
3230
3232
 
3231
3233
  // src/config/index.ts
3232
3234
  init_constants();
3235
+ var KEY_OPTIONAL_PROVIDER_TYPES = /* @__PURE__ */ new Set(["ollama", "openai-compatible"]);
3236
+ function hasUsableProvider(providers) {
3237
+ if (!providers?.length) return false;
3238
+ return providers.some((p) => KEY_OPTIONAL_PROVIDER_TYPES.has(p.type) || !!p.apiKey);
3239
+ }
3233
3240
  var ConfigManager = class {
3234
3241
  config;
3235
3242
  keystore;
@@ -7518,6 +7525,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7518
7525
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
7519
7526
  if (this.store) worker.setStore(this.store, taskId);
7520
7527
  worker.setPeerBus(this.t3PeerBus);
7528
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
7529
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
7530
+ worker.on("log", (e) => this.emit("log", e));
7531
+ worker.on("tier:status", (e) => this.emit("tier:status", e));
7521
7532
  worker.on("stream:token", (e) => this.emit("stream:token", e));
7522
7533
  worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
7523
7534
  ...e,
@@ -7869,12 +7880,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
7869
7880
  status: "IN_PROGRESS"
7870
7881
  });
7871
7882
  const okBefore = okCount(allT2Results);
7883
+ const completedSummary = this.summarizeCompletedSections(allT2Results);
7884
+ const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
7872
7885
  const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
7873
7886
  Review reason: ${reviewResult.reason}
7874
7887
 
7875
7888
  Original goal: ${enrichedPrompt}
7876
7889
 
7877
- Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`);
7890
+ Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
7878
7891
  const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
7879
7892
  allT2Results = [...allT2Results, ...correctionResults];
7880
7893
  if (okCount(allT2Results) <= okBefore) {
@@ -7977,6 +7990,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
7977
7990
  return null;
7978
7991
  }
7979
7992
  }
7993
+ /** Structured, grounded summary of what's already done — used to keep
7994
+ * corrective replan passes from re-emitting completed sections. */
7995
+ summarizeCompletedSections(results) {
7996
+ const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
7997
+ if (done.length === 0) return "";
7998
+ const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
7999
+ return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
8000
+ ${lines.join("\n")}`;
8001
+ }
7980
8002
  async decomposeTask(prompt, systemContext) {
7981
8003
  const db = this.router.getWorldStateDB?.();
7982
8004
  let worldStateContext = "";
@@ -8244,7 +8266,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
8244
8266
  }
8245
8267
  }
8246
8268
  if (readyIds.length === 0) return;
8247
- await Promise.all(readyIds.map(async (id) => {
8269
+ const runOne = async (id) => {
8248
8270
  resultMap.set(id, null);
8249
8271
  const index = sections.findIndex((s) => s.sectionId === id);
8250
8272
  const section = sections[index];
@@ -8284,7 +8306,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
8284
8306
  for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
8285
8307
  inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
8286
8308
  }
8287
- }));
8309
+ };
8310
+ if (this.router.getT3ExecutionMode?.() === "sequential") {
8311
+ for (const id of readyIds) {
8312
+ await runOne(id);
8313
+ }
8314
+ } else {
8315
+ await Promise.all(readyIds.map(runOne));
8316
+ }
8288
8317
  if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
8289
8318
  await executeWave();
8290
8319
  }
@@ -10088,6 +10117,13 @@ var PermissionEscalator = class extends EventEmitter__default.default {
10088
10117
  * All T3 workers under the same T2 share cached decisions for the same tool.
10089
10118
  */
10090
10119
  sessionCache = /* @__PURE__ */ new Map();
10120
+ /**
10121
+ * Task-wide cache keyed by `toolName` alone, for USER- and T1-level
10122
+ * "always" decisions — these are meant to cover every sibling T2/T3 in the
10123
+ * run, not just the one that happened to ask first (see PermissionDecision
10124
+ * doc comment: "task-wide for T1").
10125
+ */
10126
+ taskWideCache = /* @__PURE__ */ new Map();
10091
10127
  t2Evaluator;
10092
10128
  t1Evaluator;
10093
10129
  /** Pending user-decision resolvers keyed by request ID */
@@ -10116,6 +10152,15 @@ var PermissionEscalator = class extends EventEmitter__default.default {
10116
10152
  * Returns a PermissionDecision from whichever tier was able to decide.
10117
10153
  */
10118
10154
  async requestPermission(req) {
10155
+ if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
10156
+ return {
10157
+ requestId: req.id,
10158
+ approved: this.taskWideCache.get(req.toolName),
10159
+ always: true,
10160
+ decidedBy: "T1",
10161
+ reasoning: "Cached from a previous task-wide decision in this session"
10162
+ };
10163
+ }
10119
10164
  const cacheKey = `${req.parentT2Id}:${req.toolName}`;
10120
10165
  if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
10121
10166
  return {
@@ -10160,7 +10205,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
10160
10205
  try {
10161
10206
  const t1Decision = await this.t1Evaluator(req);
10162
10207
  if (t1Decision !== null) {
10163
- if (t1Decision.always) this.sessionCache.set(cacheKey, t1Decision.approved);
10208
+ if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
10164
10209
  return t1Decision;
10165
10210
  }
10166
10211
  } catch {
@@ -10190,7 +10235,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
10190
10235
  const wrappedResolver = (decision) => {
10191
10236
  if (timer) clearTimeout(timer);
10192
10237
  if (decision.always) {
10193
- this.sessionCache.set(`${req.parentT2Id}:${req.toolName}`, decision.approved);
10238
+ this.taskWideCache.set(req.toolName, decision.approved);
10194
10239
  }
10195
10240
  resolve(decision);
10196
10241
  };
@@ -15269,11 +15314,18 @@ function SetupWizard({ workspacePath, onComplete }) {
15269
15314
  }
15270
15315
  });
15271
15316
  const currentEntry = state.entries[state.currentEntryIdx];
15317
+ const rejectIfBlank = React2.useCallback((val, message) => {
15318
+ if (val.trim()) return false;
15319
+ dispatch({ type: "SET_ERROR", error: message });
15320
+ return true;
15321
+ }, []);
15272
15322
  const handleFieldSubmit = React2.useCallback((val) => {
15273
15323
  if (!currentEntry) return;
15274
15324
  if (currentEntry.type === "azure") {
15275
15325
  if (fieldStage === "deploymentName") {
15326
+ if (rejectIfBlank(val, "Deployment name is required.")) return;
15276
15327
  dispatch({ type: "SET_ENTRY_FIELD", field: "deploymentName", value: val });
15328
+ dispatch({ type: "SET_ERROR", error: "" });
15277
15329
  setFieldBuffer("");
15278
15330
  if (currentEntry.baseUrl && currentEntry.apiKey && currentEntry.apiVersion) {
15279
15331
  setFieldStage("askMore");
@@ -15281,38 +15333,50 @@ function SetupWizard({ workspacePath, onComplete }) {
15281
15333
  setFieldStage("baseUrl");
15282
15334
  }
15283
15335
  } else if (fieldStage === "baseUrl") {
15336
+ if (rejectIfBlank(val, "Azure endpoint URL is required.")) return;
15284
15337
  dispatch({ type: "SET_ENTRY_FIELD", field: "baseUrl", value: val });
15338
+ dispatch({ type: "SET_ERROR", error: "" });
15285
15339
  setFieldBuffer("");
15286
15340
  setFieldStage("apiKey");
15287
15341
  } else if (fieldStage === "apiKey") {
15342
+ if (rejectIfBlank(val, "API key is required.")) return;
15288
15343
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiKey", value: val });
15344
+ dispatch({ type: "SET_ERROR", error: "" });
15289
15345
  setFieldBuffer("");
15290
15346
  setFieldStage("apiVersion");
15291
15347
  } else if (fieldStage === "apiVersion") {
15292
15348
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiVersion", value: val || "2024-08-01-preview" });
15349
+ dispatch({ type: "SET_ERROR", error: "" });
15293
15350
  setFieldBuffer("");
15294
15351
  setFieldStage("askMore");
15295
15352
  }
15296
15353
  } else if (currentEntry.type === "openai-compatible") {
15297
15354
  if (fieldStage === "label") {
15298
15355
  dispatch({ type: "SET_ENTRY_FIELD", field: "label", value: val || currentEntry.label });
15356
+ dispatch({ type: "SET_ERROR", error: "" });
15299
15357
  setFieldBuffer("");
15300
15358
  setFieldStage("baseUrl");
15301
15359
  } else if (fieldStage === "baseUrl") {
15360
+ if (rejectIfBlank(val, "Base URL is required.")) return;
15302
15361
  dispatch({ type: "SET_ENTRY_FIELD", field: "baseUrl", value: val });
15362
+ dispatch({ type: "SET_ERROR", error: "" });
15303
15363
  setFieldBuffer("");
15304
15364
  setFieldStage("apiKey");
15305
15365
  } else if (fieldStage === "apiKey") {
15306
15366
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiKey", value: val });
15367
+ dispatch({ type: "SET_ERROR", error: "" });
15307
15368
  setFieldBuffer("");
15308
15369
  setFieldStage("askMore");
15309
15370
  }
15310
15371
  } else if (currentEntry.type === "ollama") {
15311
15372
  dispatch({ type: "SET_ENTRY_FIELD", field: "baseUrl", value: val || "http://localhost:11434" });
15373
+ dispatch({ type: "SET_ERROR", error: "" });
15312
15374
  setFieldBuffer("");
15313
15375
  setFieldStage("askMore");
15314
15376
  } else {
15377
+ if (rejectIfBlank(val, "API key is required.")) return;
15315
15378
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiKey", value: val });
15379
+ dispatch({ type: "SET_ERROR", error: "" });
15316
15380
  setFieldBuffer("");
15317
15381
  const nextEntry = state.entries[state.currentEntryIdx + 1];
15318
15382
  if (nextEntry) {
@@ -15320,7 +15384,7 @@ function SetupWizard({ workspacePath, onComplete }) {
15320
15384
  }
15321
15385
  dispatch({ type: "NEXT_ENTRY" });
15322
15386
  }
15323
- }, [currentEntry, fieldStage]);
15387
+ }, [currentEntry, fieldStage, rejectIfBlank, state.entries, state.currentEntryIdx]);
15324
15388
  if (state.step === "PROVIDER_SELECT") {
15325
15389
  return /* @__PURE__ */ jsxRuntime.jsxs(Frame, { theme, phase: "keys", children: [
15326
15390
  /* @__PURE__ */ jsxRuntime.jsxs(ink.Box, { marginBottom: 1, children: [
@@ -16025,6 +16089,25 @@ var DashboardSocket = class {
16025
16089
  });
16026
16090
  });
16027
16091
  }
16092
+ /**
16093
+ * Boardroom plan decisions from a connected client. The desktop shows a
16094
+ * plan-review modal on `plan:approval-required` and answers here; the
16095
+ * server routes the decision into the paused run via resolvePlanApproval.
16096
+ */
16097
+ onPlanDecision(callback) {
16098
+ this.io.on("connection", (socket) => {
16099
+ socket.on("plan:decision", (payload) => {
16100
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
16101
+ callback({
16102
+ sessionId: payload.sessionId,
16103
+ approved: payload.approved,
16104
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
16105
+ editedPlan: payload.editedPlan
16106
+ });
16107
+ }
16108
+ });
16109
+ });
16110
+ }
16028
16111
  onSessionSteer(callback) {
16029
16112
  this.io.on("connection", (socket) => {
16030
16113
  socket.on("session:steer", (payload) => {
@@ -16069,6 +16152,122 @@ var DashboardSocket = class {
16069
16152
 
16070
16153
  // src/dashboard/server.ts
16071
16154
  init_constants();
16155
+ var TaskScheduler = class {
16156
+ cronJobs = /* @__PURE__ */ new Map();
16157
+ store;
16158
+ runner;
16159
+ constructor(store, runner) {
16160
+ this.store = store;
16161
+ this.runner = runner;
16162
+ }
16163
+ start() {
16164
+ const tasks = this.store.listScheduledTasks();
16165
+ for (const task of tasks) {
16166
+ if (task.enabled) this.schedule(task);
16167
+ }
16168
+ }
16169
+ stop() {
16170
+ for (const job of this.cronJobs.values()) job.stop();
16171
+ this.cronJobs.clear();
16172
+ }
16173
+ schedule(task) {
16174
+ if (!cron__default.default.validate(task.cronExpression)) {
16175
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
16176
+ }
16177
+ const existing = this.cronJobs.get(task.id);
16178
+ if (existing) {
16179
+ try {
16180
+ existing.stop();
16181
+ } catch {
16182
+ }
16183
+ }
16184
+ const job = cron__default.default.schedule(task.cronExpression, async () => {
16185
+ try {
16186
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
16187
+ this.store.saveScheduledTask(task);
16188
+ await this.runner(task);
16189
+ } catch (err) {
16190
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
16191
+ }
16192
+ }, { timezone: "UTC" });
16193
+ this.cronJobs.set(task.id, job);
16194
+ }
16195
+ unschedule(taskId) {
16196
+ this.cronJobs.get(taskId)?.stop();
16197
+ this.cronJobs.delete(taskId);
16198
+ }
16199
+ add(task) {
16200
+ this.store.saveScheduledTask(task);
16201
+ if (task.enabled) this.schedule(task);
16202
+ }
16203
+ remove(taskId) {
16204
+ this.unschedule(taskId);
16205
+ this.store.deleteScheduledTask(taskId);
16206
+ }
16207
+ list() {
16208
+ return this.store.listScheduledTasks();
16209
+ }
16210
+ isRunning(taskId) {
16211
+ return this.cronJobs.has(taskId);
16212
+ }
16213
+ static validateCron(expression) {
16214
+ return cron__default.default.validate(expression);
16215
+ }
16216
+ };
16217
+
16218
+ // src/dashboard/cost-stats.ts
16219
+ var DAY_MS = 24 * 60 * 60 * 1e3;
16220
+ function utcDateKey(d) {
16221
+ return d.toISOString().slice(0, 10);
16222
+ }
16223
+ function aggregateCostStats(sessions, opts = {}) {
16224
+ const days = Math.max(1, opts.days ?? 30);
16225
+ const topN = Math.max(1, opts.topN ?? 8);
16226
+ const now = opts.now ?? /* @__PURE__ */ new Date();
16227
+ const buckets = /* @__PURE__ */ new Map();
16228
+ for (let i = days - 1; i >= 0; i--) {
16229
+ const key = utcDateKey(new Date(now.getTime() - i * DAY_MS));
16230
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
16231
+ }
16232
+ let totalCostUsd = 0;
16233
+ let totalTokens = 0;
16234
+ let totalRuns = 0;
16235
+ for (const s of sessions) {
16236
+ const cost = s.metadata?.totalCostUsd ?? 0;
16237
+ const tokens = s.metadata?.totalTokens ?? 0;
16238
+ const runs = s.metadata?.taskCount ?? 0;
16239
+ totalCostUsd += cost;
16240
+ totalTokens += tokens;
16241
+ totalRuns += runs;
16242
+ const when = new Date(s.updatedAt || s.createdAt);
16243
+ if (!Number.isNaN(when.getTime())) {
16244
+ const bucket = buckets.get(utcDateKey(when));
16245
+ if (bucket) {
16246
+ bucket.costUsd += cost;
16247
+ bucket.tokens += tokens;
16248
+ bucket.runs += runs;
16249
+ }
16250
+ }
16251
+ }
16252
+ const topSessions = [...sessions].filter((s) => (s.metadata?.totalCostUsd ?? 0) > 0).sort((a, b) => (b.metadata?.totalCostUsd ?? 0) - (a.metadata?.totalCostUsd ?? 0)).slice(0, topN).map((s) => ({
16253
+ sessionId: s.id,
16254
+ title: s.title,
16255
+ costUsd: s.metadata?.totalCostUsd ?? 0,
16256
+ tokens: s.metadata?.totalTokens ?? 0,
16257
+ runs: s.metadata?.taskCount ?? 0,
16258
+ updatedAt: s.updatedAt
16259
+ }));
16260
+ return {
16261
+ totalCostUsd,
16262
+ totalTokens,
16263
+ totalSessions: sessions.length,
16264
+ totalRuns,
16265
+ perDay: [...buckets.values()],
16266
+ topSessions
16267
+ };
16268
+ }
16269
+
16270
+ // src/dashboard/server.ts
16072
16271
  var __dirname$1 = path25__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href))));
16073
16272
  var DashboardServer = class {
16074
16273
  app;
@@ -16094,6 +16293,14 @@ var DashboardServer = class {
16094
16293
  * map is how that answer reaches the run that's blocked on it.
16095
16294
  */
16096
16295
  pendingApprovals = /* @__PURE__ */ new Map();
16296
+ /**
16297
+ * The orchestration decision trail ("why") of each session's most recent
16298
+ * run — captured when the run ends so the desktop's Why panel can show it
16299
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
16300
+ */
16301
+ whyBySession = /* @__PURE__ */ new Map();
16302
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
16303
+ scheduler;
16097
16304
  port;
16098
16305
  host;
16099
16306
  workspacePath;
@@ -16111,6 +16318,7 @@ var DashboardServer = class {
16111
16318
  secret: this.dashboardSecret,
16112
16319
  corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
16113
16320
  });
16321
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
16114
16322
  this.setupMiddleware();
16115
16323
  this.setupRoutes();
16116
16324
  this.socket.onSessionRate((sessionId, rating) => {
@@ -16174,6 +16382,9 @@ var DashboardServer = class {
16174
16382
  cascade.on("peer:message", (e) => {
16175
16383
  this.socket.emitPeerMessage(e);
16176
16384
  });
16385
+ cascade.on("plan:approval-required", (e) => {
16386
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
16387
+ });
16177
16388
  try {
16178
16389
  const result = await cascade.run({
16179
16390
  prompt: runPrompt,
@@ -16181,7 +16392,8 @@ var DashboardServer = class {
16181
16392
  approvalCallback: this.makeApprovalCallback(sessionId)
16182
16393
  });
16183
16394
  this.recordSessionTask(sessionId, result.taskId);
16184
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
16395
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
16396
+ this.captureWhy(sessionId, cascade, result);
16185
16397
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
16186
16398
  this.socket.broadcast("cost:update", {
16187
16399
  sessionId,
@@ -16191,6 +16403,7 @@ var DashboardServer = class {
16191
16403
  this.throttledBroadcast("workspace");
16192
16404
  } catch (err) {
16193
16405
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
16406
+ this.captureWhy(sessionId, cascade);
16194
16407
  this.socket.emitToSocket(socketId, "session:error", {
16195
16408
  sessionId,
16196
16409
  error: err instanceof Error ? err.message : String(err)
@@ -16201,9 +16414,17 @@ var DashboardServer = class {
16201
16414
  this.denyPendingApprovals(sessionId);
16202
16415
  }
16203
16416
  });
16417
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
16418
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
16419
+ approved,
16420
+ note,
16421
+ editedPlan
16422
+ );
16423
+ });
16204
16424
  this.socket.onSessionHalt((sessionId) => {
16205
16425
  this.activeControllers.get(sessionId)?.abort();
16206
16426
  this.denyPendingApprovals(sessionId);
16427
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
16207
16428
  });
16208
16429
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
16209
16430
  const pending = this.pendingApprovals.get(requestId);
@@ -16236,12 +16457,21 @@ var DashboardServer = class {
16236
16457
  resolve();
16237
16458
  });
16238
16459
  });
16460
+ try {
16461
+ this.scheduler.start();
16462
+ } catch (err) {
16463
+ console.warn("[dashboard] failed to start task scheduler:", err);
16464
+ }
16239
16465
  }
16240
16466
  async stop() {
16241
16467
  if (this.broadcastTimer) {
16242
16468
  clearTimeout(this.broadcastTimer);
16243
16469
  this.broadcastTimer = null;
16244
16470
  }
16471
+ try {
16472
+ this.scheduler.stop();
16473
+ } catch {
16474
+ }
16245
16475
  this.socket.close();
16246
16476
  try {
16247
16477
  this.globalStore?.close();
@@ -16255,6 +16485,16 @@ var DashboardServer = class {
16255
16485
  getSocket() {
16256
16486
  return this.socket;
16257
16487
  }
16488
+ /**
16489
+ * Rebind the workspace tasks execute in — e.g. the desktop app's Code view
16490
+ * opening a different project folder — without tearing down the socket
16491
+ * server (which would drop the port/auth token/connection mid-session).
16492
+ * The next `cascade:run` picks this up immediately since `this.workspacePath`
16493
+ * is read live per-run (see onCascadeRun below).
16494
+ */
16495
+ setWorkspacePath(workspacePath) {
16496
+ this.workspacePath = workspacePath;
16497
+ }
16258
16498
  /**
16259
16499
  * Write the in-memory config back to the workspace config file so mutations
16260
16500
  * made over the socket (Settings → Save) persist across restarts. Best-effort:
@@ -16352,16 +16592,60 @@ var DashboardServer = class {
16352
16592
  return title;
16353
16593
  }
16354
16594
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
16355
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
16595
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
16356
16596
  try {
16357
16597
  if (reply && reply.trim()) {
16358
16598
  this.store.addMessage({ id: crypto.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
16359
16599
  }
16600
+ if (result) {
16601
+ const session = this.store.getSession(sessionId);
16602
+ if (session) {
16603
+ this.store.updateSession(sessionId, {
16604
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
16605
+ metadata: {
16606
+ ...session.metadata,
16607
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
16608
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
16609
+ taskCount: session.metadata.taskCount + 1
16610
+ }
16611
+ });
16612
+ }
16613
+ }
16360
16614
  } catch (err) {
16361
16615
  console.warn("[dashboard] failed to persist run end:", err);
16362
16616
  }
16363
16617
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
16364
16618
  }
16619
+ /**
16620
+ * Capture the run's decision trail + router economics ("why") and broadcast
16621
+ * it so the desktop's Why panel updates live; kept per-session for the
16622
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
16623
+ */
16624
+ captureWhy(sessionId, cascade, result) {
16625
+ try {
16626
+ const stats = cascade.getRouter().getStats();
16627
+ const savings = cascade.getRouter().getDelegationSavings();
16628
+ const report = {
16629
+ sessionId,
16630
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
16631
+ decisions: cascade.getDecisionLog(),
16632
+ savedUsd: savings.savedUsd,
16633
+ savedPct: savings.savedPct,
16634
+ totalCostUsd: stats.totalCostUsd,
16635
+ totalTokens: stats.totalTokens,
16636
+ costByTier: stats.costByTier,
16637
+ durationMs: result?.durationMs
16638
+ };
16639
+ this.whyBySession.set(sessionId, report);
16640
+ if (this.whyBySession.size > 50) {
16641
+ const oldest = this.whyBySession.keys().next().value;
16642
+ if (oldest) this.whyBySession.delete(oldest);
16643
+ }
16644
+ this.socket.broadcast("run:why", report);
16645
+ } catch (err) {
16646
+ console.warn("[dashboard] failed to capture decision trail:", err);
16647
+ }
16648
+ }
16365
16649
  /**
16366
16650
  * Route steering text into running Cascade instances. Targets the given
16367
16651
  * session, or every active session when none is specified (the desktop
@@ -16391,6 +16675,52 @@ var DashboardServer = class {
16391
16675
  this.pendingApprovals.set(request.id, { resolve, sessionId });
16392
16676
  });
16393
16677
  }
16678
+ /**
16679
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
16680
+ * tool approvals are auto-granted since nobody may be watching when the cron
16681
+ * fires — the Schedules UI states this. Events broadcast to every connected
16682
+ * client so an open desktop sees the run appear live.
16683
+ */
16684
+ async runScheduledTask(task) {
16685
+ const sessionId = crypto.randomUUID();
16686
+ const prompt = task.prompt;
16687
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
16688
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
16689
+ this.activeSessions.set(sessionId, cascade);
16690
+ cascade.on("tier:status", (e) => {
16691
+ this.socket.broadcast("tier:status", { sessionId, ...e });
16692
+ });
16693
+ cascade.on("peer:message", (e) => {
16694
+ this.socket.emitPeerMessage(e);
16695
+ });
16696
+ try {
16697
+ const result = await cascade.run({
16698
+ prompt,
16699
+ identityId: task.identityId,
16700
+ approvalCallback: async () => ({ approved: true, always: false })
16701
+ });
16702
+ this.recordSessionTask(sessionId, result.taskId);
16703
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
16704
+ this.captureWhy(sessionId, cascade, result);
16705
+ this.socket.broadcast("session:complete", { sessionId, result });
16706
+ this.socket.broadcast("cost:update", {
16707
+ sessionId,
16708
+ totalTokens: result.usage.totalTokens,
16709
+ totalCostUsd: result.usage.estimatedCostUsd
16710
+ });
16711
+ this.throttledBroadcast("workspace");
16712
+ } catch (err) {
16713
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
16714
+ this.captureWhy(sessionId, cascade);
16715
+ this.socket.broadcast("session:error", {
16716
+ sessionId,
16717
+ error: err instanceof Error ? err.message : String(err)
16718
+ });
16719
+ throw err;
16720
+ } finally {
16721
+ this.activeSessions.delete(sessionId);
16722
+ }
16723
+ }
16394
16724
  /** Deny + clear any approvals still pending for a session (run end / abort). */
16395
16725
  denyPendingApprovals(sessionId) {
16396
16726
  for (const [id, pending] of this.pendingApprovals) {
@@ -16903,6 +17233,9 @@ ${prompt}`;
16903
17233
  cascade.on("peer:message", (e) => {
16904
17234
  this.socket.emitPeerMessage(e);
16905
17235
  });
17236
+ cascade.on("plan:approval-required", (e) => {
17237
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
17238
+ });
16906
17239
  try {
16907
17240
  const result = await cascade.run({
16908
17241
  prompt: runPrompt,
@@ -16910,7 +17243,8 @@ ${prompt}`;
16910
17243
  approvalCallback: this.makeApprovalCallback(sessionId)
16911
17244
  });
16912
17245
  this.recordSessionTask(sessionId, result.taskId);
16913
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
17246
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
17247
+ this.captureWhy(sessionId, cascade, result);
16914
17248
  this.socket.broadcast("cost:update", {
16915
17249
  sessionId,
16916
17250
  totalTokens: result.usage.totalTokens,
@@ -16920,6 +17254,7 @@ ${prompt}`;
16920
17254
  this.throttledBroadcast("workspace");
16921
17255
  } catch (err) {
16922
17256
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
17257
+ this.captureWhy(sessionId, cascade);
16923
17258
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
16924
17259
  sessionId,
16925
17260
  error: err instanceof Error ? err.message : String(err)
@@ -16941,6 +17276,168 @@ ${prompt}`;
16941
17276
  }))
16942
17277
  });
16943
17278
  });
17279
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
17280
+ const report = this.whyBySession.get(req.params.id);
17281
+ if (!report) {
17282
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
17283
+ return;
17284
+ }
17285
+ res.json(report);
17286
+ });
17287
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
17288
+ const sessionId = req.params.id;
17289
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
17290
+ const before = /* @__PURE__ */ new Map();
17291
+ for (const taskId of taskIds) {
17292
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
17293
+ if (!before.has(filePath)) before.set(filePath, content);
17294
+ }
17295
+ }
17296
+ const { readFile } = await import('fs/promises');
17297
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
17298
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
17299
+ let after = "";
17300
+ let missing = false;
17301
+ try {
17302
+ const buf = await readFile(filePath);
17303
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
17304
+ } catch {
17305
+ missing = true;
17306
+ }
17307
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
17308
+ }));
17309
+ res.json({ sessionId, changes: changes.filter((c) => c.changed) });
17310
+ });
17311
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
17312
+ const sessionId = req.params.id;
17313
+ const body = req.body;
17314
+ if (!body.filePath || typeof body.filePath !== "string") {
17315
+ res.status(400).json({ error: "filePath is required" });
17316
+ return;
17317
+ }
17318
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
17319
+ let content;
17320
+ for (const taskId of taskIds) {
17321
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s) => s.filePath === body.filePath);
17322
+ if (snap) {
17323
+ content = snap.content;
17324
+ break;
17325
+ }
17326
+ }
17327
+ if (content === void 0) {
17328
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
17329
+ return;
17330
+ }
17331
+ try {
17332
+ const { writeFile } = await import('fs/promises');
17333
+ await writeFile(body.filePath, content, "utf-8");
17334
+ res.json({ ok: true, filePath: body.filePath });
17335
+ } catch (err) {
17336
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17337
+ }
17338
+ });
17339
+ this.app.get("/api/costs", auth, (_req, res) => {
17340
+ try {
17341
+ const sessions = this.store.listSessions(void 0, 1e3);
17342
+ const budget = this.config.budget ?? { warnAtPct: 80 };
17343
+ res.json({
17344
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
17345
+ budget: {
17346
+ dailyBudgetUsd: budget.dailyBudgetUsd,
17347
+ sessionBudgetUsd: budget.sessionBudgetUsd,
17348
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
17349
+ }
17350
+ });
17351
+ } catch (err) {
17352
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17353
+ }
17354
+ });
17355
+ this.app.get("/api/schedules", auth, (_req, res) => {
17356
+ try {
17357
+ res.json(this.scheduler.list().map((t) => ({ ...t, armed: this.scheduler.isRunning(t.id) })));
17358
+ } catch (err) {
17359
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17360
+ }
17361
+ });
17362
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
17363
+ const body = req.body;
17364
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
17365
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
17366
+ return;
17367
+ }
17368
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
17369
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
17370
+ return;
17371
+ }
17372
+ const task = {
17373
+ id: crypto.randomUUID(),
17374
+ name: body.name.trim(),
17375
+ cronExpression: body.cronExpression.trim(),
17376
+ prompt: body.prompt.trim(),
17377
+ workspacePath: this.workspacePath,
17378
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
17379
+ enabled: body.enabled !== false
17380
+ };
17381
+ try {
17382
+ this.scheduler.add(task);
17383
+ res.json(task);
17384
+ } catch (err) {
17385
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17386
+ }
17387
+ });
17388
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
17389
+ const id = req.params.id;
17390
+ const existing = this.scheduler.list().find((t) => t.id === id);
17391
+ if (!existing) {
17392
+ res.status(404).json({ error: "Not found" });
17393
+ return;
17394
+ }
17395
+ const body = req.body;
17396
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
17397
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
17398
+ return;
17399
+ }
17400
+ const updated = {
17401
+ ...existing,
17402
+ name: body.name?.trim() || existing.name,
17403
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
17404
+ prompt: body.prompt?.trim() || existing.prompt,
17405
+ enabled: body.enabled ?? existing.enabled
17406
+ };
17407
+ try {
17408
+ this.scheduler.add(updated);
17409
+ if (!updated.enabled) this.scheduler.unschedule(id);
17410
+ res.json(updated);
17411
+ } catch (err) {
17412
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17413
+ }
17414
+ });
17415
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
17416
+ try {
17417
+ this.scheduler.remove(req.params.id);
17418
+ res.json({ ok: true });
17419
+ } catch (err) {
17420
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17421
+ }
17422
+ });
17423
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
17424
+ try {
17425
+ const limit = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
17426
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
17427
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
17428
+ const logger = new AuditLogger3(this.workspacePath);
17429
+ try {
17430
+ const all = logger.getAllLogs();
17431
+ const total = all.length;
17432
+ const entries = all.reverse().slice(offset, offset + limit);
17433
+ res.json({ total, offset, entries });
17434
+ } finally {
17435
+ logger.close();
17436
+ }
17437
+ } catch (err) {
17438
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17439
+ }
17440
+ });
16944
17441
  const prodPath = path25__default.default.resolve(__dirname$1, "../web/dist");
16945
17442
  const devPath = path25__default.default.resolve(__dirname$1, "../../web/dist");
16946
17443
  const webDistPath = fs23__default.default.existsSync(prodPath) ? prodPath : devPath;
@@ -17558,7 +18055,7 @@ async function startRepl(options) {
17558
18055
  process.exit(1);
17559
18056
  }
17560
18057
  let config = cm.getConfig();
17561
- const needsSetup = !config.providers?.length || config.providers.every((p) => p.type !== "ollama" && !p.apiKey);
18058
+ const needsSetup = !hasUsableProvider(config.providers);
17562
18059
  if (needsSetup) {
17563
18060
  console.log(chalk9__default.default.magenta(" \u25C8 No providers configured \u2014 launching setup wizard\u2026"));
17564
18061
  console.log();
@@ -17599,7 +18096,7 @@ async function runHeadless(prompt, options) {
17599
18096
  process.exit(1);
17600
18097
  }
17601
18098
  const config = cm.getConfig();
17602
- const needsSetup = !config.providers?.length || config.providers.every((p) => p.type !== "ollama" && !p.apiKey);
18099
+ const needsSetup = !hasUsableProvider(config.providers);
17603
18100
  if (needsSetup) {
17604
18101
  console.error(chalk9__default.default.red("No providers configured. Run `cascade init` first."));
17605
18102
  process.exit(1);