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.js CHANGED
@@ -42,6 +42,7 @@ import bcrypt from 'bcryptjs';
42
42
  import { Server } from 'socket.io';
43
43
  import parser from 'socket.io-msgpack-parser';
44
44
  import jwt from 'jsonwebtoken';
45
+ import cron from 'node-cron';
45
46
 
46
47
  // Cascade AI — Multi-tier AI Orchestration System
47
48
  var __defProp = Object.defineProperty;
@@ -58,7 +59,7 @@ var __export = (target, all) => {
58
59
  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;
59
60
  var init_constants = __esm({
60
61
  "src/constants.ts"() {
61
- CASCADE_VERSION = "0.15.2";
62
+ CASCADE_VERSION = "0.17.0";
62
63
  CASCADE_CONFIG_FILE = ".cascade/config.json";
63
64
  CASCADE_DB_FILE = ".cascade/memory.db";
64
65
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -3179,6 +3180,11 @@ function validateConfig(raw) {
3179
3180
 
3180
3181
  // src/config/index.ts
3181
3182
  init_constants();
3183
+ var KEY_OPTIONAL_PROVIDER_TYPES = /* @__PURE__ */ new Set(["ollama", "openai-compatible"]);
3184
+ function hasUsableProvider(providers) {
3185
+ if (!providers?.length) return false;
3186
+ return providers.some((p) => KEY_OPTIONAL_PROVIDER_TYPES.has(p.type) || !!p.apiKey);
3187
+ }
3182
3188
  var ConfigManager = class {
3183
3189
  config;
3184
3190
  keystore;
@@ -7467,6 +7473,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7467
7473
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
7468
7474
  if (this.store) worker.setStore(this.store, taskId);
7469
7475
  worker.setPeerBus(this.t3PeerBus);
7476
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
7477
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
7478
+ worker.on("log", (e) => this.emit("log", e));
7479
+ worker.on("tier:status", (e) => this.emit("tier:status", e));
7470
7480
  worker.on("stream:token", (e) => this.emit("stream:token", e));
7471
7481
  worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
7472
7482
  ...e,
@@ -7818,12 +7828,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
7818
7828
  status: "IN_PROGRESS"
7819
7829
  });
7820
7830
  const okBefore = okCount(allT2Results);
7831
+ const completedSummary = this.summarizeCompletedSections(allT2Results);
7832
+ const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
7821
7833
  const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
7822
7834
  Review reason: ${reviewResult.reason}
7823
7835
 
7824
7836
  Original goal: ${enrichedPrompt}
7825
7837
 
7826
- Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`);
7838
+ Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
7827
7839
  const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
7828
7840
  allT2Results = [...allT2Results, ...correctionResults];
7829
7841
  if (okCount(allT2Results) <= okBefore) {
@@ -7926,6 +7938,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
7926
7938
  return null;
7927
7939
  }
7928
7940
  }
7941
+ /** Structured, grounded summary of what's already done — used to keep
7942
+ * corrective replan passes from re-emitting completed sections. */
7943
+ summarizeCompletedSections(results) {
7944
+ const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
7945
+ if (done.length === 0) return "";
7946
+ const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
7947
+ return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
7948
+ ${lines.join("\n")}`;
7949
+ }
7929
7950
  async decomposeTask(prompt, systemContext) {
7930
7951
  const db = this.router.getWorldStateDB?.();
7931
7952
  let worldStateContext = "";
@@ -8193,7 +8214,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
8193
8214
  }
8194
8215
  }
8195
8216
  if (readyIds.length === 0) return;
8196
- await Promise.all(readyIds.map(async (id) => {
8217
+ const runOne = async (id) => {
8197
8218
  resultMap.set(id, null);
8198
8219
  const index = sections.findIndex((s) => s.sectionId === id);
8199
8220
  const section = sections[index];
@@ -8233,7 +8254,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
8233
8254
  for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
8234
8255
  inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
8235
8256
  }
8236
- }));
8257
+ };
8258
+ if (this.router.getT3ExecutionMode?.() === "sequential") {
8259
+ for (const id of readyIds) {
8260
+ await runOne(id);
8261
+ }
8262
+ } else {
8263
+ await Promise.all(readyIds.map(runOne));
8264
+ }
8237
8265
  if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
8238
8266
  await executeWave();
8239
8267
  }
