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.
@@ -222595,7 +222595,7 @@ Anthropic.Models = Models2;
222595
222595
  Anthropic.Beta = Beta;
222596
222596
 
222597
222597
  // src/constants.ts
222598
- var CASCADE_VERSION = "0.15.2";
222598
+ var CASCADE_VERSION = "0.17.0";
222599
222599
  var CASCADE_CONFIG_DIR = ".cascade";
222600
222600
  var CASCADE_MD_FILE = "CASCADE.md";
222601
222601
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -253816,6 +253816,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
253816
253816
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
253817
253817
  if (this.store) worker.setStore(this.store, taskId);
253818
253818
  worker.setPeerBus(this.t3PeerBus);
253819
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
253820
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
253821
+ worker.on("log", (e3) => this.emit("log", e3));
253822
+ worker.on("tier:status", (e3) => this.emit("tier:status", e3));
253819
253823
  worker.on("stream:token", (e3) => this.emit("stream:token", e3));
253820
253824
  worker.on("tool:approval-request", (e3) => this.emit("tool:approval-request", {
253821
253825
  ...e3,
@@ -254164,12 +254168,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
254164
254168
  status: "IN_PROGRESS"
254165
254169
  });
254166
254170
  const okBefore = okCount(allT2Results);
254171
+ const completedSummary = this.summarizeCompletedSections(allT2Results);
254172
+ const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
254167
254173
  const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
254168
254174
  Review reason: ${reviewResult.reason}
254169
254175
 
254170
254176
  Original goal: ${enrichedPrompt}
254171
254177
 
254172
- Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`);
254178
+ Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
254173
254179
  const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
254174
254180
  allT2Results = [...allT2Results, ...correctionResults];
254175
254181
  if (okCount(allT2Results) <= okBefore) {
@@ -254272,6 +254278,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
254272
254278
  return null;
254273
254279
  }
254274
254280
  }
254281
+ /** Structured, grounded summary of what's already done — used to keep
254282
+ * corrective replan passes from re-emitting completed sections. */
254283
+ summarizeCompletedSections(results) {
254284
+ const done = results.filter((r4) => r4.status === "COMPLETED" || r4.status === "PARTIAL");
254285
+ if (done.length === 0) return "";
254286
+ const lines = done.map((r4) => `- "${r4.sectionTitle}" [${r4.status}]: ${(r4.sectionSummary || "(no summary)").slice(0, 300)}`);
254287
+ return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
254288
+ ${lines.join("\n")}`;
254289
+ }
254275
254290
  async decomposeTask(prompt, systemContext) {
254276
254291
  const db = this.router.getWorldStateDB?.();
254277
254292
  let worldStateContext = "";
@@ -254539,7 +254554,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
254539
254554
  }
254540
254555
  }
254541
254556
  if (readyIds.length === 0) return;
254542
- await Promise.all(readyIds.map(async (id) => {
254557
+ const runOne = async (id) => {
254543
254558
  resultMap.set(id, null);
254544
254559
  const index = sections.findIndex((s3) => s3.sectionId === id);
254545
254560
  const section = sections[index];
@@ -254579,7 +254594,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
254579
254594
  for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
254580
254595
  inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
254581
254596
  }
254582
- }));
254597
+ };
254598
+ if (this.router.getT3ExecutionMode?.() === "sequential") {
254599
+ for (const id of readyIds) {
254600
+ await runOne(id);
254601
+ }
254602
+ } else {
254603
+ await Promise.all(readyIds.map(runOne));
254604
+ }
254583
254605
  if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
254584
254606
  await executeWave();
254585
254607
  }
