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/index.cjs CHANGED
@@ -236,7 +236,7 @@ var init_audit_logger = __esm({
236
236
  });
237
237
 
238
238
  // src/constants.ts
239
- var CASCADE_VERSION = "0.15.2";
239
+ var CASCADE_VERSION = "0.17.0";
240
240
  var CASCADE_CONFIG_DIR = ".cascade";
241
241
  var CASCADE_MD_FILE = "CASCADE.md";
242
242
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -5552,6 +5552,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
5552
5552
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
5553
5553
  if (this.store) worker.setStore(this.store, taskId);
5554
5554
  worker.setPeerBus(this.t3PeerBus);
5555
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
5556
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
5557
+ worker.on("log", (e) => this.emit("log", e));
5558
+ worker.on("tier:status", (e) => this.emit("tier:status", e));
5555
5559
  worker.on("stream:token", (e) => this.emit("stream:token", e));
5556
5560
  worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
5557
5561
  ...e,
@@ -5900,12 +5904,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
5900
5904
  status: "IN_PROGRESS"
5901
5905
  });
5902
5906
  const okBefore = okCount(allT2Results);
5907
+ const completedSummary = this.summarizeCompletedSections(allT2Results);
5908
+ const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
5903
5909
  const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
5904
5910
  Review reason: ${reviewResult.reason}
5905
5911
 
5906
5912
  Original goal: ${enrichedPrompt}
5907
5913
 
5908
- Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`);
5914
+ Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
5909
5915
  const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
5910
5916
  allT2Results = [...allT2Results, ...correctionResults];
5911
5917
  if (okCount(allT2Results) <= okBefore) {
@@ -6008,6 +6014,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
6008
6014
  return null;
6009
6015
  }
6010
6016
  }
6017
+ /** Structured, grounded summary of what's already done — used to keep
6018
+ * corrective replan passes from re-emitting completed sections. */
6019
+ summarizeCompletedSections(results) {
6020
+ const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
6021
+ if (done.length === 0) return "";
6022
+ const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
6023
+ return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
6024
+ ${lines.join("\n")}`;
6025
+ }
6011
6026
  async decomposeTask(prompt, systemContext) {
6012
6027
  const db = this.router.getWorldStateDB?.();
6013
6028
  let worldStateContext = "";
@@ -6275,7 +6290,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
6275
6290
  }
6276
6291
  }
6277
6292
  if (readyIds.length === 0) return;
6278
- await Promise.all(readyIds.map(async (id) => {
6293
+ const runOne = async (id) => {
6279
6294
  resultMap.set(id, null);
6280
6295
  const index = sections.findIndex((s) => s.sectionId === id);
6281
6296
  const section = sections[index];
@@ -6315,7 +6330,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
6315
6330
  for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
6316
6331
  inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
6317
6332
  }
6318
- }));
6333
+ };
6334
+ if (this.router.getT3ExecutionMode?.() === "sequential") {
6335
+ for (const id of readyIds) {
6336
+ await runOne(id);
6337
+ }
6338
+ } else {
6339
+ await Promise.all(readyIds.map(runOne));
6340
+ }
6319
6341
  if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
6320
6342
  await executeWave();
6321
6343
  }
