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.js CHANGED
@@ -190,7 +190,7 @@ var init_audit_logger = __esm({
190
190
  });
191
191
 
192
192
  // src/constants.ts
193
- var CASCADE_VERSION = "0.15.2";
193
+ var CASCADE_VERSION = "0.17.0";
194
194
  var CASCADE_CONFIG_DIR = ".cascade";
195
195
  var CASCADE_MD_FILE = "CASCADE.md";
196
196
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -5506,6 +5506,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
5506
5506
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
5507
5507
  if (this.store) worker.setStore(this.store, taskId);
5508
5508
  worker.setPeerBus(this.t3PeerBus);
5509
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
5510
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
5511
+ worker.on("log", (e) => this.emit("log", e));
5512
+ worker.on("tier:status", (e) => this.emit("tier:status", e));
5509
5513
  worker.on("stream:token", (e) => this.emit("stream:token", e));
5510
5514
  worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
5511
5515
  ...e,
@@ -5854,12 +5858,14 @@ Board guidance (must be followed in the plan): ${decision.note}`,
5854
5858
  status: "IN_PROGRESS"
5855
5859
  });
5856
5860
  const okBefore = okCount(allT2Results);
5861
+ const completedSummary = this.summarizeCompletedSections(allT2Results);
5862
+ const correctionContext = [systemContext, completedSummary].filter(Boolean).join("\n\n") || void 0;
5857
5863
  const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
5858
5864
  Review reason: ${reviewResult.reason}
5859
5865
 
5860
5866
  Original goal: ${enrichedPrompt}
5861
5867
 
5862
- Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`);
5868
+ Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`, correctionContext);
5863
5869
  const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
5864
5870
  allT2Results = [...allT2Results, ...correctionResults];
5865
5871
  if (okCount(allT2Results) <= okBefore) {
@@ -5962,6 +5968,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
5962
5968
  return null;
5963
5969
  }
5964
5970
  }
5971
+ /** Structured, grounded summary of what's already done — used to keep
5972
+ * corrective replan passes from re-emitting completed sections. */
5973
+ summarizeCompletedSections(results) {
5974
+ const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
5975
+ if (done.length === 0) return "";
5976
+ const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
5977
+ return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
5978
+ ${lines.join("\n")}`;
5979
+ }
5965
5980
  async decomposeTask(prompt, systemContext) {
5966
5981
  const db = this.router.getWorldStateDB?.();
5967
5982
  let worldStateContext = "";
@@ -6229,7 +6244,7 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
6229
6244
  }
6230
6245
  }
6231
6246
  if (readyIds.length === 0) return;
6232
- await Promise.all(readyIds.map(async (id) => {
6247
+ const runOne = async (id) => {
6233
6248
  resultMap.set(id, null);
6234
6249
  const index = sections.findIndex((s) => s.sectionId === id);
6235
6250
  const section = sections[index];
@@ -6269,7 +6284,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
6269
6284
  for (const dependentId of adj.get(id) ?? /* @__PURE__ */ new Set()) {
6270
6285
  inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
6271
6286
  }
6272
- }));
6287
+ };
6288
+ if (this.router.getT3ExecutionMode?.() === "sequential") {
6289
+ for (const id of readyIds) {
6290
+ await runOne(id);
6291
+ }
6292
+ } else {
6293
+ await Promise.all(readyIds.map(runOne));
6294
+ }
6273
6295
  if (Array.from(inDegree.values()).some((deg) => deg === 0) && resultMap.size < totalSections) {
6274
6296
  await executeWave();
6275
6297
  }