@@ -294549,6 +294571,13 @@ var PermissionEscalator = class extends EventEmitter__default.default {
294549
294571
  * All T3 workers under the same T2 share cached decisions for the same tool.
294550
294572
  */
294551
294573
  sessionCache = /* @__PURE__ */ new Map();
294574
+ /**
294575
+ * Task-wide cache keyed by `toolName` alone, for USER- and T1-level
294576
+ * "always" decisions — these are meant to cover every sibling T2/T3 in the
294577
+ * run, not just the one that happened to ask first (see PermissionDecision
294578
+ * doc comment: "task-wide for T1").
294579
+ */
294580
+ taskWideCache = /* @__PURE__ */ new Map();
294552
294581
  t2Evaluator;
294553
294582
  t1Evaluator;
294554
294583
  /** Pending user-decision resolvers keyed by request ID */
@@ -294577,6 +294606,15 @@ var PermissionEscalator = class extends EventEmitter__default.default {
294577
294606
  * Returns a PermissionDecision from whichever tier was able to decide.
294578
294607
  */
294579
294608
  async requestPermission(req) {
294609
+ if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
294610
+ return {
294611
+ requestId: req.id,
294612
+ approved: this.taskWideCache.get(req.toolName),
294613
+ always: true,
294614
+ decidedBy: "T1",
294615
+ reasoning: "Cached from a previous task-wide decision in this session"
294616
+ };
294617
+ }
294580
294618
  const cacheKey = `${req.parentT2Id}:${req.toolName}`;
294581
294619
  if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
294582
294620
  return {
@@ -294621,7 +294659,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
294621
294659
  try {
294622
294660
  const t1Decision = await this.t1Evaluator(req);
294623
294661
  if (t1Decision !== null) {
294624
- if (t1Decision.always) this.sessionCache.set(cacheKey, t1Decision.approved);
294662
+ if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
294625
294663
  return t1Decision;
294626
294664
  }
294627
294665
  } catch {
@@ -294651,7 +294689,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
294651
294689
  const wrappedResolver = (decision) => {
294652
294690
  if (timer) clearTimeout(timer);
294653
294691
  if (decision.always) {
294654
- this.sessionCache.set(`${req.parentT2Id}:${req.toolName}`, decision.approved);
294692
+ this.taskWideCache.set(req.toolName, decision.approved);
294655
294693
  }
294656
294694
  resolve(decision);
294657
294695
  };
@@ -299132,6 +299170,25 @@ var DashboardSocket = class {
299132
299170
  });
299133
299171
  });
299134
299172
  }