@@ -8113,6 +8135,13 @@ var PermissionEscalator = class extends EventEmitter__default.default {
8113
8135
  * All T3 workers under the same T2 share cached decisions for the same tool.
8114
8136
  */
8115
8137
  sessionCache = /* @__PURE__ */ new Map();
8138
+ /**
8139
+ * Task-wide cache keyed by `toolName` alone, for USER- and T1-level
8140
+ * "always" decisions — these are meant to cover every sibling T2/T3 in the
8141
+ * run, not just the one that happened to ask first (see PermissionDecision
8142
+ * doc comment: "task-wide for T1").
8143
+ */
8144
+ taskWideCache = /* @__PURE__ */ new Map();
8116
8145
  t2Evaluator;
8117
8146
  t1Evaluator;
8118
8147
  /** Pending user-decision resolvers keyed by request ID */
@@ -8141,6 +8170,15 @@ var PermissionEscalator = class extends EventEmitter__default.default {
8141
8170
  * Returns a PermissionDecision from whichever tier was able to decide.
8142
8171
  */
8143
8172
  async requestPermission(req) {
8173
+ if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
8174
+ return {
8175
+ requestId: req.id,
8176
+ approved: this.taskWideCache.get(req.toolName),
8177
+ always: true,
8178
+ decidedBy: "T1",
8179
+ reasoning: "Cached from a previous task-wide decision in this session"
8180
+ };
8181
+ }
8144
8182
  const cacheKey = `${req.parentT2Id}:${req.toolName}`;
8145
8183
  if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
8146
8184
  return {
@@ -8185,7 +8223,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
8185
8223
  try {
8186
8224
  const t1Decision = await this.t1Evaluator(req);
8187
8225
  if (t1Decision !== null) {
8188
- if (t1Decision.always) this.sessionCache.set(cacheKey, t1Decision.approved);
8226
+ if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
8189
8227
  return t1Decision;
8190
8228
  }
8191
8229
  } catch {
@@ -8215,7 +8253,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
8215
8253
  const wrappedResolver = (decision) => {
8216
8254
  if (timer) clearTimeout(timer);
8217
8255
  if (decision.always) {
8218
- this.sessionCache.set(`${req.parentT2Id}:${req.toolName}`, decision.approved);
8256
+ this.taskWideCache.set(req.toolName, decision.approved);
8219
8257
  }
8220
8258
  resolve(decision);
8221
8259
  };
@@ -11907,6 +11945,25 @@ var DashboardSocket = class {
11907
11945
  });
11908
11946
  });
11909
11947
  }