@@ -10037,6 +10065,13 @@ var PermissionEscalator = class extends EventEmitter {
10037
10065
  * All T3 workers under the same T2 share cached decisions for the same tool.
10038
10066
  */
10039
10067
  sessionCache = /* @__PURE__ */ new Map();
10068
+ /**
10069
+ * Task-wide cache keyed by `toolName` alone, for USER- and T1-level
10070
+ * "always" decisions — these are meant to cover every sibling T2/T3 in the
10071
+ * run, not just the one that happened to ask first (see PermissionDecision
10072
+ * doc comment: "task-wide for T1").
10073
+ */
10074
+ taskWideCache = /* @__PURE__ */ new Map();
10040
10075
  t2Evaluator;
10041
10076
  t1Evaluator;
10042
10077
  /** Pending user-decision resolvers keyed by request ID */
@@ -10065,6 +10100,15 @@ var PermissionEscalator = class extends EventEmitter {
10065
10100
  * Returns a PermissionDecision from whichever tier was able to decide.
10066
10101
  */
10067
10102
  async requestPermission(req) {
10103
+ if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
10104
+ return {
10105
+ requestId: req.id,
10106
+ approved: this.taskWideCache.get(req.toolName),
10107
+ always: true,
10108
+ decidedBy: "T1",
10109
+ reasoning: "Cached from a previous task-wide decision in this session"
10110
+ };
10111
+ }
10068
10112
  const cacheKey = `${req.parentT2Id}:${req.toolName}`;
10069
10113
  if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
10070
10114
  return {
@@ -10109,7 +10153,7 @@ var PermissionEscalator = class extends EventEmitter {
10109
10153
  try {
10110
10154
  const t1Decision = await this.t1Evaluator(req);
10111
10155
  if (t1Decision !== null) {
10112
- if (t1Decision.always) this.sessionCache.set(cacheKey, t1Decision.approved);
10156
+ if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
10113
10157
  return t1Decision;
10114
10158
  }
10115
10159
  } catch {
@@ -10139,7 +10183,7 @@ var PermissionEscalator = class extends EventEmitter {
10139
10183
  const wrappedResolver = (decision) => {
10140
10184
  if (timer) clearTimeout(timer);
10141
10185
  if (decision.always) {
10142
- this.sessionCache.set(`${req.parentT2Id}:${req.toolName}`, decision.approved);
10186
+ this.taskWideCache.set(req.toolName, decision.approved);
10143
10187
  }
10144
10188
  resolve(decision);
10145
10189
  };
@@ -15218,11 +15262,18 @@ function SetupWizard({ workspacePath, onComplete }) {
15218
15262
  }
15219
15263
  });
15220
15264
  const currentEntry = state.entries[state.currentEntryIdx];
15265
+ const rejectIfBlank = useCallback((val, message) => {
15266
+ if (val.trim()) return false;
15267
+ dispatch({ type: "SET_ERROR", error: message });
15268
+ return true;
15269
+ }, []);
15221
15270
  const handleFieldSubmit = useCallback((val) => {
15222
15271
  if (!currentEntry) return;
15223
15272
  if (currentEntry.type === "azure") {
15224
15273
  if (fieldStage === "deploymentName") {
15274
+ if (rejectIfBlank(val, "Deployment name is required.")) return;
15225
15275
  dispatch({ type: "SET_ENTRY_FIELD", field: "deploymentName", value: val });
15276
+ dispatch({ type: "SET_ERROR", error: "" });
15226
15277
  setFieldBuffer("");
15227
15278
  if (currentEntry.baseUrl && currentEntry.apiKey && currentEntry.apiVersion) {
15228
15279
  setFieldStage("askMore");
@@ -15230,38 +15281,50 @@ function SetupWizard({ workspacePath, onComplete }) {
15230
15281
  setFieldStage("baseUrl");
15231
15282
  }
15232
15283
  } else if (fieldStage === "baseUrl") {
15284
+ if (rejectIfBlank(val, "Azure endpoint URL is required.")) return;
15233
15285
  dispatch({ type: "SET_ENTRY_FIELD", field: "baseUrl", value: val });
15286
+ dispatch({ type: "SET_ERROR", error: "" });
15234
15287
  setFieldBuffer("");
15235
15288
  setFieldStage("apiKey");
15236
15289
  } else if (fieldStage === "apiKey") {
15290
+ if (rejectIfBlank(val, "API key is required.")) return;
15237
15291
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiKey", value: val });
15292
+ dispatch({ type: "SET_ERROR", error: "" });
15238
15293
  setFieldBuffer("");
15239
15294
  setFieldStage("apiVersion");
15240
15295
  } else if (fieldStage === "apiVersion") {
15241
15296
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiVersion", value: val || "2024-08-01-preview" });
15297
+ dispatch({ type: "SET_ERROR", error: "" });
15242
15298
  setFieldBuffer("");
15243
15299
  setFieldStage("askMore");
15244
15300
  }
15245
15301
  } else if (currentEntry.type === "openai-compatible") {
15246
15302
  if (fieldStage === "label") {
15247
15303
  dispatch({ type: "SET_ENTRY_FIELD", field: "label", value: val || currentEntry.label });
15304
+ dispatch({ type: "SET_ERROR", error: "" });
15248
15305
  setFieldBuffer("");
15249
15306
  setFieldStage("baseUrl");
15250
15307
  } else if (fieldStage === "baseUrl") {
15308
+ if (rejectIfBlank(val, "Base URL is required.")) return;
15251
15309
  dispatch({ type: "SET_ENTRY_FIELD", field: "baseUrl", value: val });
15310
+ dispatch({ type: "SET_ERROR", error: "" });
15252
15311
  setFieldBuffer("");
15253
15312
  setFieldStage("apiKey");
15254
15313
  } else if (fieldStage === "apiKey") {
15255
15314
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiKey", value: val });
15315
+ dispatch({ type: "SET_ERROR", error: "" });
15256
15316
  setFieldBuffer("");
15257
15317
  setFieldStage("askMore");
15258
15318
  }
15259
15319
  } else if (currentEntry.type === "ollama") {
15260
15320
  dispatch({ type: "SET_ENTRY_FIELD", field: "baseUrl", value: val || "http://localhost:11434" });
15321
+ dispatch({ type: "SET_ERROR", error: "" });
15261
15322
  setFieldBuffer("");
15262
15323
  setFieldStage("askMore");
15263
15324
  } else {
15325
+ if (rejectIfBlank(val, "API key is required.")) return;
15264
15326
  dispatch({ type: "SET_ENTRY_FIELD", field: "apiKey", value: val });
15327
+ dispatch({ type: "SET_ERROR", error: "" });
15265
15328
  setFieldBuffer("");
15266
15329
  const nextEntry = state.entries[state.currentEntryIdx + 1];
15267
15330
  if (nextEntry) {
@@ -15269,7 +15332,7 @@ function SetupWizard({ workspacePath, onComplete }) {
15269
15332
  }
15270
15333
  dispatch({ type: "NEXT_ENTRY" });
15271
15334
  }
15272
- }, [currentEntry, fieldStage]);
15335
+ }, [currentEntry, fieldStage, rejectIfBlank, state.entries, state.currentEntryIdx]);
15273
15336
  if (state.step === "PROVIDER_SELECT") {
15274
15337
  return /* @__PURE__ */ jsxs(Frame, { theme, phase: "keys", children: [
15275
15338
  /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
@@ -15974,6 +16037,25 @@ var DashboardSocket = class {
15974
16037
  });
15975
16038
  });
15976
16039
  }
16040
+ /**
16041
+ * Boardroom plan decisions from a connected client. The desktop shows a
16042
+ * plan-review modal on `plan:approval-required` and answers here; the
16043
+ * server routes the decision into the paused run via resolvePlanApproval.
16044
+ */
16045
+ onPlanDecision(callback) {
16046
+ this.io.on("connection", (socket) => {
16047
+ socket.on("plan:decision", (payload) => {
16048
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
16049
+ callback({
16050
+ sessionId: payload.sessionId,
16051
+ approved: payload.approved,
16052
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
16053
+ editedPlan: payload.editedPlan
16054
+ });
16055
+ }
16056
+ });
16057
+ });
16058
+ }
15977
16059
  onSessionSteer(callback) {
15978
16060
  this.io.on("connection", (socket) => {
15979
16061
  socket.on("session:steer", (payload) => {
@@ -16018,6 +16100,122 @@ var DashboardSocket = class {
16018
16100
 
16019
16101
  // src/dashboard/server.ts
16020
16102
  init_constants();
16103
+ var TaskScheduler = class {
16104
+ cronJobs = /* @__PURE__ */ new Map();
16105
+ store;
16106
+ runner;
16107
+ constructor(store, runner) {
16108
+ this.store = store;
16109
+ this.runner = runner;
16110
+ }
16111
+ start() {
16112
+ const tasks = this.store.listScheduledTasks();
16113
+ for (const task of tasks) {
16114
+ if (task.enabled) this.schedule(task);
16115
+ }
16116
+ }
16117
+ stop() {
16118
+ for (const job of this.cronJobs.values()) job.stop();
16119
+ this.cronJobs.clear();
16120
+ }
16121
+ schedule(task) {
16122
+ if (!cron.validate(task.cronExpression)) {
16123
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
16124
+ }
16125
+ const existing = this.cronJobs.get(task.id);
16126
+ if (existing) {
16127
+ try {
16128
+ existing.stop();
16129
+ } catch {
16130
+ }
16131
+ }
16132
+ const job = cron.schedule(task.cronExpression, async () => {
16133
+ try {
16134
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
16135
+ this.store.saveScheduledTask(task);
16136
+ await this.runner(task);
16137
+ } catch (err) {
16138
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
16139
+ }
16140
+ }, { timezone: "UTC" });
16141
+ this.cronJobs.set(task.id, job);
16142
+ }
16143
+ unschedule(taskId) {
16144
+ this.cronJobs.get(taskId)?.stop();
16145
+ this.cronJobs.delete(taskId);
16146
+ }
16147
+ add(task) {
16148
+ this.store.saveScheduledTask(task);
16149
+ if (task.enabled) this.schedule(task);
16150
+ }
16151
+ remove(taskId) {
16152
+ this.unschedule(taskId);
16153
+ this.store.deleteScheduledTask(taskId);
16154
+ }
16155
+ list() {
16156
+ return this.store.listScheduledTasks();
16157
+ }
16158
+ isRunning(taskId) {
16159
+ return this.cronJobs.has(taskId);
16160
+ }
16161
+ static validateCron(expression) {
16162
+ return cron.validate(expression);
16163
+ }
16164
+ };
16165
+
16166
+ // src/dashboard/cost-stats.ts
16167
+ var DAY_MS = 24 * 60 * 60 * 1e3;
16168
+ function utcDateKey(d) {
16169
+ return d.toISOString().slice(0, 10);
16170
+ }
16171
+ function aggregateCostStats(sessions, opts = {}) {
16172
+ const days = Math.max(1, opts.days ?? 30);
16173
+ const topN = Math.max(1, opts.topN ?? 8);
16174
+ const now = opts.now ?? /* @__PURE__ */ new Date();
16175
+ const buckets = /* @__PURE__ */ new Map();
16176
+ for (let i = days - 1; i >= 0; i--) {
16177
+ const key = utcDateKey(new Date(now.getTime() - i * DAY_MS));
16178
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
16179
+ }
16180
+ let totalCostUsd = 0;
16181
+ let totalTokens = 0;
16182
+ let totalRuns = 0;
16183
+ for (const s of sessions) {
16184
+ const cost = s.metadata?.totalCostUsd ?? 0;
16185
+ const tokens = s.metadata?.totalTokens ?? 0;
16186
+ const runs = s.metadata?.taskCount ?? 0;
16187
+ totalCostUsd += cost;
16188
+ totalTokens += tokens;
16189
+ totalRuns += runs;
16190
+ const when = new Date(s.updatedAt || s.createdAt);
16191
+ if (!Number.isNaN(when.getTime())) {
16192
+ const bucket = buckets.get(utcDateKey(when));
16193
+ if (bucket) {
16194
+ bucket.costUsd += cost;
16195
+ bucket.tokens += tokens;
16196
+ bucket.runs += runs;
16197
+ }
16198
+ }
16199
+ }
16200
+ 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) => ({
16201
+ sessionId: s.id,
16202
+ title: s.title,
16203
+ costUsd: s.metadata?.totalCostUsd ?? 0,
16204
+ tokens: s.metadata?.totalTokens ?? 0,
16205
+ runs: s.metadata?.taskCount ?? 0,
16206
+ updatedAt: s.updatedAt
16207
+ }));
16208
+ return {
16209
+ totalCostUsd,
16210
+ totalTokens,
16211
+ totalSessions: sessions.length,
16212
+ totalRuns,
16213
+ perDay: [...buckets.values()],
16214
+ topSessions
16215
+ };
16216
+ }
16217
+
16218
+ // src/dashboard/server.ts
16021
16219
  var __dirname$1 = path25.dirname(fileURLToPath(import.meta.url));
16022
16220
  var DashboardServer = class {
16023
16221
  app;
@@ -16043,6 +16241,14 @@ var DashboardServer = class {
16043
16241
  * map is how that answer reaches the run that's blocked on it.
16044
16242
  */
16045
16243
  pendingApprovals = /* @__PURE__ */ new Map();
16244
+ /**
16245
+ * The orchestration decision trail ("why") of each session's most recent
16246
+ * run — captured when the run ends so the desktop's Why panel can show it
16247
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
16248
+ */
16249
+ whyBySession = /* @__PURE__ */ new Map();
16250
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
16251
+ scheduler;
16046
16252
  port;
16047
16253
  host;
16048
16254
  workspacePath;
@@ -16060,6 +16266,7 @@ var DashboardServer = class {
16060
16266
  secret: this.dashboardSecret,
16061
16267
  corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
16062
16268
  });
16269
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
16063
16270
  this.setupMiddleware();
16064
16271
  this.setupRoutes();
16065
16272
  this.socket.onSessionRate((sessionId, rating) => {
@@ -16123,6 +16330,9 @@ var DashboardServer = class {
16123
16330
  cascade.on("peer:message", (e) => {
16124
16331
  this.socket.emitPeerMessage(e);
16125
16332
  });
16333
+ cascade.on("plan:approval-required", (e) => {
16334
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
16335
+ });
16126
16336
  try {
16127
16337
  const result = await cascade.run({
16128
16338
  prompt: runPrompt,
@@ -16130,7 +16340,8 @@ var DashboardServer = class {
16130
16340
  approvalCallback: this.makeApprovalCallback(sessionId)
16131
16341
  });
16132
16342
  this.recordSessionTask(sessionId, result.taskId);
16133
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
16343
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
16344
+ this.captureWhy(sessionId, cascade, result);
16134
16345
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
16135
16346
  this.socket.broadcast("cost:update", {
16136
16347
  sessionId,
@@ -16140,6 +16351,7 @@ var DashboardServer = class {
16140
16351
  this.throttledBroadcast("workspace");
16141
16352
  } catch (err) {
16142
16353
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
16354
+ this.captureWhy(sessionId, cascade);
16143
16355
  this.socket.emitToSocket(socketId, "session:error", {
16144
16356
  sessionId,
16145
16357
  error: err instanceof Error ? err.message : String(err)
@@ -16150,9 +16362,17 @@ var DashboardServer = class {
16150
16362
  this.denyPendingApprovals(sessionId);
16151
16363
  }
16152
16364
  });
16365
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
16366
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
16367
+ approved,
16368
+ note,
16369
+ editedPlan
16370
+ );
16371
+ });
16153
16372
  this.socket.onSessionHalt((sessionId) => {
16154
16373
  this.activeControllers.get(sessionId)?.abort();
16155
16374
  this.denyPendingApprovals(sessionId);
16375
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
16156
16376
  });
16157
16377
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
16158
16378
  const pending = this.pendingApprovals.get(requestId);
@@ -16185,12 +16405,21 @@ var DashboardServer = class {
16185
16405
  resolve();
16186
16406
  });
16187
16407
  });
16408
+ try {
16409
+ this.scheduler.start();
16410
+ } catch (err) {
16411
+ console.warn("[dashboard] failed to start task scheduler:", err);
16412
+ }
16188
16413
  }
16189
16414
  async stop() {
16190
16415
  if (this.broadcastTimer) {
16191
16416
  clearTimeout(this.broadcastTimer);
16192
16417
  this.broadcastTimer = null;
16193
16418
  }
16419
+ try {
16420
+ this.scheduler.stop();
16421
+ } catch {
16422
+ }
16194
16423
  this.socket.close();
16195
16424
  try {
16196
16425
  this.globalStore?.close();
@@ -16204,6 +16433,16 @@ var DashboardServer = class {
16204
16433
  getSocket() {
16205
16434
  return this.socket;
16206
16435
  }
16436
+ /**
16437
+ * Rebind the workspace tasks execute in — e.g. the desktop app's Code view
16438
+ * opening a different project folder — without tearing down the socket
16439
+ * server (which would drop the port/auth token/connection mid-session).
16440
+ * The next `cascade:run` picks this up immediately since `this.workspacePath`
16441
+ * is read live per-run (see onCascadeRun below).
16442
+ */
16443
+ setWorkspacePath(workspacePath) {
16444
+ this.workspacePath = workspacePath;
16445
+ }
16207
16446
  /**
16208
16447
  * Write the in-memory config back to the workspace config file so mutations
16209
16448
  * made over the socket (Settings → Save) persist across restarts. Best-effort:
@@ -16301,16 +16540,60 @@ var DashboardServer = class {
16301
16540
  return title;
16302
16541
  }
16303
16542
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
16304
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
16543
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
16305
16544
  try {
16306
16545
  if (reply && reply.trim()) {
16307
16546
  this.store.addMessage({ id: randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
16308
16547
  }
16548
+ if (result) {
16549
+ const session = this.store.getSession(sessionId);
16550
+ if (session) {
16551
+ this.store.updateSession(sessionId, {
16552
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
16553
+ metadata: {
16554
+ ...session.metadata,
16555
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
16556
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
16557
+ taskCount: session.metadata.taskCount + 1
16558
+ }
16559
+ });
16560
+ }
16561
+ }
16309
16562
  } catch (err) {
16310
16563
  console.warn("[dashboard] failed to persist run end:", err);
16311
16564
  }
16312
16565
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
16313
16566
  }
16567
+ /**
16568
+ * Capture the run's decision trail + router economics ("why") and broadcast
16569
+ * it so the desktop's Why panel updates live; kept per-session for the
16570
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
16571
+ */
16572
+ captureWhy(sessionId, cascade, result) {
16573
+ try {
16574
+ const stats = cascade.getRouter().getStats();
16575
+ const savings = cascade.getRouter().getDelegationSavings();
16576
+ const report = {
16577
+ sessionId,
16578
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
16579
+ decisions: cascade.getDecisionLog(),
16580
+ savedUsd: savings.savedUsd,
16581
+ savedPct: savings.savedPct,
16582
+ totalCostUsd: stats.totalCostUsd,
16583
+ totalTokens: stats.totalTokens,
16584
+ costByTier: stats.costByTier,
16585
+ durationMs: result?.durationMs
16586
+ };
16587
+ this.whyBySession.set(sessionId, report);
16588
+ if (this.whyBySession.size > 50) {
16589
+ const oldest = this.whyBySession.keys().next().value;
16590
+ if (oldest) this.whyBySession.delete(oldest);
16591
+ }
16592
+ this.socket.broadcast("run:why", report);
16593
+ } catch (err) {
16594
+ console.warn("[dashboard] failed to capture decision trail:", err);
16595
+ }
16596
+ }
16314
16597
  /**
16315
16598
  * Route steering text into running Cascade instances. Targets the given
16316
16599
  * session, or every active session when none is specified (the desktop
@@ -16340,6 +16623,52 @@ var DashboardServer = class {
16340
16623
  this.pendingApprovals.set(request.id, { resolve, sessionId });
16341
16624
  });
16342
16625
  }
16626
+ /**
16627
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
16628
+ * tool approvals are auto-granted since nobody may be watching when the cron
16629
+ * fires — the Schedules UI states this. Events broadcast to every connected
16630
+ * client so an open desktop sees the run appear live.
16631
+ */
16632
+ async runScheduledTask(task) {
16633
+ const sessionId = randomUUID();
16634
+ const prompt = task.prompt;
16635
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
16636
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
16637
+ this.activeSessions.set(sessionId, cascade);
16638
+ cascade.on("tier:status", (e) => {
16639
+ this.socket.broadcast("tier:status", { sessionId, ...e });
16640
+ });
16641
+ cascade.on("peer:message", (e) => {
16642
+ this.socket.emitPeerMessage(e);
16643
+ });
16644
+ try {
16645
+ const result = await cascade.run({
16646
+ prompt,
16647
+ identityId: task.identityId,
16648
+ approvalCallback: async () => ({ approved: true, always: false })
16649
+ });
16650
+ this.recordSessionTask(sessionId, result.taskId);
16651
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
16652
+ this.captureWhy(sessionId, cascade, result);
16653
+ this.socket.broadcast("session:complete", { sessionId, result });
16654
+ this.socket.broadcast("cost:update", {
16655
+ sessionId,
16656
+ totalTokens: result.usage.totalTokens,
16657
+ totalCostUsd: result.usage.estimatedCostUsd
16658
+ });
16659
+ this.throttledBroadcast("workspace");
16660
+ } catch (err) {
16661
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
16662
+ this.captureWhy(sessionId, cascade);
16663
+ this.socket.broadcast("session:error", {
16664
+ sessionId,
16665
+ error: err instanceof Error ? err.message : String(err)
16666
+ });
16667
+ throw err;
16668
+ } finally {
16669
+ this.activeSessions.delete(sessionId);
16670
+ }
16671
+ }
16343
16672
  /** Deny + clear any approvals still pending for a session (run end / abort). */
16344
16673
  denyPendingApprovals(sessionId) {
16345
16674
  for (const [id, pending] of this.pendingApprovals) {
@@ -16852,6 +17181,9 @@ ${prompt}`;
16852
17181
  cascade.on("peer:message", (e) => {
16853
17182
  this.socket.emitPeerMessage(e);
16854
17183
  });
17184
+ cascade.on("plan:approval-required", (e) => {
17185
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
17186
+ });
16855
17187
  try {
16856
17188
  const result = await cascade.run({
16857
17189
  prompt: runPrompt,
@@ -16859,7 +17191,8 @@ ${prompt}`;
16859
17191
  approvalCallback: this.makeApprovalCallback(sessionId)
16860
17192
  });
16861
17193
  this.recordSessionTask(sessionId, result.taskId);
16862
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
17194
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
17195
+ this.captureWhy(sessionId, cascade, result);
16863
17196
  this.socket.broadcast("cost:update", {
16864
17197
  sessionId,
16865
17198
  totalTokens: result.usage.totalTokens,
@@ -16869,6 +17202,7 @@ ${prompt}`;
16869
17202
  this.throttledBroadcast("workspace");
16870
17203
  } catch (err) {
16871
17204
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
17205
+ this.captureWhy(sessionId, cascade);
16872
17206
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
16873
17207
  sessionId,
16874
17208
  error: err instanceof Error ? err.message : String(err)
@@ -16890,6 +17224,168 @@ ${prompt}`;
16890
17224
  }))
16891
17225
  });
16892
17226
  });
17227
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
17228
+ const report = this.whyBySession.get(req.params.id);
17229
+ if (!report) {
17230
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
17231
+ return;
17232
+ }
17233
+ res.json(report);
17234
+ });
17235
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
17236
+ const sessionId = req.params.id;
17237
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
17238
+ const before = /* @__PURE__ */ new Map();
17239
+ for (const taskId of taskIds) {
17240
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
17241
+ if (!before.has(filePath)) before.set(filePath, content);
17242
+ }
17243
+ }
17244
+ const { readFile } = await import('fs/promises');
17245
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
17246
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
17247
+ let after = "";
17248
+ let missing = false;
17249
+ try {
17250
+ const buf = await readFile(filePath);
17251
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
17252
+ } catch {
17253
+ missing = true;
17254
+ }
17255
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
17256
+ }));
17257
+ res.json({ sessionId, changes: changes.filter((c) => c.changed) });
17258
+ });
17259
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
17260
+ const sessionId = req.params.id;
17261
+ const body = req.body;
17262
+ if (!body.filePath || typeof body.filePath !== "string") {
17263
+ res.status(400).json({ error: "filePath is required" });
17264
+ return;
17265
+ }
17266
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
17267
+ let content;
17268
+ for (const taskId of taskIds) {
17269
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s) => s.filePath === body.filePath);
17270
+ if (snap) {
17271
+ content = snap.content;
17272
+ break;
17273
+ }
17274
+ }
17275
+ if (content === void 0) {
17276
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
17277
+ return;
17278
+ }
17279
+ try {
17280
+ const { writeFile } = await import('fs/promises');
17281
+ await writeFile(body.filePath, content, "utf-8");
17282
+ res.json({ ok: true, filePath: body.filePath });
17283
+ } catch (err) {
17284
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17285
+ }
17286
+ });
17287
+ this.app.get("/api/costs", auth, (_req, res) => {
17288
+ try {
17289
+ const sessions = this.store.listSessions(void 0, 1e3);
17290
+ const budget = this.config.budget ?? { warnAtPct: 80 };
17291
+ res.json({
17292
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
17293
+ budget: {
17294
+ dailyBudgetUsd: budget.dailyBudgetUsd,
17295
+ sessionBudgetUsd: budget.sessionBudgetUsd,
17296
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
17297
+ }
17298
+ });
17299
+ } catch (err) {
17300
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17301
+ }
17302
+ });
17303
+ this.app.get("/api/schedules", auth, (_req, res) => {
17304
+ try {
17305
+ res.json(this.scheduler.list().map((t) => ({ ...t, armed: this.scheduler.isRunning(t.id) })));
17306
+ } catch (err) {
17307
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17308
+ }
17309
+ });
17310
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
17311
+ const body = req.body;
17312
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
17313
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
17314
+ return;
17315
+ }
17316
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
17317
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
17318
+ return;
17319
+ }
17320
+ const task = {
17321
+ id: randomUUID(),
17322
+ name: body.name.trim(),
17323
+ cronExpression: body.cronExpression.trim(),
17324
+ prompt: body.prompt.trim(),
17325
+ workspacePath: this.workspacePath,
17326
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
17327
+ enabled: body.enabled !== false
17328
+ };
17329
+ try {
17330
+ this.scheduler.add(task);
17331
+ res.json(task);
17332
+ } catch (err) {
17333
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17334
+ }
17335
+ });
17336
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
17337
+ const id = req.params.id;
17338
+ const existing = this.scheduler.list().find((t) => t.id === id);
17339
+ if (!existing) {
17340
+ res.status(404).json({ error: "Not found" });
17341
+ return;
17342
+ }
17343
+ const body = req.body;
17344
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
17345
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
17346
+ return;
17347
+ }
17348
+ const updated = {
17349
+ ...existing,
17350
+ name: body.name?.trim() || existing.name,
17351
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
17352
+ prompt: body.prompt?.trim() || existing.prompt,
17353
+ enabled: body.enabled ?? existing.enabled
17354
+ };
17355
+ try {
17356
+ this.scheduler.add(updated);
17357
+ if (!updated.enabled) this.scheduler.unschedule(id);
17358
+ res.json(updated);
17359
+ } catch (err) {
17360
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17361
+ }
17362
+ });
17363
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
17364
+ try {
17365
+ this.scheduler.remove(req.params.id);
17366
+ res.json({ ok: true });
17367
+ } catch (err) {
17368
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17369
+ }
17370
+ });
17371
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
17372
+ try {
17373
+ const limit = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
17374
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
17375
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
17376
+ const logger = new AuditLogger3(this.workspacePath);
17377
+ try {
17378
+ const all = logger.getAllLogs();
17379
+ const total = all.length;
17380
+ const entries = all.reverse().slice(offset, offset + limit);
17381
+ res.json({ total, offset, entries });
17382
+ } finally {
17383
+ logger.close();
17384
+ }
17385
+ } catch (err) {
17386
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
17387
+ }
17388
+ });
16893
17389
  const prodPath = path25.resolve(__dirname$1, "../web/dist");