299173
+ /**
299174
+ * Boardroom plan decisions from a connected client. The desktop shows a
299175
+ * plan-review modal on `plan:approval-required` and answers here; the
299176
+ * server routes the decision into the paused run via resolvePlanApproval.
299177
+ */
299178
+ onPlanDecision(callback) {
299179
+ this.io.on("connection", (socket) => {
299180
+ socket.on("plan:decision", (payload) => {
299181
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
299182
+ callback({
299183
+ sessionId: payload.sessionId,
299184
+ approved: payload.approved,
299185
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
299186
+ editedPlan: payload.editedPlan
299187
+ });
299188
+ }
299189
+ });
299190
+ });
299191
+ }
299135
299192
  onSessionSteer(callback) {
299136
299193
  this.io.on("connection", (socket) => {
299137
299194
  socket.on("session:steer", (payload) => {
@@ -299173,6 +299230,125 @@ var DashboardSocket = class {
299173
299230
  this.io.close();
299174
299231
  }
299175
299232
  };
299233
+
299234
+ // src/scheduler/index.ts
299235
+ var import_node_cron = __toESM(require_node_cron(), 1);
299236
+ var TaskScheduler = class {
299237
+ cronJobs = /* @__PURE__ */ new Map();
299238
+ store;
299239
+ runner;
299240
+ constructor(store, runner) {
299241
+ this.store = store;
299242
+ this.runner = runner;
299243
+ }
299244
+ start() {
299245
+ const tasks = this.store.listScheduledTasks();
299246
+ for (const task of tasks) {
299247
+ if (task.enabled) this.schedule(task);
299248
+ }
299249
+ }
299250
+ stop() {
299251
+ for (const job of this.cronJobs.values()) job.stop();
299252
+ this.cronJobs.clear();
299253
+ }
299254
+ schedule(task) {
299255
+ if (!import_node_cron.default.validate(task.cronExpression)) {
299256
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
299257
+ }
299258
+ const existing = this.cronJobs.get(task.id);
299259
+ if (existing) {
299260
+ try {
299261
+ existing.stop();
299262
+ } catch {
299263
+ }
299264
+ }
299265
+ const job = import_node_cron.default.schedule(task.cronExpression, async () => {
299266
+ try {
299267
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
299268
+ this.store.saveScheduledTask(task);
299269
+ await this.runner(task);
299270
+ } catch (err) {
299271
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
299272
+ }
299273
+ }, { timezone: "UTC" });
299274
+ this.cronJobs.set(task.id, job);
299275
+ }
299276
+ unschedule(taskId) {
299277
+ this.cronJobs.get(taskId)?.stop();
299278
+ this.cronJobs.delete(taskId);
299279
+ }
299280
+ add(task) {
299281
+ this.store.saveScheduledTask(task);
299282
+ if (task.enabled) this.schedule(task);
299283
+ }
299284
+ remove(taskId) {
299285
+ this.unschedule(taskId);
299286
+ this.store.deleteScheduledTask(taskId);
299287
+ }
299288
+ list() {
299289
+ return this.store.listScheduledTasks();
299290
+ }
299291
+ isRunning(taskId) {
299292
+ return this.cronJobs.has(taskId);
299293
+ }
299294
+ static validateCron(expression) {
299295
+ return import_node_cron.default.validate(expression);
299296
+ }
299297
+ };
299298
+
299299
+ // src/dashboard/cost-stats.ts
299300
+ var DAY_MS = 24 * 60 * 60 * 1e3;
299301
+ function utcDateKey(d2) {
299302
+ return d2.toISOString().slice(0, 10);
299303
+ }
299304
+ function aggregateCostStats(sessions, opts = {}) {
299305
+ const days = Math.max(1, opts.days ?? 30);
299306
+ const topN = Math.max(1, opts.topN ?? 8);
299307
+ const now = opts.now ?? /* @__PURE__ */ new Date();
299308
+ const buckets = /* @__PURE__ */ new Map();
299309
+ for (let i4 = days - 1; i4 >= 0; i4--) {
299310
+ const key = utcDateKey(new Date(now.getTime() - i4 * DAY_MS));
299311
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
299312
+ }
299313
+ let totalCostUsd = 0;
299314
+ let totalTokens = 0;
299315
+ let totalRuns = 0;
299316
+ for (const s3 of sessions) {
299317
+ const cost = s3.metadata?.totalCostUsd ?? 0;
299318
+ const tokens = s3.metadata?.totalTokens ?? 0;
299319
+ const runs = s3.metadata?.taskCount ?? 0;
299320
+ totalCostUsd += cost;
299321
+ totalTokens += tokens;
299322
+ totalRuns += runs;
299323
+ const when = new Date(s3.updatedAt || s3.createdAt);
299324
+ if (!Number.isNaN(when.getTime())) {
299325
+ const bucket = buckets.get(utcDateKey(when));
299326
+ if (bucket) {
299327
+ bucket.costUsd += cost;
299328
+ bucket.tokens += tokens;
299329
+ bucket.runs += runs;
299330
+ }
299331
+ }
299332
+ }
299333
+ const topSessions = [...sessions].filter((s3) => (s3.metadata?.totalCostUsd ?? 0) > 0).sort((a2, b3) => (b3.metadata?.totalCostUsd ?? 0) - (a2.metadata?.totalCostUsd ?? 0)).slice(0, topN).map((s3) => ({
299334
+ sessionId: s3.id,
299335
+ title: s3.title,
299336
+ costUsd: s3.metadata?.totalCostUsd ?? 0,
299337
+ tokens: s3.metadata?.totalTokens ?? 0,
299338
+ runs: s3.metadata?.taskCount ?? 0,
299339
+ updatedAt: s3.updatedAt
299340
+ }));
299341
+ return {
299342
+ totalCostUsd,
299343
+ totalTokens,
299344
+ totalSessions: sessions.length,
299345
+ totalRuns,
299346
+ perDay: [...buckets.values()],
299347
+ topSessions
299348
+ };
299349
+ }
299350
+
299351
+ // src/dashboard/server.ts
299176
299352
  var __dirname2 = path22__namespace.default.dirname(Url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('desktop-core.cjs', document.baseURI).href))));
299177
299353
  var DashboardServer = class {
299178
299354
  app;
@@ -299198,6 +299374,14 @@ var DashboardServer = class {
299198
299374
  * map is how that answer reaches the run that's blocked on it.
299199
299375
  */
299200
299376
  pendingApprovals = /* @__PURE__ */ new Map();
299377
+ /**
299378
+ * The orchestration decision trail ("why") of each session's most recent
299379
+ * run — captured when the run ends so the desktop's Why panel can show it
299380
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
299381
+ */
299382
+ whyBySession = /* @__PURE__ */ new Map();
299383
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
299384
+ scheduler;
299201
299385
  port;
299202
299386
  host;
299203
299387
  workspacePath;
@@ -299215,6 +299399,7 @@ var DashboardServer = class {
299215
299399
  secret: this.dashboardSecret,
299216
299400
  corsOrigin: config2.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
299217
299401
  });
299402
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
299218
299403
  this.setupMiddleware();
299219
299404
  this.setupRoutes();
299220
299405
  this.socket.onSessionRate((sessionId, rating) => {
@@ -299278,6 +299463,9 @@ var DashboardServer = class {
299278
299463
  cascade.on("peer:message", (e3) => {
299279
299464
  this.socket.emitPeerMessage(e3);
299280
299465
  });
299466
+ cascade.on("plan:approval-required", (e3) => {
299467
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e3 });
299468
+ });
299281
299469
  try {
299282
299470
  const result = await cascade.run({
299283
299471
  prompt: runPrompt,
@@ -299285,7 +299473,8 @@ var DashboardServer = class {
299285
299473
  approvalCallback: this.makeApprovalCallback(sessionId)
299286
299474
  });
299287
299475
  this.recordSessionTask(sessionId, result.taskId);
299288
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
299476
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
299477
+ this.captureWhy(sessionId, cascade, result);
299289
299478
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
299290
299479
  this.socket.broadcast("cost:update", {
299291
299480
  sessionId,
@@ -299295,6 +299484,7 @@ var DashboardServer = class {
299295
299484
  this.throttledBroadcast("workspace");
299296
299485
  } catch (err) {
299297
299486
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
299487
+ this.captureWhy(sessionId, cascade);
299298
299488
  this.socket.emitToSocket(socketId, "session:error", {
299299
299489
  sessionId,
299300
299490
  error: err instanceof Error ? err.message : String(err)
@@ -299305,9 +299495,17 @@ var DashboardServer = class {
299305
299495
  this.denyPendingApprovals(sessionId);
299306
299496
  }
299307
299497
  });
299498
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
299499
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
299500
+ approved,
299501
+ note,
299502
+ editedPlan
299503
+ );
299504
+ });
299308
299505
  this.socket.onSessionHalt((sessionId) => {
299309
299506
  this.activeControllers.get(sessionId)?.abort();
299310
299507
  this.denyPendingApprovals(sessionId);
299508
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
299311
299509
  });
299312
299510
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
299313
299511
  const pending = this.pendingApprovals.get(requestId);
@@ -299340,12 +299538,21 @@ var DashboardServer = class {
299340
299538
  resolve();
299341
299539
  });
299342
299540
  });
299541
+ try {
299542
+ this.scheduler.start();
299543
+ } catch (err) {
299544
+ console.warn("[dashboard] failed to start task scheduler:", err);
299545
+ }
299343
299546
  }
299344
299547
  async stop() {
299345
299548
  if (this.broadcastTimer) {
299346
299549
  clearTimeout(this.broadcastTimer);
299347
299550
  this.broadcastTimer = null;
299348
299551
  }
299552
+ try {
299553
+ this.scheduler.stop();
299554
+ } catch {
299555
+ }
299349
299556
  this.socket.close();
299350
299557
  try {
299351
299558
  this.globalStore?.close();
@@ -299359,6 +299566,16 @@ var DashboardServer = class {
299359
299566
  getSocket() {
299360
299567
  return this.socket;
299361
299568
  }
299569
+ /**
299570
+ * Rebind the workspace tasks execute in — e.g. the desktop app's Code view
299571
+ * opening a different project folder — without tearing down the socket
299572
+ * server (which would drop the port/auth token/connection mid-session).
299573
+ * The next `cascade:run` picks this up immediately since `this.workspacePath`
299574
+ * is read live per-run (see onCascadeRun below).
299575
+ */
299576
+ setWorkspacePath(workspacePath) {
299577
+ this.workspacePath = workspacePath;
299578
+ }
299362
299579
  /**
299363
299580
  * Write the in-memory config back to the workspace config file so mutations
299364
299581
  * made over the socket (Settings → Save) persist across restarts. Best-effort:
@@ -299456,16 +299673,60 @@ var DashboardServer = class {
299456
299673
  return title;
299457
299674
  }
299458
299675
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
299459
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
299676
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
299460
299677
  try {
299461
299678
  if (reply && reply.trim()) {
299462
299679
  this.store.addMessage({ id: crypto4.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
299463
299680
  }
299681
+ if (result) {
299682
+ const session = this.store.getSession(sessionId);
299683
+ if (session) {
299684
+ this.store.updateSession(sessionId, {
299685
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
299686
+ metadata: {
299687
+ ...session.metadata,
299688
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
299689
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
299690
+ taskCount: session.metadata.taskCount + 1
299691
+ }
299692
+ });
299693
+ }
299694
+ }
299464
299695
  } catch (err) {
299465
299696
  console.warn("[dashboard] failed to persist run end:", err);
299466
299697
  }
299467
299698
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
299468
299699
  }
299700
+ /**
299701
+ * Capture the run's decision trail + router economics ("why") and broadcast
299702
+ * it so the desktop's Why panel updates live; kept per-session for the
299703
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
299704
+ */
299705
+ captureWhy(sessionId, cascade, result) {
299706
+ try {
299707
+ const stats = cascade.getRouter().getStats();
299708
+ const savings = cascade.getRouter().getDelegationSavings();
299709
+ const report = {
299710
+ sessionId,
299711
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
299712
+ decisions: cascade.getDecisionLog(),
299713
+ savedUsd: savings.savedUsd,
299714
+ savedPct: savings.savedPct,
299715
+ totalCostUsd: stats.totalCostUsd,
299716
+ totalTokens: stats.totalTokens,
299717
+ costByTier: stats.costByTier,
299718
+ durationMs: result?.durationMs
299719
+ };
299720
+ this.whyBySession.set(sessionId, report);
299721
+ if (this.whyBySession.size > 50) {
299722
+ const oldest = this.whyBySession.keys().next().value;
299723
+ if (oldest) this.whyBySession.delete(oldest);
299724
+ }
299725
+ this.socket.broadcast("run:why", report);
299726
+ } catch (err) {
299727
+ console.warn("[dashboard] failed to capture decision trail:", err);
299728
+ }
299729
+ }
299469
299730
  /**
299470
299731
  * Route steering text into running Cascade instances. Targets the given
299471
299732
  * session, or every active session when none is specified (the desktop
@@ -299495,6 +299756,52 @@ var DashboardServer = class {
299495
299756
  this.pendingApprovals.set(request.id, { resolve, sessionId });
299496
299757
  });
299497
299758
  }
299759
+ /**
299760
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
299761
+ * tool approvals are auto-granted since nobody may be watching when the cron
299762
+ * fires — the Schedules UI states this. Events broadcast to every connected
299763
+ * client so an open desktop sees the run appear live.
299764
+ */
299765
+ async runScheduledTask(task) {
299766
+ const sessionId = crypto4.randomUUID();
299767
+ const prompt = task.prompt;
299768
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
299769
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
299770
+ this.activeSessions.set(sessionId, cascade);
299771
+ cascade.on("tier:status", (e3) => {
299772
+ this.socket.broadcast("tier:status", { sessionId, ...e3 });
299773
+ });
299774
+ cascade.on("peer:message", (e3) => {
299775
+ this.socket.emitPeerMessage(e3);
299776
+ });
299777
+ try {
299778
+ const result = await cascade.run({
299779
+ prompt,
299780
+ identityId: task.identityId,
299781
+ approvalCallback: async () => ({ approved: true, always: false })
299782
+ });
299783
+ this.recordSessionTask(sessionId, result.taskId);
299784
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
299785
+ this.captureWhy(sessionId, cascade, result);
299786
+ this.socket.broadcast("session:complete", { sessionId, result });
299787
+ this.socket.broadcast("cost:update", {
299788
+ sessionId,
299789
+ totalTokens: result.usage.totalTokens,
299790
+ totalCostUsd: result.usage.estimatedCostUsd
299791
+ });
299792
+ this.throttledBroadcast("workspace");
299793
+ } catch (err) {
299794
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
299795
+ this.captureWhy(sessionId, cascade);
299796
+ this.socket.broadcast("session:error", {
299797
+ sessionId,
299798
+ error: err instanceof Error ? err.message : String(err)
299799
+ });
299800
+ throw err;
299801
+ } finally {
299802
+ this.activeSessions.delete(sessionId);
299803
+ }
299804
+ }
299498
299805
  /** Deny + clear any approvals still pending for a session (run end / abort). */
299499
299806
  denyPendingApprovals(sessionId) {
299500
299807
  for (const [id, pending] of this.pendingApprovals) {
@@ -300007,6 +300314,9 @@ ${prompt}`;
300007
300314
  cascade.on("peer:message", (e3) => {
300008
300315
  this.socket.emitPeerMessage(e3);
300009
300316
  });
300317
+ cascade.on("plan:approval-required", (e3) => {
300318
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e3 });
300319
+ });
300010
300320
  try {
300011
300321
  const result = await cascade.run({
300012
300322
  prompt: runPrompt,
@@ -300014,7 +300324,8 @@ ${prompt}`;
300014
300324
  approvalCallback: this.makeApprovalCallback(sessionId)
300015
300325
  });
300016
300326
  this.recordSessionTask(sessionId, result.taskId);
300017
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
300327
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
300328
+ this.captureWhy(sessionId, cascade, result);
300018
300329
  this.socket.broadcast("cost:update", {
300019
300330
  sessionId,
300020
300331
  totalTokens: result.usage.totalTokens,
@@ -300024,6 +300335,7 @@ ${prompt}`;
300024
300335
  this.throttledBroadcast("workspace");
300025
300336
  } catch (err) {
300026
300337
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
300338
+ this.captureWhy(sessionId, cascade);
300027
300339
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
300028
300340
  sessionId,
300029
300341
  error: err instanceof Error ? err.message : String(err)
@@ -300045,6 +300357,168 @@ ${prompt}`;
300045
300357
  }))
300046
300358
  });
300047
300359
  });