11948
+ /**
11949
+ * Boardroom plan decisions from a connected client. The desktop shows a
11950
+ * plan-review modal on `plan:approval-required` and answers here; the
11951
+ * server routes the decision into the paused run via resolvePlanApproval.
11952
+ */
11953
+ onPlanDecision(callback) {
11954
+ this.io.on("connection", (socket) => {
11955
+ socket.on("plan:decision", (payload) => {
11956
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
11957
+ callback({
11958
+ sessionId: payload.sessionId,
11959
+ approved: payload.approved,
11960
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
11961
+ editedPlan: payload.editedPlan
11962
+ });
11963
+ }
11964
+ });
11965
+ });
11966
+ }
11910
11967
  onSessionSteer(callback) {
11911
11968
  this.io.on("connection", (socket) => {
11912
11969
  socket.on("session:steer", (payload) => {
@@ -11948,6 +12005,122 @@ var DashboardSocket = class {
11948
12005
  this.io.close();
11949
12006
  }
11950
12007
  };
12008
+ var TaskScheduler = class {
12009
+ cronJobs = /* @__PURE__ */ new Map();
12010
+ store;
12011
+ runner;
12012
+ constructor(store, runner) {
12013
+ this.store = store;
12014
+ this.runner = runner;
12015
+ }
12016
+ start() {
12017
+ const tasks = this.store.listScheduledTasks();
12018
+ for (const task of tasks) {
12019
+ if (task.enabled) this.schedule(task);
12020
+ }
12021
+ }
12022
+ stop() {
12023
+ for (const job of this.cronJobs.values()) job.stop();
12024
+ this.cronJobs.clear();
12025
+ }
12026
+ schedule(task) {
12027
+ if (!cron__default.default.validate(task.cronExpression)) {
12028
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
12029
+ }
12030
+ const existing = this.cronJobs.get(task.id);
12031
+ if (existing) {
12032
+ try {
12033
+ existing.stop();
12034
+ } catch {
12035
+ }
12036
+ }
12037
+ const job = cron__default.default.schedule(task.cronExpression, async () => {
12038
+ try {
12039
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
12040
+ this.store.saveScheduledTask(task);
12041
+ await this.runner(task);
12042
+ } catch (err) {
12043
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
12044
+ }
12045
+ }, { timezone: "UTC" });
12046
+ this.cronJobs.set(task.id, job);
12047
+ }
12048
+ unschedule(taskId) {
12049
+ this.cronJobs.get(taskId)?.stop();
12050
+ this.cronJobs.delete(taskId);
12051
+ }
12052
+ add(task) {
12053
+ this.store.saveScheduledTask(task);
12054
+ if (task.enabled) this.schedule(task);
12055
+ }
12056
+ remove(taskId) {
12057
+ this.unschedule(taskId);
12058
+ this.store.deleteScheduledTask(taskId);
12059
+ }
12060
+ list() {
12061
+ return this.store.listScheduledTasks();
12062
+ }
12063
+ isRunning(taskId) {
12064
+ return this.cronJobs.has(taskId);
12065
+ }
12066
+ static validateCron(expression) {
12067
+ return cron__default.default.validate(expression);
12068
+ }
12069
+ };
12070
+
12071
+ // src/dashboard/cost-stats.ts
12072
+ var DAY_MS = 24 * 60 * 60 * 1e3;
12073
+ function utcDateKey(d) {
12074
+ return d.toISOString().slice(0, 10);
12075
+ }
12076
+ function aggregateCostStats(sessions, opts = {}) {
12077
+ const days = Math.max(1, opts.days ?? 30);
12078
+ const topN = Math.max(1, opts.topN ?? 8);
12079
+ const now = opts.now ?? /* @__PURE__ */ new Date();
12080
+ const buckets = /* @__PURE__ */ new Map();
12081
+ for (let i = days - 1; i >= 0; i--) {
12082
+ const key = utcDateKey(new Date(now.getTime() - i * DAY_MS));
12083
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
12084
+ }
12085
+ let totalCostUsd = 0;
12086
+ let totalTokens = 0;
12087
+ let totalRuns = 0;
12088
+ for (const s of sessions) {
12089
+ const cost = s.metadata?.totalCostUsd ?? 0;
12090
+ const tokens = s.metadata?.totalTokens ?? 0;
12091
+ const runs = s.metadata?.taskCount ?? 0;
12092
+ totalCostUsd += cost;
12093
+ totalTokens += tokens;
12094
+ totalRuns += runs;
12095
+ const when = new Date(s.updatedAt || s.createdAt);
12096
+ if (!Number.isNaN(when.getTime())) {
12097
+ const bucket = buckets.get(utcDateKey(when));
12098
+ if (bucket) {
12099
+ bucket.costUsd += cost;
12100
+ bucket.tokens += tokens;
12101
+ bucket.runs += runs;
12102
+ }
12103
+ }
12104
+ }
12105
+ 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) => ({
12106
+ sessionId: s.id,
12107
+ title: s.title,
12108
+ costUsd: s.metadata?.totalCostUsd ?? 0,
12109
+ tokens: s.metadata?.totalTokens ?? 0,
12110
+ runs: s.metadata?.taskCount ?? 0,
12111
+ updatedAt: s.updatedAt
12112
+ }));
12113
+ return {
12114
+ totalCostUsd,
12115
+ totalTokens,
12116
+ totalSessions: sessions.length,
12117
+ totalRuns,
12118
+ perDay: [...buckets.values()],
12119
+ topSessions
12120
+ };
12121
+ }
12122
+
12123
+ // src/dashboard/server.ts
11951
12124
  var __dirname$1 = path20__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
11952
12125
  var DashboardServer = class {
11953
12126
  app;
@@ -11973,6 +12146,14 @@ var DashboardServer = class {
11973
12146
  * map is how that answer reaches the run that's blocked on it.
11974
12147
  */
11975
12148
  pendingApprovals = /* @__PURE__ */ new Map();
12149
+ /**
12150
+ * The orchestration decision trail ("why") of each session's most recent
12151
+ * run — captured when the run ends so the desktop's Why panel can show it
12152
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
12153
+ */
12154
+ whyBySession = /* @__PURE__ */ new Map();
12155
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
12156
+ scheduler;
11976
12157
  port;
11977
12158
  host;
11978
12159
  workspacePath;
@@ -11990,6 +12171,7 @@ var DashboardServer = class {
11990
12171
  secret: this.dashboardSecret,
11991
12172
  corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
11992
12173
  });
12174
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
11993
12175
  this.setupMiddleware();
11994
12176
  this.setupRoutes();
11995
12177
  this.socket.onSessionRate((sessionId, rating) => {
@@ -12053,6 +12235,9 @@ var DashboardServer = class {
12053
12235
  cascade.on("peer:message", (e) => {
12054
12236
  this.socket.emitPeerMessage(e);
12055
12237
  });
12238
+ cascade.on("plan:approval-required", (e) => {
12239
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
12240
+ });
12056
12241
  try {
12057
12242
  const result = await cascade.run({
12058
12243
  prompt: runPrompt,
@@ -12060,7 +12245,8 @@ var DashboardServer = class {
12060
12245
  approvalCallback: this.makeApprovalCallback(sessionId)
12061
12246
  });
12062
12247
  this.recordSessionTask(sessionId, result.taskId);
12063
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
12248
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12249
+ this.captureWhy(sessionId, cascade, result);
12064
12250
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
12065
12251
  this.socket.broadcast("cost:update", {
12066
12252
  sessionId,
@@ -12070,6 +12256,7 @@ var DashboardServer = class {
12070
12256
  this.throttledBroadcast("workspace");
12071
12257
  } catch (err) {
12072
12258
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12259
+ this.captureWhy(sessionId, cascade);
12073
12260
  this.socket.emitToSocket(socketId, "session:error", {
12074
12261
  sessionId,
12075
12262
  error: err instanceof Error ? err.message : String(err)
@@ -12080,9 +12267,17 @@ var DashboardServer = class {
12080
12267
  this.denyPendingApprovals(sessionId);
12081
12268
  }
12082
12269
  });
12270
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
12271
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
12272
+ approved,
12273
+ note,
12274
+ editedPlan
12275
+ );
12276
+ });
12083
12277
  this.socket.onSessionHalt((sessionId) => {
12084
12278
  this.activeControllers.get(sessionId)?.abort();
12085
12279
  this.denyPendingApprovals(sessionId);
12280
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
12086
12281
  });
12087
12282
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
12088
12283
  const pending = this.pendingApprovals.get(requestId);
@@ -12115,12 +12310,21 @@ var DashboardServer = class {
12115
12310
  resolve();
12116
12311
  });
12117
12312
  });
12313
+ try {
12314
+ this.scheduler.start();
12315
+ } catch (err) {
12316
+ console.warn("[dashboard] failed to start task scheduler:", err);
12317
+ }
12118
12318
  }
12119
12319
  async stop() {
12120
12320
  if (this.broadcastTimer) {
12121
12321
  clearTimeout(this.broadcastTimer);
12122
12322
  this.broadcastTimer = null;
12123
12323
  }
12324
+ try {
12325
+ this.scheduler.stop();
12326
+ } catch {
12327
+ }
12124
12328
  this.socket.close();
12125
12329
  try {
12126
12330
  this.globalStore?.close();
@@ -12134,6 +12338,16 @@ var DashboardServer = class {
12134
12338
  getSocket() {
12135
12339
  return this.socket;
12136
12340
  }
12341
+ /**
12342
+ * Rebind the workspace tasks execute in — e.g. the desktop app's Code view
12343
+ * opening a different project folder — without tearing down the socket
12344
+ * server (which would drop the port/auth token/connection mid-session).
12345
+ * The next `cascade:run` picks this up immediately since `this.workspacePath`
12346
+ * is read live per-run (see onCascadeRun below).
12347
+ */
12348
+ setWorkspacePath(workspacePath) {
12349
+ this.workspacePath = workspacePath;
12350
+ }
12137
12351
  /**
12138
12352
  * Write the in-memory config back to the workspace config file so mutations
12139
12353
  * made over the socket (Settings → Save) persist across restarts. Best-effort:
@@ -12231,16 +12445,60 @@ var DashboardServer = class {
12231
12445
  return title;
12232
12446
  }
12233
12447
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
12234
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
12448
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
12235
12449
  try {
12236
12450
  if (reply && reply.trim()) {
12237
12451
  this.store.addMessage({ id: crypto3.randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
12238
12452
  }
12453
+ if (result) {
12454
+ const session = this.store.getSession(sessionId);
12455
+ if (session) {
12456
+ this.store.updateSession(sessionId, {
12457
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12458
+ metadata: {
12459
+ ...session.metadata,
12460
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
12461
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
12462
+ taskCount: session.metadata.taskCount + 1
12463
+ }
12464
+ });
12465
+ }
12466
+ }
12239
12467
  } catch (err) {
12240
12468
  console.warn("[dashboard] failed to persist run end:", err);
12241
12469
  }
12242
12470
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
12243
12471
  }
12472
+ /**
12473
+ * Capture the run's decision trail + router economics ("why") and broadcast
12474
+ * it so the desktop's Why panel updates live; kept per-session for the
12475
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
12476
+ */
12477
+ captureWhy(sessionId, cascade, result) {
12478
+ try {
12479
+ const stats = cascade.getRouter().getStats();
12480
+ const savings = cascade.getRouter().getDelegationSavings();
12481
+ const report = {
12482
+ sessionId,
12483
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
12484
+ decisions: cascade.getDecisionLog(),
12485
+ savedUsd: savings.savedUsd,
12486
+ savedPct: savings.savedPct,
12487
+ totalCostUsd: stats.totalCostUsd,
12488
+ totalTokens: stats.totalTokens,
12489
+ costByTier: stats.costByTier,
12490
+ durationMs: result?.durationMs
12491
+ };
12492
+ this.whyBySession.set(sessionId, report);
12493
+ if (this.whyBySession.size > 50) {
12494
+ const oldest = this.whyBySession.keys().next().value;
12495
+ if (oldest) this.whyBySession.delete(oldest);
12496
+ }
12497
+ this.socket.broadcast("run:why", report);
12498
+ } catch (err) {
12499
+ console.warn("[dashboard] failed to capture decision trail:", err);
12500
+ }
12501
+ }
12244
12502
  /**
12245
12503
  * Route steering text into running Cascade instances. Targets the given
12246
12504
  * session, or every active session when none is specified (the desktop
@@ -12270,6 +12528,52 @@ var DashboardServer = class {
12270
12528
  this.pendingApprovals.set(request.id, { resolve, sessionId });
12271
12529
  });
12272
12530
  }
12531
+ /**
12532
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
12533
+ * tool approvals are auto-granted since nobody may be watching when the cron
12534
+ * fires — the Schedules UI states this. Events broadcast to every connected
12535
+ * client so an open desktop sees the run appear live.
12536
+ */
12537
+ async runScheduledTask(task) {
12538
+ const sessionId = crypto3.randomUUID();
12539
+ const prompt = task.prompt;
12540
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
12541
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
12542
+ this.activeSessions.set(sessionId, cascade);
12543
+ cascade.on("tier:status", (e) => {
12544
+ this.socket.broadcast("tier:status", { sessionId, ...e });
12545
+ });
12546
+ cascade.on("peer:message", (e) => {
12547
+ this.socket.emitPeerMessage(e);
12548
+ });
12549
+ try {
12550
+ const result = await cascade.run({
12551
+ prompt,
12552
+ identityId: task.identityId,
12553
+ approvalCallback: async () => ({ approved: true, always: false })
12554
+ });
12555
+ this.recordSessionTask(sessionId, result.taskId);
12556
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12557
+ this.captureWhy(sessionId, cascade, result);
12558
+ this.socket.broadcast("session:complete", { sessionId, result });
12559
+ this.socket.broadcast("cost:update", {
12560
+ sessionId,
12561
+ totalTokens: result.usage.totalTokens,
12562
+ totalCostUsd: result.usage.estimatedCostUsd
12563
+ });
12564
+ this.throttledBroadcast("workspace");
12565
+ } catch (err) {
12566
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12567
+ this.captureWhy(sessionId, cascade);
12568
+ this.socket.broadcast("session:error", {
12569
+ sessionId,
12570
+ error: err instanceof Error ? err.message : String(err)
12571
+ });
12572
+ throw err;
12573
+ } finally {
12574
+ this.activeSessions.delete(sessionId);
12575
+ }
12576
+ }
12273
12577
  /** Deny + clear any approvals still pending for a session (run end / abort). */
12274
12578
  denyPendingApprovals(sessionId) {
12275
12579
  for (const [id, pending] of this.pendingApprovals) {
@@ -12782,6 +13086,9 @@ ${prompt}`;
12782
13086
  cascade.on("peer:message", (e) => {
12783
13087
  this.socket.emitPeerMessage(e);
12784
13088
  });
13089
+ cascade.on("plan:approval-required", (e) => {
13090
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
13091
+ });
12785
13092
  try {
12786
13093
  const result = await cascade.run({
12787
13094
  prompt: runPrompt,
@@ -12789,7 +13096,8 @@ ${prompt}`;
12789
13096
  approvalCallback: this.makeApprovalCallback(sessionId)
12790
13097
  });
12791
13098
  this.recordSessionTask(sessionId, result.taskId);
12792
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
13099
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
13100
+ this.captureWhy(sessionId, cascade, result);
12793
13101
  this.socket.broadcast("cost:update", {
12794
13102
  sessionId,
12795
13103
  totalTokens: result.usage.totalTokens,
@@ -12799,6 +13107,7 @@ ${prompt}`;
12799
13107
  this.throttledBroadcast("workspace");
12800
13108
  } catch (err) {
12801
13109
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
13110
+ this.captureWhy(sessionId, cascade);
12802
13111
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
12803
13112
  sessionId,
12804
13113
  error: err instanceof Error ? err.message : String(err)
@@ -12820,6 +13129,168 @@ ${prompt}`;
12820
13129
  }))
12821
13130
  });
12822
13131
  });
13132
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
13133
+ const report = this.whyBySession.get(req.params.id);
13134
+ if (!report) {
13135
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
13136
+ return;
13137
+ }
13138
+ res.json(report);
13139
+ });
13140
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
13141
+ const sessionId = req.params.id;
13142
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13143
+ const before = /* @__PURE__ */ new Map();
13144
+ for (const taskId of taskIds) {
13145
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
13146
+ if (!before.has(filePath)) before.set(filePath, content);
13147
+ }
13148
+ }
13149
+ const { readFile } = await import('fs/promises');
13150
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
13151
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
13152
+ let after = "";
13153
+ let missing = false;
13154
+ try {
13155
+ const buf = await readFile(filePath);
13156
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
13157
+ } catch {
13158
+ missing = true;
13159
+ }
13160
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
13161
+ }));
13162
+ res.json({ sessionId, changes: changes.filter((c) => c.changed) });
13163
+ });
13164
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
13165
+ const sessionId = req.params.id;
13166
+ const body = req.body;
13167
+ if (!body.filePath || typeof body.filePath !== "string") {
13168
+ res.status(400).json({ error: "filePath is required" });
13169
+ return;
13170
+ }
13171
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13172
+ let content;
13173
+ for (const taskId of taskIds) {
13174
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s) => s.filePath === body.filePath);
13175
+ if (snap) {
13176
+ content = snap.content;
13177
+ break;
13178
+ }
13179
+ }
13180
+ if (content === void 0) {
13181
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
13182
+ return;
13183
+ }
13184
+ try {
13185
+ const { writeFile } = await import('fs/promises');
13186
+ await writeFile(body.filePath, content, "utf-8");
13187
+ res.json({ ok: true, filePath: body.filePath });
13188
+ } catch (err) {
13189
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13190
+ }
13191
+ });
13192
+ this.app.get("/api/costs", auth, (_req, res) => {
13193
+ try {
13194
+ const sessions = this.store.listSessions(void 0, 1e3);
13195
+ const budget = this.config.budget ?? { warnAtPct: 80 };
13196
+ res.json({
13197
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
13198
+ budget: {
13199
+ dailyBudgetUsd: budget.dailyBudgetUsd,
13200
+ sessionBudgetUsd: budget.sessionBudgetUsd,
13201
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
13202
+ }
13203
+ });
13204
+ } catch (err) {
13205
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13206
+ }
13207
+ });
13208
+ this.app.get("/api/schedules", auth, (_req, res) => {
13209
+ try {
13210
+ res.json(this.scheduler.list().map((t) => ({ ...t, armed: this.scheduler.isRunning(t.id) })));
13211
+ } catch (err) {
13212
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13213
+ }
13214
+ });
13215
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
13216
+ const body = req.body;
13217
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
13218
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
13219
+ return;
13220
+ }
13221
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
13222
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13223
+ return;
13224
+ }
13225
+ const task = {
13226
+ id: crypto3.randomUUID(),
13227
+ name: body.name.trim(),
13228
+ cronExpression: body.cronExpression.trim(),
13229
+ prompt: body.prompt.trim(),
13230
+ workspacePath: this.workspacePath,
13231
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13232
+ enabled: body.enabled !== false
13233
+ };
13234
+ try {
13235
+ this.scheduler.add(task);
13236
+ res.json(task);
13237
+ } catch (err) {
13238
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13239
+ }
13240
+ });
13241
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13242
+ const id = req.params.id;
13243
+ const existing = this.scheduler.list().find((t) => t.id === id);
13244
+ if (!existing) {
13245
+ res.status(404).json({ error: "Not found" });
13246
+ return;
13247
+ }
13248
+ const body = req.body;
13249
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
13250
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13251
+ return;
13252
+ }
13253
+ const updated = {
13254
+ ...existing,
13255
+ name: body.name?.trim() || existing.name,
13256
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
13257
+ prompt: body.prompt?.trim() || existing.prompt,
13258
+ enabled: body.enabled ?? existing.enabled
13259
+ };
13260
+ try {
13261
+ this.scheduler.add(updated);
13262
+ if (!updated.enabled) this.scheduler.unschedule(id);
13263
+ res.json(updated);
13264
+ } catch (err) {
13265
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13266
+ }
13267
+ });
13268
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13269
+ try {
13270
+ this.scheduler.remove(req.params.id);
13271
+ res.json({ ok: true });
13272
+ } catch (err) {
13273
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13274
+ }
13275
+ });
13276
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
13277
+ try {
13278
+ const limit = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
13279
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
13280
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
13281
+ const logger = new AuditLogger3(this.workspacePath);
13282
+ try {
13283
+ const all = logger.getAllLogs();
13284
+ const total = all.length;
13285
+ const entries = all.reverse().slice(offset, offset + limit);
13286
+ res.json({ total, offset, entries });
13287
+ } finally {
13288
+ logger.close();
13289
+ }
13290
+ } catch (err) {
13291
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13292
+ }
13293
+ });
12823
13294
  const prodPath = path20__default.default.resolve(__dirname$1, "../web/dist");