16894
17390
  const devPath = path25.resolve(__dirname$1, "../../web/dist");
16895
17391
  const webDistPath = fs23.existsSync(prodPath) ? prodPath : devPath;
@@ -17507,7 +18003,7 @@ async function startRepl(options) {
17507
18003
  process.exit(1);
17508
18004
  }
17509
18005
  let config = cm.getConfig();
17510
- const needsSetup = !config.providers?.length || config.providers.every((p) => p.type !== "ollama" && !p.apiKey);
18006
+ const needsSetup = !hasUsableProvider(config.providers);
17511
18007
  if (needsSetup) {
17512
18008
  console.log(chalk9.magenta(" \u25C8 No providers configured \u2014 launching setup wizard\u2026"));
17513
18009
  console.log();
@@ -17548,7 +18044,7 @@ async function runHeadless(prompt, options) {
17548
18044
  process.exit(1);
17549
18045
  }
17550
18046
  const config = cm.getConfig();
17551
- const needsSetup = !config.providers?.length || config.providers.every((p) => p.type !== "ollama" && !p.apiKey);
18047
+ const needsSetup = !hasUsableProvider(config.providers);
17552
18048
  if (needsSetup) {
17553
18049
  console.error(chalk9.red("No providers configured. Run `cascade init` first."));
17554
18050
  process.exit(1);