300360
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
300361
+ const report = this.whyBySession.get(req.params.id);
300362
+ if (!report) {
300363
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
300364
+ return;
300365
+ }
300366
+ res.json(report);
300367
+ });
300368
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
300369
+ const sessionId = req.params.id;
300370
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
300371
+ const before = /* @__PURE__ */ new Map();
300372
+ for (const taskId of taskIds) {
300373
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
300374
+ if (!before.has(filePath)) before.set(filePath, content);
300375
+ }
300376
+ }
300377
+ const { readFile } = await import('fs/promises');
300378
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
300379
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
300380
+ let after = "";
300381
+ let missing = false;
300382
+ try {
300383
+ const buf = await readFile(filePath);
300384
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
300385
+ } catch {
300386
+ missing = true;
300387
+ }
300388
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
300389
+ }));
300390
+ res.json({ sessionId, changes: changes.filter((c4) => c4.changed) });
300391
+ });
300392
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
300393
+ const sessionId = req.params.id;
300394
+ const body = req.body;
300395
+ if (!body.filePath || typeof body.filePath !== "string") {
300396
+ res.status(400).json({ error: "filePath is required" });
300397
+ return;
300398
+ }
300399
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
300400
+ let content;
300401
+ for (const taskId of taskIds) {
300402
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s3) => s3.filePath === body.filePath);
300403
+ if (snap) {
300404
+ content = snap.content;
300405
+ break;
300406
+ }
300407
+ }
300408
+ if (content === void 0) {
300409
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
300410
+ return;
300411
+ }
300412
+ try {
300413
+ const { writeFile: writeFile2 } = await import('fs/promises');
300414
+ await writeFile2(body.filePath, content, "utf-8");
300415
+ res.json({ ok: true, filePath: body.filePath });
300416
+ } catch (err) {
300417
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300418
+ }
300419
+ });
300420
+ this.app.get("/api/costs", auth, (_req, res) => {
300421
+ try {
300422
+ const sessions = this.store.listSessions(void 0, 1e3);
300423
+ const budget = this.config.budget ?? { warnAtPct: 80 };
300424
+ res.json({
300425
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
300426
+ budget: {
300427
+ dailyBudgetUsd: budget.dailyBudgetUsd,
300428
+ sessionBudgetUsd: budget.sessionBudgetUsd,
300429
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
300430
+ }
300431
+ });
300432
+ } catch (err) {
300433
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300434
+ }
300435
+ });
300436
+ this.app.get("/api/schedules", auth, (_req, res) => {
300437
+ try {
300438
+ res.json(this.scheduler.list().map((t4) => ({ ...t4, armed: this.scheduler.isRunning(t4.id) })));
300439
+ } catch (err) {
300440
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300441
+ }
300442
+ });
300443
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
300444
+ const body = req.body;
300445
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
300446
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
300447
+ return;
300448
+ }
300449
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
300450
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
300451
+ return;
300452
+ }
300453
+ const task = {
300454
+ id: crypto4.randomUUID(),
300455
+ name: body.name.trim(),
300456
+ cronExpression: body.cronExpression.trim(),
300457
+ prompt: body.prompt.trim(),
300458
+ workspacePath: this.workspacePath,
300459
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
300460
+ enabled: body.enabled !== false
300461
+ };
300462
+ try {
300463
+ this.scheduler.add(task);
300464
+ res.json(task);
300465
+ } catch (err) {
300466
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300467
+ }
300468
+ });
300469
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
300470
+ const id = req.params.id;
300471
+ const existing = this.scheduler.list().find((t4) => t4.id === id);
300472
+ if (!existing) {
300473
+ res.status(404).json({ error: "Not found" });
300474
+ return;
300475
+ }
300476
+ const body = req.body;
300477
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
300478
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
300479
+ return;
300480
+ }
300481
+ const updated = {
300482
+ ...existing,
300483
+ name: body.name?.trim() || existing.name,
300484
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
300485
+ prompt: body.prompt?.trim() || existing.prompt,
300486
+ enabled: body.enabled ?? existing.enabled
300487
+ };
300488
+ try {
300489
+ this.scheduler.add(updated);
300490
+ if (!updated.enabled) this.scheduler.unschedule(id);
300491
+ res.json(updated);
300492
+ } catch (err) {
300493
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300494
+ }
300495
+ });
300496
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
300497
+ try {
300498
+ this.scheduler.remove(req.params.id);
300499
+ res.json({ ok: true });
300500
+ } catch (err) {
300501
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300502
+ }
300503
+ });
300504
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
300505
+ try {
300506
+ const limit2 = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
300507
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
300508
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
300509
+ const logger = new AuditLogger3(this.workspacePath);
300510
+ try {
300511
+ const all = logger.getAllLogs();
300512
+ const total = all.length;
300513
+ const entries = all.reverse().slice(offset, offset + limit2);
300514
+ res.json({ total, offset, entries });
300515
+ } finally {
300516
+ logger.close();
300517
+ }
300518
+ } catch (err) {
300519
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
300520
+ }
300521
+ });
300048
300522
  const prodPath = path22__namespace.default.resolve(__dirname2, "../web/dist");