12824
13295
  const devPath = path20__default.default.resolve(__dirname$1, "../../web/dist");
12825
13296
  const webDistPath = fs19__default.default.existsSync(prodPath) ? prodPath : devPath;
@@ -12841,68 +13312,6 @@ ${prompt}`;
12841
13312
  }
12842
13313
  }
12843
13314
  };
12844
- var TaskScheduler = class {
12845
- cronJobs = /* @__PURE__ */ new Map();
12846
- store;
12847
- runner;
12848
- constructor(store, runner) {
12849
- this.store = store;
12850
- this.runner = runner;
12851
- }
12852
- start() {
12853
- const tasks = this.store.listScheduledTasks();
12854
- for (const task of tasks) {
12855
- if (task.enabled) this.schedule(task);
12856
- }
12857
- }
12858
- stop() {
12859
- for (const job of this.cronJobs.values()) job.stop();
12860
- this.cronJobs.clear();
12861
- }
12862
- schedule(task) {
12863
- if (!cron__default.default.validate(task.cronExpression)) {
12864
- throw new Error(`Invalid cron expression: ${task.cronExpression}`);
12865
- }
12866
- const existing = this.cronJobs.get(task.id);
12867
- if (existing) {
12868
- try {
12869
- existing.stop();
12870
- } catch {
12871
- }
12872
- }
12873
- const job = cron__default.default.schedule(task.cronExpression, async () => {
12874
- try {
12875
- task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
12876
- this.store.saveScheduledTask(task);
12877
- await this.runner(task);
12878
- } catch (err) {
12879
- console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
12880
- }
12881
- }, { timezone: "UTC" });
12882
- this.cronJobs.set(task.id, job);
12883
- }
12884
- unschedule(taskId) {
12885
- this.cronJobs.get(taskId)?.stop();
12886
- this.cronJobs.delete(taskId);
12887
- }
12888
- add(task) {
12889
- this.store.saveScheduledTask(task);
12890
- if (task.enabled) this.schedule(task);
12891
- }
12892
- remove(taskId) {
12893
- this.unschedule(taskId);
12894
- this.store.deleteScheduledTask(taskId);
12895
- }
12896
- list() {
12897
- return this.store.listScheduledTasks();
12898
- }
12899
- isRunning(taskId) {
12900
- return this.cronJobs.has(taskId);
12901
- }
12902
- static validateCron(expression) {
12903
- return cron__default.default.validate(expression);
12904
- }
12905
- };
12906
13315
  var execFileAsync2 = util.promisify(child_process.execFile);
12907
13316
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
12908
13317
  function sanitizeEnvValue(v) {