@@ -8067,6 +8089,13 @@ var PermissionEscalator = class extends EventEmitter {
8067
8089
  * All T3 workers under the same T2 share cached decisions for the same tool.
8068
8090
  */
8069
8091
  sessionCache = /* @__PURE__ */ new Map();
8092
+ /**
8093
+ * Task-wide cache keyed by `toolName` alone, for USER- and T1-level
8094
+ * "always" decisions — these are meant to cover every sibling T2/T3 in the
8095
+ * run, not just the one that happened to ask first (see PermissionDecision
8096
+ * doc comment: "task-wide for T1").
8097
+ */
8098
+ taskWideCache = /* @__PURE__ */ new Map();
8070
8099
  t2Evaluator;
8071
8100
  t1Evaluator;
8072
8101
  /** Pending user-decision resolvers keyed by request ID */
@@ -8095,6 +8124,15 @@ var PermissionEscalator = class extends EventEmitter {
8095
8124
  * Returns a PermissionDecision from whichever tier was able to decide.
8096
8125
  */
8097
8126
  async requestPermission(req) {
8127
+ if (!req.forceReprompt && this.taskWideCache.has(req.toolName)) {
8128
+ return {
8129
+ requestId: req.id,
8130
+ approved: this.taskWideCache.get(req.toolName),
8131
+ always: true,
8132
+ decidedBy: "T1",
8133
+ reasoning: "Cached from a previous task-wide decision in this session"
8134
+ };
8135
+ }
8098
8136
  const cacheKey = `${req.parentT2Id}:${req.toolName}`;
8099
8137
  if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
8100
8138
  return {
@@ -8139,7 +8177,7 @@ var PermissionEscalator = class extends EventEmitter {
8139
8177
  try {
8140
8178
  const t1Decision = await this.t1Evaluator(req);
8141
8179
  if (t1Decision !== null) {
8142
- if (t1Decision.always) this.sessionCache.set(cacheKey, t1Decision.approved);
8180
+ if (t1Decision.always) this.taskWideCache.set(req.toolName, t1Decision.approved);
8143
8181
  return t1Decision;
8144
8182
  }
8145
8183
  } catch {
@@ -8169,7 +8207,7 @@ var PermissionEscalator = class extends EventEmitter {
8169
8207
  const wrappedResolver = (decision) => {
8170
8208
  if (timer) clearTimeout(timer);
8171
8209
  if (decision.always) {
8172
- this.sessionCache.set(`${req.parentT2Id}:${req.toolName}`, decision.approved);
8210
+ this.taskWideCache.set(req.toolName, decision.approved);
8173
8211
  }
8174
8212
  resolve(decision);
8175
8213
  };
@@ -11861,6 +11899,25 @@ var DashboardSocket = class {
11861
11899
  });
11862
11900
  });
11863
11901
  }
11902
+ /**
11903
+ * Boardroom plan decisions from a connected client. The desktop shows a
11904
+ * plan-review modal on `plan:approval-required` and answers here; the
11905
+ * server routes the decision into the paused run via resolvePlanApproval.
11906
+ */
11907
+ onPlanDecision(callback) {
11908
+ this.io.on("connection", (socket) => {
11909
+ socket.on("plan:decision", (payload) => {
11910
+ if (typeof payload?.sessionId === "string" && typeof payload?.approved === "boolean") {
11911
+ callback({
11912
+ sessionId: payload.sessionId,
11913
+ approved: payload.approved,
11914
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0,
11915
+ editedPlan: payload.editedPlan
11916
+ });
11917
+ }
11918
+ });
11919
+ });
11920
+ }
11864
11921
  onSessionSteer(callback) {
11865
11922
  this.io.on("connection", (socket) => {
11866
11923
  socket.on("session:steer", (payload) => {
@@ -11902,6 +11959,122 @@ var DashboardSocket = class {
11902
11959
  this.io.close();
11903
11960
  }
11904
11961
  };
11962
+ var TaskScheduler = class {
11963
+ cronJobs = /* @__PURE__ */ new Map();
11964
+ store;
11965
+ runner;
11966
+ constructor(store, runner) {
11967
+ this.store = store;
11968
+ this.runner = runner;
11969
+ }
11970
+ start() {
11971
+ const tasks = this.store.listScheduledTasks();
11972
+ for (const task of tasks) {
11973
+ if (task.enabled) this.schedule(task);
11974
+ }
11975
+ }
11976
+ stop() {
11977
+ for (const job of this.cronJobs.values()) job.stop();
11978
+ this.cronJobs.clear();
11979
+ }
11980
+ schedule(task) {
11981
+ if (!cron.validate(task.cronExpression)) {
11982
+ throw new Error(`Invalid cron expression: ${task.cronExpression}`);
11983
+ }
11984
+ const existing = this.cronJobs.get(task.id);
11985
+ if (existing) {
11986
+ try {
11987
+ existing.stop();
11988
+ } catch {
11989
+ }
11990
+ }
11991
+ const job = cron.schedule(task.cronExpression, async () => {
11992
+ try {
11993
+ task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
11994
+ this.store.saveScheduledTask(task);
11995
+ await this.runner(task);
11996
+ } catch (err) {
11997
+ console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
11998
+ }
11999
+ }, { timezone: "UTC" });
12000
+ this.cronJobs.set(task.id, job);
12001
+ }
12002
+ unschedule(taskId) {
12003
+ this.cronJobs.get(taskId)?.stop();
12004
+ this.cronJobs.delete(taskId);
12005
+ }
12006
+ add(task) {
12007
+ this.store.saveScheduledTask(task);
12008
+ if (task.enabled) this.schedule(task);
12009
+ }
12010
+ remove(taskId) {
12011
+ this.unschedule(taskId);
12012
+ this.store.deleteScheduledTask(taskId);
12013
+ }
12014
+ list() {
12015
+ return this.store.listScheduledTasks();
12016
+ }
12017
+ isRunning(taskId) {
12018
+ return this.cronJobs.has(taskId);
12019
+ }
12020
+ static validateCron(expression) {
12021
+ return cron.validate(expression);
12022
+ }
12023
+ };
12024
+
12025
+ // src/dashboard/cost-stats.ts
12026
+ var DAY_MS = 24 * 60 * 60 * 1e3;
12027
+ function utcDateKey(d) {
12028
+ return d.toISOString().slice(0, 10);
12029
+ }
12030
+ function aggregateCostStats(sessions, opts = {}) {
12031
+ const days = Math.max(1, opts.days ?? 30);
12032
+ const topN = Math.max(1, opts.topN ?? 8);
12033
+ const now = opts.now ?? /* @__PURE__ */ new Date();
12034
+ const buckets = /* @__PURE__ */ new Map();
12035
+ for (let i = days - 1; i >= 0; i--) {
12036
+ const key = utcDateKey(new Date(now.getTime() - i * DAY_MS));
12037
+ buckets.set(key, { date: key, costUsd: 0, tokens: 0, runs: 0 });
12038
+ }
12039
+ let totalCostUsd = 0;
12040
+ let totalTokens = 0;
12041
+ let totalRuns = 0;
12042
+ for (const s of sessions) {
12043
+ const cost = s.metadata?.totalCostUsd ?? 0;
12044
+ const tokens = s.metadata?.totalTokens ?? 0;
12045
+ const runs = s.metadata?.taskCount ?? 0;
12046
+ totalCostUsd += cost;
12047
+ totalTokens += tokens;
12048
+ totalRuns += runs;
12049
+ const when = new Date(s.updatedAt || s.createdAt);
12050
+ if (!Number.isNaN(when.getTime())) {
12051
+ const bucket = buckets.get(utcDateKey(when));
12052
+ if (bucket) {
12053
+ bucket.costUsd += cost;
12054
+ bucket.tokens += tokens;
12055
+ bucket.runs += runs;
12056
+ }
12057
+ }
12058
+ }
12059
+ 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) => ({
12060
+ sessionId: s.id,
12061
+ title: s.title,
12062
+ costUsd: s.metadata?.totalCostUsd ?? 0,
12063
+ tokens: s.metadata?.totalTokens ?? 0,
12064
+ runs: s.metadata?.taskCount ?? 0,
12065
+ updatedAt: s.updatedAt
12066
+ }));
12067
+ return {
12068
+ totalCostUsd,
12069
+ totalTokens,
12070
+ totalSessions: sessions.length,
12071
+ totalRuns,
12072
+ perDay: [...buckets.values()],
12073
+ topSessions
12074
+ };
12075
+ }
12076
+
12077
+ // src/dashboard/server.ts
11905
12078
  var __dirname$1 = path20.dirname(fileURLToPath(import.meta.url));
11906
12079
  var DashboardServer = class {
11907
12080
  app;
@@ -11927,6 +12100,14 @@ var DashboardServer = class {
11927
12100
  * map is how that answer reaches the run that's blocked on it.
11928
12101
  */
11929
12102
  pendingApprovals = /* @__PURE__ */ new Map();
12103
+ /**
12104
+ * The orchestration decision trail ("why") of each session's most recent
12105
+ * run — captured when the run ends so the desktop's Why panel can show it
12106
+ * after the fact. Bounded: oldest entry evicted past 50 sessions.
12107
+ */
12108
+ whyBySession = /* @__PURE__ */ new Map();
12109
+ /** Cron scheduler for the Schedules UI — runs tasks while this server is up. */
12110
+ scheduler;
11930
12111
  port;
11931
12112
  host;
11932
12113
  workspacePath;
@@ -11944,6 +12125,7 @@ var DashboardServer = class {
11944
12125
  secret: this.dashboardSecret,
11945
12126
  corsOrigin: config.dashboard.auth ? [`http://localhost:${this.port}`, `http://127.0.0.1:${this.port}`] : "*"
11946
12127
  });
12128
+ this.scheduler = new TaskScheduler(store, (task) => this.runScheduledTask(task));
11947
12129
  this.setupMiddleware();
11948
12130
  this.setupRoutes();
11949
12131
  this.socket.onSessionRate((sessionId, rating) => {
@@ -12007,6 +12189,9 @@ var DashboardServer = class {
12007
12189
  cascade.on("peer:message", (e) => {
12008
12190
  this.socket.emitPeerMessage(e);
12009
12191
  });
12192
+ cascade.on("plan:approval-required", (e) => {
12193
+ this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
12194
+ });
12010
12195
  try {
12011
12196
  const result = await cascade.run({
12012
12197
  prompt: runPrompt,
@@ -12014,7 +12199,8 @@ var DashboardServer = class {
12014
12199
  approvalCallback: this.makeApprovalCallback(sessionId)
12015
12200
  });
12016
12201
  this.recordSessionTask(sessionId, result.taskId);
12017
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
12202
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12203
+ this.captureWhy(sessionId, cascade, result);
12018
12204
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
12019
12205
  this.socket.broadcast("cost:update", {
12020
12206
  sessionId,
@@ -12024,6 +12210,7 @@ var DashboardServer = class {
12024
12210
  this.throttledBroadcast("workspace");
12025
12211
  } catch (err) {
12026
12212
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12213
+ this.captureWhy(sessionId, cascade);
12027
12214
  this.socket.emitToSocket(socketId, "session:error", {
12028
12215
  sessionId,
12029
12216
  error: err instanceof Error ? err.message : String(err)
@@ -12034,9 +12221,17 @@ var DashboardServer = class {
12034
12221
  this.denyPendingApprovals(sessionId);
12035
12222
  }
12036
12223
  });
12224
+ this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
12225
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(
12226
+ approved,
12227
+ note,
12228
+ editedPlan
12229
+ );
12230
+ });
12037
12231
  this.socket.onSessionHalt((sessionId) => {
12038
12232
  this.activeControllers.get(sessionId)?.abort();
12039
12233
  this.denyPendingApprovals(sessionId);
12234
+ this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
12040
12235
  });
12041
12236
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
12042
12237
  const pending = this.pendingApprovals.get(requestId);
@@ -12069,12 +12264,21 @@ var DashboardServer = class {
12069
12264
  resolve();
12070
12265
  });
12071
12266
  });
12267
+ try {
12268
+ this.scheduler.start();
12269
+ } catch (err) {
12270
+ console.warn("[dashboard] failed to start task scheduler:", err);
12271
+ }
12072
12272
  }
12073
12273
  async stop() {
12074
12274
  if (this.broadcastTimer) {
12075
12275
  clearTimeout(this.broadcastTimer);
12076
12276
  this.broadcastTimer = null;
12077
12277
  }
12278
+ try {
12279
+ this.scheduler.stop();
12280
+ } catch {
12281
+ }
12078
12282
  this.socket.close();
12079
12283
  try {
12080
12284
  this.globalStore?.close();
@@ -12088,6 +12292,16 @@ var DashboardServer = class {
12088
12292
  getSocket() {
12089
12293
  return this.socket;
12090
12294
  }
12295
+ /**
12296
+ * Rebind the workspace tasks execute in — e.g. the desktop app's Code view
12297
+ * opening a different project folder — without tearing down the socket
12298
+ * server (which would drop the port/auth token/connection mid-session).
12299
+ * The next `cascade:run` picks this up immediately since `this.workspacePath`
12300
+ * is read live per-run (see onCascadeRun below).
12301
+ */
12302
+ setWorkspacePath(workspacePath) {
12303
+ this.workspacePath = workspacePath;
12304
+ }
12091
12305
  /**
12092
12306
  * Write the in-memory config back to the workspace config file so mutations
12093
12307
  * made over the socket (Settings → Save) persist across restarts. Best-effort:
@@ -12185,16 +12399,60 @@ var DashboardServer = class {
12185
12399
  return title;
12186
12400
  }
12187
12401
  /** Record run end: the assistant reply (when there is one) and the runtime row's final status. */
12188
- persistRunEnd(sessionId, title, latestPrompt, reply, status) {
12402
+ persistRunEnd(sessionId, title, latestPrompt, reply, status, result) {
12189
12403
  try {
12190
12404
  if (reply && reply.trim()) {
12191
12405
  this.store.addMessage({ id: randomUUID(), sessionId, role: "assistant", content: reply, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
12192
12406
  }
12407
+ if (result) {
12408
+ const session = this.store.getSession(sessionId);
12409
+ if (session) {
12410
+ this.store.updateSession(sessionId, {
12411
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12412
+ metadata: {
12413
+ ...session.metadata,
12414
+ totalTokens: session.metadata.totalTokens + result.usage.totalTokens,
12415
+ totalCostUsd: session.metadata.totalCostUsd + result.usage.estimatedCostUsd,
12416
+ taskCount: session.metadata.taskCount + 1
12417
+ }
12418
+ });
12419
+ }
12420
+ }
12193
12421
  } catch (err) {
12194
12422
  console.warn("[dashboard] failed to persist run end:", err);
12195
12423
  }
12196
12424
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
12197
12425
  }
12426
+ /**
12427
+ * Capture the run's decision trail + router economics ("why") and broadcast
12428
+ * it so the desktop's Why panel updates live; kept per-session for the
12429
+ * GET /api/sessions/:id/why fallback (panel opened after the run).
12430
+ */
12431
+ captureWhy(sessionId, cascade, result) {
12432
+ try {
12433
+ const stats = cascade.getRouter().getStats();
12434
+ const savings = cascade.getRouter().getDelegationSavings();
12435
+ const report = {
12436
+ sessionId,
12437
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
12438
+ decisions: cascade.getDecisionLog(),
12439
+ savedUsd: savings.savedUsd,
12440
+ savedPct: savings.savedPct,
12441
+ totalCostUsd: stats.totalCostUsd,
12442
+ totalTokens: stats.totalTokens,
12443
+ costByTier: stats.costByTier,
12444
+ durationMs: result?.durationMs
12445
+ };
12446
+ this.whyBySession.set(sessionId, report);
12447
+ if (this.whyBySession.size > 50) {
12448
+ const oldest = this.whyBySession.keys().next().value;
12449
+ if (oldest) this.whyBySession.delete(oldest);
12450
+ }
12451
+ this.socket.broadcast("run:why", report);
12452
+ } catch (err) {
12453
+ console.warn("[dashboard] failed to capture decision trail:", err);
12454
+ }
12455
+ }
12198
12456
  /**
12199
12457
  * Route steering text into running Cascade instances. Targets the given
12200
12458
  * session, or every active session when none is specified (the desktop
@@ -12224,6 +12482,52 @@ var DashboardServer = class {
12224
12482
  this.pendingApprovals.set(request.id, { resolve, sessionId });
12225
12483
  });
12226
12484
  }
12485
+ /**
12486
+ * Execute one scheduled task firing. Runs headless (like `cascade run -p`):
12487
+ * tool approvals are auto-granted since nobody may be watching when the cron
12488
+ * fires — the Schedules UI states this. Events broadcast to every connected
12489
+ * client so an open desktop sees the run appear live.
12490
+ */
12491
+ async runScheduledTask(task) {
12492
+ const sessionId = randomUUID();
12493
+ const prompt = task.prompt;
12494
+ const title = this.persistRunStart(sessionId, `[${task.name}] ${prompt}`);
12495
+ const cascade = new Cascade(this.config, task.workspacePath ?? this.workspacePath, this.store);
12496
+ this.activeSessions.set(sessionId, cascade);
12497
+ cascade.on("tier:status", (e) => {
12498
+ this.socket.broadcast("tier:status", { sessionId, ...e });
12499
+ });
12500
+ cascade.on("peer:message", (e) => {
12501
+ this.socket.emitPeerMessage(e);
12502
+ });
12503
+ try {
12504
+ const result = await cascade.run({
12505
+ prompt,
12506
+ identityId: task.identityId,
12507
+ approvalCallback: async () => ({ approved: true, always: false })
12508
+ });
12509
+ this.recordSessionTask(sessionId, result.taskId);
12510
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
12511
+ this.captureWhy(sessionId, cascade, result);
12512
+ this.socket.broadcast("session:complete", { sessionId, result });
12513
+ this.socket.broadcast("cost:update", {
12514
+ sessionId,
12515
+ totalTokens: result.usage.totalTokens,
12516
+ totalCostUsd: result.usage.estimatedCostUsd
12517
+ });
12518
+ this.throttledBroadcast("workspace");
12519
+ } catch (err) {
12520
+ this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
12521
+ this.captureWhy(sessionId, cascade);
12522
+ this.socket.broadcast("session:error", {
12523
+ sessionId,
12524
+ error: err instanceof Error ? err.message : String(err)
12525
+ });
12526
+ throw err;
12527
+ } finally {
12528
+ this.activeSessions.delete(sessionId);
12529
+ }
12530
+ }
12227
12531
  /** Deny + clear any approvals still pending for a session (run end / abort). */
12228
12532
  denyPendingApprovals(sessionId) {
12229
12533
  for (const [id, pending] of this.pendingApprovals) {
@@ -12736,6 +13040,9 @@ ${prompt}`;
12736
13040
  cascade.on("peer:message", (e) => {
12737
13041
  this.socket.emitPeerMessage(e);
12738
13042
  });
13043
+ cascade.on("plan:approval-required", (e) => {
13044
+ this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
13045
+ });
12739
13046
  try {
12740
13047
  const result = await cascade.run({
12741
13048
  prompt: runPrompt,
@@ -12743,7 +13050,8 @@ ${prompt}`;
12743
13050
  approvalCallback: this.makeApprovalCallback(sessionId)
12744
13051
  });
12745
13052
  this.recordSessionTask(sessionId, result.taskId);
12746
- this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
13053
+ this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
13054
+ this.captureWhy(sessionId, cascade, result);
12747
13055
  this.socket.broadcast("cost:update", {
12748
13056
  sessionId,
12749
13057
  totalTokens: result.usage.totalTokens,
@@ -12753,6 +13061,7 @@ ${prompt}`;
12753
13061
  this.throttledBroadcast("workspace");
12754
13062
  } catch (err) {
12755
13063
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
13064
+ this.captureWhy(sessionId, cascade);
12756
13065
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:error", {
12757
13066
  sessionId,
12758
13067
  error: err instanceof Error ? err.message : String(err)
@@ -12774,6 +13083,168 @@ ${prompt}`;
12774
13083
  }))
12775
13084
  });
12776
13085
  });
13086
+ this.app.get("/api/sessions/:id/why", auth, (req, res) => {
13087
+ const report = this.whyBySession.get(req.params.id);
13088
+ if (!report) {
13089
+ res.status(404).json({ error: "No decision trail recorded for this session in the current app run." });
13090
+ return;
13091
+ }
13092
+ res.json(report);
13093
+ });
13094
+ this.app.get("/api/sessions/:id/changes", auth, async (req, res) => {
13095
+ const sessionId = req.params.id;
13096
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13097
+ const before = /* @__PURE__ */ new Map();
13098
+ for (const taskId of taskIds) {
13099
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
13100
+ if (!before.has(filePath)) before.set(filePath, content);
13101
+ }
13102
+ }
13103
+ const { readFile } = await import('fs/promises');
13104
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
13105
+ const changes = await Promise.all([...before.entries()].map(async ([filePath, beforeContent]) => {
13106
+ let after = "";
13107
+ let missing = false;
13108
+ try {
13109
+ const buf = await readFile(filePath);
13110
+ after = buf.length > MAX_DIFF_BYTES ? `[file too large to diff \u2014 ${buf.length} bytes]` : buf.toString("utf-8");
13111
+ } catch {
13112
+ missing = true;
13113
+ }
13114
+ return { filePath, before: beforeContent, after, missing, changed: missing || after !== beforeContent };
13115
+ }));
13116
+ res.json({ sessionId, changes: changes.filter((c) => c.changed) });
13117
+ });
13118
+ this.app.post("/api/sessions/:id/revert-file", auth, mutationLimiter, async (req, res) => {
13119
+ const sessionId = req.params.id;
13120
+ const body = req.body;
13121
+ if (!body.filePath || typeof body.filePath !== "string") {
13122
+ res.status(400).json({ error: "filePath is required" });
13123
+ return;
13124
+ }
13125
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
13126
+ let content;
13127
+ for (const taskId of taskIds) {
13128
+ const snap = this.store.getLatestFileSnapshots(taskId).find((s) => s.filePath === body.filePath);
13129
+ if (snap) {
13130
+ content = snap.content;
13131
+ break;
13132
+ }
13133
+ }
13134
+ if (content === void 0) {
13135
+ res.status(404).json({ error: "No snapshot recorded for that file in this session." });
13136
+ return;
13137
+ }
13138
+ try {
13139
+ const { writeFile } = await import('fs/promises');
13140
+ await writeFile(body.filePath, content, "utf-8");
13141
+ res.json({ ok: true, filePath: body.filePath });
13142
+ } catch (err) {
13143
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13144
+ }
13145
+ });
13146
+ this.app.get("/api/costs", auth, (_req, res) => {
13147
+ try {
13148
+ const sessions = this.store.listSessions(void 0, 1e3);
13149
+ const budget = this.config.budget ?? { warnAtPct: 80 };
13150
+ res.json({
13151
+ ...aggregateCostStats(sessions, { days: 30, topN: 8 }),
13152
+ budget: {
13153
+ dailyBudgetUsd: budget.dailyBudgetUsd,
13154
+ sessionBudgetUsd: budget.sessionBudgetUsd,
13155
+ maxCostPerRunUsd: budget.maxCostPerRunUsd
13156
+ }
13157
+ });
13158
+ } catch (err) {
13159
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13160
+ }
13161
+ });
13162
+ this.app.get("/api/schedules", auth, (_req, res) => {
13163
+ try {
13164
+ res.json(this.scheduler.list().map((t) => ({ ...t, armed: this.scheduler.isRunning(t.id) })));
13165
+ } catch (err) {
13166
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13167
+ }
13168
+ });
13169
+ this.app.post("/api/schedules", auth, mutationLimiter, (req, res) => {
13170
+ const body = req.body;
13171
+ if (!body.name?.trim() || !body.cronExpression?.trim() || !body.prompt?.trim()) {
13172
+ res.status(400).json({ error: "name, cronExpression, and prompt are required" });
13173
+ return;
13174
+ }
13175
+ if (!TaskScheduler.validateCron(body.cronExpression.trim())) {
13176
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13177
+ return;
13178
+ }
13179
+ const task = {
13180
+ id: randomUUID(),
13181
+ name: body.name.trim(),
13182
+ cronExpression: body.cronExpression.trim(),
13183
+ prompt: body.prompt.trim(),
13184
+ workspacePath: this.workspacePath,
13185
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
13186
+ enabled: body.enabled !== false
13187
+ };
13188
+ try {
13189
+ this.scheduler.add(task);
13190
+ res.json(task);
13191
+ } catch (err) {
13192
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13193
+ }
13194
+ });
13195
+ this.app.put("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13196
+ const id = req.params.id;
13197
+ const existing = this.scheduler.list().find((t) => t.id === id);
13198
+ if (!existing) {
13199
+ res.status(404).json({ error: "Not found" });
13200
+ return;
13201
+ }
13202
+ const body = req.body;
13203
+ if (body.cronExpression !== void 0 && !TaskScheduler.validateCron(body.cronExpression)) {
13204
+ res.status(400).json({ error: `Invalid cron expression: ${body.cronExpression}` });
13205
+ return;
13206
+ }
13207
+ const updated = {
13208
+ ...existing,
13209
+ name: body.name?.trim() || existing.name,
13210
+ cronExpression: body.cronExpression?.trim() || existing.cronExpression,
13211
+ prompt: body.prompt?.trim() || existing.prompt,
13212
+ enabled: body.enabled ?? existing.enabled
13213
+ };
13214
+ try {
13215
+ this.scheduler.add(updated);
13216
+ if (!updated.enabled) this.scheduler.unschedule(id);
13217
+ res.json(updated);
13218
+ } catch (err) {
13219
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13220
+ }
13221
+ });
13222
+ this.app.delete("/api/schedules/:id", auth, mutationLimiter, (req, res) => {
13223
+ try {
13224
+ this.scheduler.remove(req.params.id);
13225
+ res.json({ ok: true });
13226
+ } catch (err) {
13227
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13228
+ }
13229
+ });
13230
+ this.app.get("/api/audit-chain", auth, async (req, res) => {
13231
+ try {
13232
+ const limit = Math.min(500, Math.max(1, parseInt(req.query["limit"] || "200", 10) || 200));
13233
+ const offset = Math.max(0, parseInt(req.query["offset"] || "0", 10) || 0);
13234
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
13235
+ const logger = new AuditLogger3(this.workspacePath);
13236
+ try {
13237
+ const all = logger.getAllLogs();
13238
+ const total = all.length;
13239
+ const entries = all.reverse().slice(offset, offset + limit);
13240
+ res.json({ total, offset, entries });
13241
+ } finally {
13242
+ logger.close();
13243
+ }
13244
+ } catch (err) {
13245
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
13246
+ }
13247
+ });
12777
13248
  const prodPath = path20.resolve(__dirname$1, "../web/dist");
12778
13249
  const devPath = path20.resolve(__dirname$1, "../../web/dist");
12779
13250
  const webDistPath = fs19.existsSync(prodPath) ? prodPath : devPath;
@@ -12795,68 +13266,6 @@ ${prompt}`;
12795
13266
  }
12796
13267
  }
12797
13268
  };
12798
- var TaskScheduler = class {
12799
- cronJobs = /* @__PURE__ */ new Map();
12800
- store;
12801
- runner;
12802
- constructor(store, runner) {
12803
- this.store = store;
12804
- this.runner = runner;
12805
- }
12806
- start() {
12807
- const tasks = this.store.listScheduledTasks();
12808
- for (const task of tasks) {
12809
- if (task.enabled) this.schedule(task);
12810
- }
12811
- }
12812
- stop() {
12813
- for (const job of this.cronJobs.values()) job.stop();
12814
- this.cronJobs.clear();
12815
- }
12816
- schedule(task) {
12817
- if (!cron.validate(task.cronExpression)) {
12818
- throw new Error(`Invalid cron expression: ${task.cronExpression}`);
12819
- }
12820
- const existing = this.cronJobs.get(task.id);
12821
- if (existing) {
12822
- try {
12823
- existing.stop();
12824
- } catch {
12825
- }
12826
- }
12827
- const job = cron.schedule(task.cronExpression, async () => {
12828
- try {
12829
- task.lastRun = (/* @__PURE__ */ new Date()).toISOString();
12830
- this.store.saveScheduledTask(task);
12831
- await this.runner(task);
12832
- } catch (err) {
12833
- console.error(`[scheduler] Task "${task.id}" (${task.cronExpression}) failed:`, err);
12834
- }
12835
- }, { timezone: "UTC" });
12836
- this.cronJobs.set(task.id, job);
12837
- }
12838
- unschedule(taskId) {
12839
- this.cronJobs.get(taskId)?.stop();
12840
- this.cronJobs.delete(taskId);
12841
- }
12842
- add(task) {
12843
- this.store.saveScheduledTask(task);
12844
- if (task.enabled) this.schedule(task);
12845
- }
12846
- remove(taskId) {
12847
- this.unschedule(taskId);
12848
- this.store.deleteScheduledTask(taskId);
12849
- }
12850
- list() {
12851
- return this.store.listScheduledTasks();
12852
- }
12853
- isRunning(taskId) {
12854
- return this.cronJobs.has(taskId);
12855
- }
12856
- static validateCron(expression) {
12857
- return cron.validate(expression);
12858
- }
12859
- };
12860
13269
  var execFileAsync2 = promisify(execFile);
12861
13270
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
12862
13271
  function sanitizeEnvValue(v) {