300049
300523
  const devPath = path22__namespace.default.resolve(__dirname2, "../../web/dist");
300050
300524
  const webDistPath = fs11__namespace.default.existsSync(prodPath) ? prodPath : devPath;
@@ -300066,71 +300540,6 @@ ${prompt}`;
300066
300540
  }
300067
300541
  }
300068
300542
  };
300069
-
300070
- // src/scheduler/index.ts
300071
- var import_node_cron = __toESM(require_node_cron(), 1);
300072
- var TaskScheduler = class {
300073
- cronJobs = /* @__PURE__ */ new Map();
300074
- store;
300075
- runner;
300076
- constructor(store, runner) {
300077
- this.store = store;
300078
- this.runner = runner;
300079
- }
300080
- start() {
300081
- const tasks = this.store.listScheduledTasks();
300082
- for (const task of tasks) {
300083
- if (task.enabled) this.schedule(task);
300084
- }
300085
- }
300086
- stop() {
300087
- for (const job of this.cronJobs.values()) job.stop();
300088
- this.cronJobs.clear();
300089
- }
300090
- schedule(task) {
300091
- if (!import_node_cron.default.validate(task.cronExpression)) {
300092
- throw new Error(`Invalid cron expression: ${task.cronExpression}`);
300093
- }
300094
- const existing = this.cronJobs.get(task.id);
300095
- if (existing) {
300096
- try {
300097
- existing.stop();
300098
- } catch {
300099
- }
300100
- }
300101
- const job = import_node_cron.default.schedule(task.cronExpression, async () => {
300102
- try {
300103
- task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
300104
- this.store.saveScheduledTask(task);
300105
- await this.runner(task);
300106
- } catch (err) {
300107
- console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
300108
- }
300109
- }, { timezone: "UTC" });
300110
- this.cronJobs.set(task.id, job);
300111
- }
300112
- unschedule(taskId) {
300113
- this.cronJobs.get(taskId)?.stop();
300114
- this.cronJobs.delete(taskId);
300115
- }
300116
- add(task) {
300117
- this.store.saveScheduledTask(task);
300118
- if (task.enabled) this.schedule(task);
300119
- }
300120
- remove(taskId) {
300121
- this.unschedule(taskId);
300122
- this.store.deleteScheduledTask(taskId);
300123
- }
300124
- list() {
300125
- return this.store.listScheduledTasks();
300126
- }
300127
- isRunning(taskId) {
300128
- return this.cronJobs.has(taskId);
300129
- }
300130
- static validateCron(expression) {
300131
- return import_node_cron.default.validate(expression);
300132
- }
300133
- };
300134
300543
  var execFileAsync2 = util$1.promisify(child_process.execFile);
300135
300544
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
300136
300545
  function sanitizeEnvValue(v6) {