cascade-ai 0.12.24 → 0.13.2

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
@@ -1,5 +1,9 @@
1
+ import Database from 'better-sqlite3';
2
+ import path20 from 'path';
3
+ import fs19 from 'fs';
4
+ import os6 from 'os';
5
+ import crypto3, { randomUUID, timingSafeEqual } from 'crypto';
1
6
  import EventEmitter from 'events';
2
- import crypto, { randomUUID, timingSafeEqual } from 'crypto';
3
7
  import { glob } from 'glob';
4
8
  import Anthropic from '@anthropic-ai/sdk';
5
9
  import OpenAI, { AzureOpenAI } from 'openai';
@@ -10,13 +14,10 @@ import https from 'https';
10
14
  import zlib from 'zlib';
11
15
  import { Readable } from 'stream';
12
16
  import fs4 from 'fs/promises';
13
- import path18 from 'path';
14
- import os4 from 'os';
15
17
  import * as ignoreFactory from 'ignore';
16
18
  import ignoreFactory__default from 'ignore';
17
19
  import { exec, execFile, execSync } from 'child_process';
18
20
  import { promisify } from 'util';
19
- import fs17 from 'fs';
20
21
  import { simpleGit } from 'simple-git';
21
22
  import PDFDocument from 'pdfkit';
22
23
  import dns2 from 'dns/promises';
@@ -25,7 +26,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
25
26
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
26
27
  import { z } from 'zod';
27
28
  import { Worker } from 'worker_threads';
28
- import Database from 'better-sqlite3';
29
29
  import { fileURLToPath } from 'url';
30
30
  import express from 'express';
31
31
  import rateLimit from 'express-rate-limit';
@@ -36,10 +36,161 @@ import jwt from 'jsonwebtoken';
36
36
  import cron from 'node-cron';
37
37
 
38
38
  // Cascade AI — Multi-tier AI Orchestration System
39
+ var __defProp = Object.defineProperty;
40
+ var __getOwnPropNames = Object.getOwnPropertyNames;
41
+ var __esm = (fn, res) => function __init() {
42
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
43
+ };
44
+ var __export = (target, all) => {
45
+ for (var name in all)
46
+ __defProp(target, name, { get: all[name], enumerable: true });
47
+ };
39
48
 
49
+ // src/core/audit/audit-logger.ts
50
+ var audit_logger_exports = {};
51
+ __export(audit_logger_exports, {
52
+ AuditLogger: () => AuditLogger2
53
+ });
54
+ var AuditLogger2;
55
+ var init_audit_logger = __esm({
56
+ "src/core/audit/audit-logger.ts"() {
57
+ AuditLogger2 = class {
58
+ constructor(workspacePath, debugMode = false) {
59
+ this.workspacePath = workspacePath;
60
+ this.debugMode = debugMode;
61
+ const cascadeDir = path20.join(workspacePath, ".cascade");
62
+ if (!fs19.existsSync(cascadeDir)) {
63
+ fs19.mkdirSync(cascadeDir, { recursive: true });
64
+ }
65
+ this.keyPath = path20.join(cascadeDir, "audit_log.key");
66
+ this.dbPath = path20.join(cascadeDir, "audit_log.db");
67
+ this.initEncryptionKey();
68
+ this.db = new Database(this.dbPath);
69
+ this.db.pragma("journal_mode = WAL");
70
+ this.db.exec(`
71
+ CREATE TABLE IF NOT EXISTS audit_logs (
72
+ id TEXT PRIMARY KEY,
73
+ timestamp TEXT NOT NULL,
74
+ event_type TEXT NOT NULL,
75
+ tier_id TEXT NOT NULL,
76
+ encrypted_payload TEXT NOT NULL,
77
+ prev_hash TEXT NOT NULL DEFAULT '',
78
+ hash TEXT NOT NULL DEFAULT ''
79
+ )
80
+ `);
81
+ const cols = this.db.pragma("table_info(audit_logs)").map((c) => c.name);
82
+ if (!cols.includes("prev_hash")) this.db.exec(`ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''`);
83
+ if (!cols.includes("hash")) this.db.exec(`ALTER TABLE audit_logs ADD COLUMN hash TEXT NOT NULL DEFAULT ''`);
84
+ }
85
+ workspacePath;
86
+ debugMode;
87
+ db;
88
+ keyPath;
89
+ dbPath;
90
+ encryptionKey;
91
+ /** SHA-256 link over everything that identifies an entry, chained to the previous entry's hash. */
92
+ chainHash(prevHash, timestamp, eventType, tierId, encryptedPayload) {
93
+ return crypto3.createHash("sha256").update(`${prevHash}|${timestamp}|${eventType}|${tierId}|${encryptedPayload}`).digest("hex");
94
+ }
95
+ initEncryptionKey() {
96
+ if (fs19.existsSync(this.keyPath)) {
97
+ this.encryptionKey = fs19.readFileSync(this.keyPath);
98
+ } else {
99
+ this.encryptionKey = crypto3.randomBytes(32);
100
+ fs19.writeFileSync(this.keyPath, this.encryptionKey);
101
+ }
102
+ }
103
+ encrypt(text) {
104
+ const iv = crypto3.randomBytes(16);
105
+ const cipher = crypto3.createCipheriv("aes-256-gcm", this.encryptionKey, iv);
106
+ let encrypted = cipher.update(text, "utf8", "hex");
107
+ encrypted += cipher.final("hex");
108
+ const authTag = cipher.getAuthTag().toString("hex");
109
+ return `${iv.toString("hex")}:${authTag}:${encrypted}`;
110
+ }
111
+ decrypt(text) {
112
+ const parts = text.split(":");
113
+ if (parts.length !== 3) throw new Error("Invalid encrypted payload format");
114
+ const [ivHex, authTagHex, encryptedHex] = parts;
115
+ const iv = Buffer.from(ivHex, "hex");
116
+ const authTag = Buffer.from(authTagHex, "hex");
117
+ const decipher = crypto3.createDecipheriv("aes-256-gcm", this.encryptionKey, iv);
118
+ decipher.setAuthTag(authTag);
119
+ let decrypted = decipher.update(encryptedHex, "hex", "utf8");
120
+ decrypted += decipher.final("utf8");
121
+ return decrypted;
122
+ }
123
+ logEvent(eventType, tierId, payloadObj) {
124
+ const id = crypto3.randomUUID();
125
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
126
+ const payload = JSON.stringify(payloadObj);
127
+ const encryptedPayload = this.encrypt(payload);
128
+ const last = this.db.prepare("SELECT hash FROM audit_logs ORDER BY rowid DESC LIMIT 1").get();
129
+ const prevHash = last?.hash ?? "";
130
+ const hash = this.chainHash(prevHash, timestamp, eventType, tierId, encryptedPayload);
131
+ const stmt = this.db.prepare("INSERT INTO audit_logs (id, timestamp, event_type, tier_id, encrypted_payload, prev_hash, hash) VALUES (?, ?, ?, ?, ?, ?, ?)");
132
+ stmt.run(id, timestamp, eventType, tierId, encryptedPayload, prevHash, hash);
133
+ this.dumpDebugIfNeeded();
134
+ return id;
135
+ }
136
+ /**
137
+ * Recompute the whole hash chain in insertion (rowid) order. Any edited,
138
+ * removed, or reordered row makes every later hash mismatch. Rows written
139
+ * before the chain existed (empty hash) fail verification too — integrity
140
+ * can only be claimed for chained history.
141
+ */
142
+ verifyChain() {
143
+ const rows = this.db.prepare(
144
+ "SELECT rowid, timestamp, event_type, tier_id, encrypted_payload, prev_hash, hash FROM audit_logs ORDER BY rowid ASC"
145
+ ).all();
146
+ let prevHash = "";
147
+ for (const row of rows) {
148
+ const expected = this.chainHash(prevHash, row.timestamp, row.event_type, row.tier_id, row.encrypted_payload);
149
+ if (row.prev_hash !== prevHash || row.hash !== expected) {
150
+ return { ok: false, entries: rows.length, firstBadRow: row.rowid };
151
+ }
152
+ prevHash = row.hash;
153
+ }
154
+ return { ok: true, entries: rows.length };
155
+ }
156
+ getAllLogs() {
157
+ const stmt = this.db.prepare("SELECT id, timestamp, event_type, tier_id, encrypted_payload FROM audit_logs ORDER BY timestamp ASC");
158
+ const rows = stmt.all();
159
+ return rows.map((row) => {
160
+ let payload = "";
161
+ try {
162
+ payload = this.decrypt(row.encrypted_payload);
163
+ } catch (err) {
164
+ payload = '{"error":"[Decryption Failed - Payload Corrupted]"}';
165
+ }
166
+ return {
167
+ id: row.id,
168
+ timestamp: row.timestamp,
169
+ eventType: row.event_type,
170
+ tierId: row.tier_id,
171
+ payload
172
+ };
173
+ });
174
+ }
175
+ dumpDebugIfNeeded() {
176
+ if (!this.debugMode) return;
177
+ try {
178
+ const dumpPath = path20.join(os6.tmpdir(), "cascade_audit_logs_debug.json");
179
+ const entries = this.getAllLogs();
180
+ fs19.writeFileSync(dumpPath, JSON.stringify(entries, null, 2), "utf-8");
181
+ } catch (err) {
182
+ console.error("Failed to dump debug audit logs", err);
183
+ }
184
+ }
185
+ close() {
186
+ this.db.close();
187
+ }
188
+ };
189
+ }
190
+ });
40
191
 
41
192
  // src/constants.ts
42
- var CASCADE_VERSION = "0.12.24";
193
+ var CASCADE_VERSION = "0.13.2";
43
194
  var CASCADE_CONFIG_DIR = ".cascade";
44
195
  var CASCADE_MD_FILE = "CASCADE.md";
45
196
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -1971,7 +2122,7 @@ function computeDelegationSavings(stats, t1Model) {
1971
2122
  var DEFAULT_SNAPSHOT_URL = "https://raw.githubusercontent.com/Varun-SV/Cascade-AI/main/src/core/router/benchmark-data.json";
1972
2123
  var OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
1973
2124
  var FETCH_TIMEOUT_MS = 8e3;
1974
- var DEFAULT_CACHE_FILE = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, "benchmarks-cache.json");
2125
+ var DEFAULT_CACHE_FILE = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, "benchmarks-cache.json");
1975
2126
  function normalizeModelId(id) {
1976
2127
  let s = id.toLowerCase();
1977
2128
  const slash = s.lastIndexOf("/");
@@ -2086,7 +2237,7 @@ var LiveDataProvider = class {
2086
2237
  }
2087
2238
  async saveCache() {
2088
2239
  try {
2089
- await fs4.mkdir(path18.dirname(this.opts.cacheFile), { recursive: true });
2240
+ await fs4.mkdir(path20.dirname(this.opts.cacheFile), { recursive: true });
2090
2241
  const cache = {
2091
2242
  fetchedAt: this.fetchedAt,
2092
2243
  snapshot: this.snapshot ?? void 0,
@@ -2216,7 +2367,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2216
2367
  costByTier: {},
2217
2368
  tokensByTier: {},
2218
2369
  inputTokensByTier: {},
2219
- outputTokensByTier: {}
2370
+ outputTokensByTier: {},
2371
+ costByFeature: {}
2220
2372
  };
2221
2373
  tierModels = /* @__PURE__ */ new Map();
2222
2374
  config;
@@ -2238,6 +2390,9 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2238
2390
  tpmLimiter;
2239
2391
  localQueue;
2240
2392
  taskAnalyzer;
2393
+ worldStateDB;
2394
+ privacyPaths;
2395
+ guidanceQueue;
2241
2396
  liveData;
2242
2397
  /** Snapshot of configured/default tier models, taken before Cascade Auto overrides them. */
2243
2398
  originalTierModels;
@@ -2395,7 +2550,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2395
2550
  if (options.model && !requireVision) {
2396
2551
  this.ensureProvider(options.model, this.config.providers);
2397
2552
  }
2398
- const model = requireVision ? this.selector.selectVisionModel() : options.model ?? this.tierModels.get(tier);
2553
+ let model = requireVision ? this.selector.selectVisionModel() : options.model ?? this.tierModels.get(tier);
2554
+ if (options.forceLocal && model && !this.isPrivateModel(model)) {
2555
+ const localModel = this.selector.getAllAvailableModels().find((m) => this.isPrivateModel(m));
2556
+ if (!localModel) {
2557
+ throw new Error(
2558
+ "privacy.paths: this subtask touches a local-only path but no LOCAL model is available. Configure Ollama or an OpenAI-compatible endpoint on a loopback/private host, or remove the privacy policy."
2559
+ );
2560
+ }
2561
+ this.ensureProvider(localModel, this.config.providers);
2562
+ model = localModel;
2563
+ }
2399
2564
  if (!model) throw new Error(`No model available for tier ${tier}`);
2400
2565
  const provider = this.getProvider(model);
2401
2566
  if (!provider) throw new Error(`No provider for model ${model.id}`);
@@ -2467,7 +2632,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2467
2632
  if (!result || typeof result.content !== "string" || !result.usage) {
2468
2633
  throw new Error(`Provider ${model.provider}:${model.id} returned an invalid generation result.`);
2469
2634
  }
2470
- this.recordStats(tier, model, result.usage);
2635
+ this.recordStats(tier, model, result.usage, options.featureTag);
2471
2636
  this.failover.recordSuccess(model.provider);
2472
2637
  return result;
2473
2638
  } catch (err) {
@@ -2572,6 +2737,42 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2572
2737
  setTaskAnalyzer(analyzer) {
2573
2738
  this.taskAnalyzer = analyzer;
2574
2739
  }
2740
+ setWorldStateDB(db) {
2741
+ this.worldStateDB = db;
2742
+ }
2743
+ getWorldStateDB() {
2744
+ return this.worldStateDB;
2745
+ }
2746
+ setPrivacyPaths(paths) {
2747
+ this.privacyPaths = paths;
2748
+ }
2749
+ getPrivacyPaths() {
2750
+ return this.privacyPaths;
2751
+ }
2752
+ setGuidanceQueue(queue) {
2753
+ this.guidanceQueue = queue;
2754
+ }
2755
+ getGuidanceQueue() {
2756
+ return this.guidanceQueue;
2757
+ }
2758
+ /**
2759
+ * "Private" = inference never leaves the user's machine/network: Ollama
2760
+ * models (isLocal), or an OpenAI-compatible endpoint (e.g. llama.cpp, vLLM,
2761
+ * LM Studio) whose configured host is loopback or a private range. A cloud
2762
+ * OpenAI-compatible endpoint (public host) does NOT qualify.
2763
+ */
2764
+ isPrivateModel(model) {
2765
+ if (model.isLocal) return true;
2766
+ if (model.provider !== "openai-compatible") return false;
2767
+ const baseUrl = this.config?.providers?.find((p) => p.type === "openai-compatible")?.baseUrl;
2768
+ if (!baseUrl) return false;
2769
+ try {
2770
+ const host = new URL(baseUrl).hostname.toLowerCase();
2771
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) || host.endsWith(".local");
2772
+ } catch {
2773
+ return false;
2774
+ }
2775
+ }
2575
2776
  /**
2576
2777
  * Cascade Auto per-subtask routing: pick the benchmark-best model for a
2577
2778
  * specific subtask's text, scoped to the tier's eligible candidates. Returns
@@ -2595,7 +2796,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2595
2796
  costByTier: { ...this.stats.costByTier },
2596
2797
  tokensByTier: { ...this.stats.tokensByTier },
2597
2798
  inputTokensByTier: { ...this.stats.inputTokensByTier },
2598
- outputTokensByTier: { ...this.stats.outputTokensByTier }
2799
+ outputTokensByTier: { ...this.stats.outputTokensByTier },
2800
+ costByFeature: { ...this.stats.costByFeature }
2599
2801
  };
2600
2802
  }
2601
2803
  /**
@@ -2645,7 +2847,8 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2645
2847
  costByTier: {},
2646
2848
  tokensByTier: {},
2647
2849
  inputTokensByTier: {},
2648
- outputTokensByTier: {}
2850
+ outputTokensByTier: {},
2851
+ costByFeature: {}
2649
2852
  };
2650
2853
  this.sessionCostUsd = 0;
2651
2854
  this.budgetState = "ok";
@@ -2811,7 +3014,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2811
3014
  }
2812
3015
  return void 0;
2813
3016
  }
2814
- recordStats(tier, model, usage) {
3017
+ recordStats(tier, model, usage, featureTag) {
2815
3018
  this.stats.totalTokens += usage.totalTokens;
2816
3019
  this.stats.totalCostUsd += usage.estimatedCostUsd;
2817
3020
  this.sessionCostUsd += usage.estimatedCostUsd;
@@ -2821,6 +3024,9 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
2821
3024
  this.stats.tokensByTier[tier] = (this.stats.tokensByTier[tier] ?? 0) + usage.totalTokens;
2822
3025
  this.stats.inputTokensByTier[tier] = (this.stats.inputTokensByTier[tier] ?? 0) + usage.inputTokens;
2823
3026
  this.stats.outputTokensByTier[tier] = (this.stats.outputTokensByTier[tier] ?? 0) + usage.outputTokens;
3027
+ if (featureTag) {
3028
+ this.stats.costByFeature[featureTag] = (this.stats.costByFeature[featureTag] ?? 0) + usage.estimatedCostUsd;
3029
+ }
2824
3030
  this.runTokens += usage.totalTokens;
2825
3031
  this.runCostUsd += usage.estimatedCostUsd;
2826
3032
  this.updateBudgetState();
@@ -2917,6 +3123,13 @@ var BaseTier = class extends EventEmitter {
2917
3123
  hierarchyContext = "";
2918
3124
  /** Propagated AbortSignal — set by the tier's `execute()` before work begins. */
2919
3125
  signal;
3126
+ /**
3127
+ * True for the run's ROOT tier (T3 for Simple, T2 for Moderate, T1 for
3128
+ * Complex). Its own synthesis stream is the user-facing answer and is
3129
+ * tagged `primary` so the desktop renders it live — background workers,
3130
+ * which would interleave, are not tagged.
3131
+ */
3132
+ isPresenter = false;
2920
3133
  constructor(role, id, parentId) {
2921
3134
  super();
2922
3135
  this.role = role;
@@ -2924,6 +3137,10 @@ var BaseTier = class extends EventEmitter {
2924
3137
  this.parentId = parentId;
2925
3138
  this.label = this.id;
2926
3139
  }
3140
+ /** Mark this tier as the run's presenter (root tier). */
3141
+ setPresenter(on = true) {
3142
+ this.isPresenter = on;
3143
+ }
2927
3144
  getStatus() {
2928
3145
  return this.status;
2929
3146
  }
@@ -3357,6 +3574,8 @@ var T3Worker = class extends BaseTier {
3357
3574
  toolRegistry;
3358
3575
  context;
3359
3576
  assignment;
3577
+ /** True when this subtask matched a privacy.paths local-only pattern. */
3578
+ localOnlyMatch = false;
3360
3579
  peerSyncBuffer = [];
3361
3580
  store;
3362
3581
  audit;
@@ -3412,6 +3631,11 @@ var T3Worker = class extends BaseTier {
3412
3631
  this.taskId = taskId;
3413
3632
  this.setLabel(assignment.subtaskTitle);
3414
3633
  this.setStatus("ACTIVE");
3634
+ const privacy = this.router.getPrivacyPaths?.();
3635
+ this.localOnlyMatch = !!privacy?.hasPolicies() && privacy.anyLocalOnly(this.extractArtifactPaths(assignment));
3636
+ if (this.localOnlyMatch) {
3637
+ this.log("Privacy: subtask touches a local-only path \u2014 forcing a private model; raw output will be withheld upstream.");
3638
+ }
3415
3639
  this.tools = this.toolRegistry.getToolDefinitions();
3416
3640
  if (this.reinforcementDepth === 0 && this.router.getReinforcementsConfig?.()?.enabled) {
3417
3641
  this.tools = [...this.tools, {
@@ -3542,9 +3766,17 @@ Now execute your subtask using this context where relevant.`
3542
3766
  }
3543
3767
  const reflectCfg = this.router.getReflectionConfig?.() ?? { enabled: false, maxRounds: 1 };
3544
3768
  if (reflectCfg.enabled) {
3545
- this.sendStatusUpdate({ progressPct: 85, currentAction: "Reflecting on output", status: "IN_PROGRESS" });
3769
+ this.sendStatusUpdate({ progressPct: 85, currentAction: "Reflecting on output via T2-Critic", status: "IN_PROGRESS" });
3546
3770
  output = await this.reflectAndImprove(assignment, output, reflectCfg.maxRounds);
3547
3771
  }
3772
+ const db = this.router.getWorldStateDB?.();
3773
+ if (db) {
3774
+ try {
3775
+ db.addEntry(this.id, `Completed: ${assignment.subtaskTitle}. Output length: ${output.length} chars.`);
3776
+ } catch (e) {
3777
+ this.log("Failed to write to World State DB");
3778
+ }
3779
+ }
3548
3780
  this.setStatus("COMPLETED", output);
3549
3781
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
3550
3782
  this.peerBus?.publish(this.id, assignment.subtaskId, output, "COMPLETED");
@@ -3617,6 +3849,16 @@ Now execute your subtask using this context where relevant.`
3617
3849
  while (iterations < MAX_ITERATIONS) {
3618
3850
  iterations++;
3619
3851
  this.throwIfCancelled();
3852
+ const guidance = this.router.getGuidanceQueue?.()?.drain(this.id) ?? [];
3853
+ for (const g of guidance) {
3854
+ this.log(`User intervention received: ${g.text}`);
3855
+ this.sendStatusUpdate({ progressPct: 50, currentAction: "Applying user intervention", status: "IN_PROGRESS" });
3856
+ await this.context.addMessage({
3857
+ role: "user",
3858
+ content: `USER INTERVENTION (mid-run steering \u2014 follow this over prior instructions where they conflict):
3859
+ ${g.text}`
3860
+ });
3861
+ }
3620
3862
  const options = {
3621
3863
  messages: this.context.getMessages(),
3622
3864
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
@@ -3625,13 +3867,15 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
3625
3867
  // Don't pass tools array when model can't use them natively
3626
3868
  tools: useTextTools ? void 0 : tools.length ? tools : void 0,
3627
3869
  maxTokens: 4096,
3628
- ...subtaskModel ? { model: subtaskModel } : {}
3870
+ ...subtaskModel ? { model: subtaskModel } : {},
3871
+ featureTag: this.assignment?.sectionTitle,
3872
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
3629
3873
  };
3630
3874
  const result = await this.router.generate(
3631
3875
  "T3",
3632
3876
  options,
3633
3877
  (chunk) => {
3634
- this.emit("stream:token", { tierId: this.id, text: chunk.text });
3878
+ this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: this.isPresenter });
3635
3879
  }
3636
3880
  );
3637
3881
  let effectiveToolCalls = result.toolCalls ?? [];
@@ -3789,8 +4033,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
3789
4033
  tierId: this.id,
3790
4034
  sessionId: this.taskId,
3791
4035
  requireApproval: false,
3792
- saveSnapshot: async (path19, content) => {
3793
- this.store?.addFileSnapshot(this.taskId, path19, content);
4036
+ saveSnapshot: async (path21, content) => {
4037
+ this.store?.addFileSnapshot(this.taskId, path21, content);
3794
4038
  },
3795
4039
  sendPeerSync: (to, syncType, content) => {
3796
4040
  this.peerBus?.send(this.id, to, syncType, this.assignment?.subtaskId ?? "", content);
@@ -3960,7 +4204,7 @@ ${assignment.expectedOutput}`;
3960
4204
  const { promisify: promisify4 } = await import('util');
3961
4205
  const execAsync2 = promisify4(exec2);
3962
4206
  for (const artifactPath of artifactPaths) {
3963
- const absolutePath = path18.resolve(process.cwd(), artifactPath);
4207
+ const absolutePath = path20.resolve(process.cwd(), artifactPath);
3964
4208
  try {
3965
4209
  const stat = await fs4.stat(absolutePath);
3966
4210
  if (!stat.isFile()) {
@@ -3981,7 +4225,7 @@ ${assignment.expectedOutput}`;
3981
4225
  issues.push(`PDF artifact looks too small to be valid: ${artifactPath}`);
3982
4226
  continue;
3983
4227
  }
3984
- const ext = path18.extname(absolutePath).toLowerCase();
4228
+ const ext = path20.extname(absolutePath).toLowerCase();
3985
4229
  try {
3986
4230
  if (ext === ".ts" || ext === ".tsx") {
3987
4231
  await execAsync2(`npx tsc --noEmit ${absolutePath}`, { timeout: 1e4 });
@@ -4010,30 +4254,35 @@ ${stdout}`);
4010
4254
  * needed. Best-effort: any parse/error just keeps the current output.
4011
4255
  */
4012
4256
  async reflectAndImprove(assignment, output, maxRounds) {
4013
- const sys = this.systemPromptOverride + (this.hierarchyContext ? `
4014
-
4015
- HIERARCHY CONTEXT: ${this.hierarchyContext}` : "");
4016
4257
  let current = output;
4017
- for (let round = 0; round < Math.max(1, maxRounds); round++) {
4018
- try {
4019
- const verdict = await this.router.generate("T3", {
4258
+ try {
4259
+ for (let round = 0; round < Math.max(1, maxRounds); round++) {
4260
+ const verdictResult = await this.router.generate("T2", {
4020
4261
  messages: [{
4021
4262
  role: "user",
4022
- content: `Does this output FULLY achieve the goal \u2014 not just the literal task, but the intent behind it?
4263
+ content: `You are an independent critic reviewing another worker's output against its assignment.
4023
4264
 
4024
- Goal / expected: ${assignment.expectedOutput}
4265
+ Goal: ${assignment.expectedOutput}
4025
4266
  Subtask: ${assignment.description}
4026
-
4027
- Output:
4267
+ Current Output:
4028
4268
  ${current}
4029
4269
 
4030
- Reply with ONLY JSON: {"sufficient": true|false, "notes": "what is weak or missing if not sufficient"}`
4270
+ Is this output sufficient and correct? Respond with ONLY a JSON object:
4271
+ {"sufficient": true|false, "notes": "what is wrong or missing if false"}`
4031
4272
  }],
4032
- systemPrompt: sys,
4033
- maxTokens: 400
4273
+ systemPrompt: "You are a T2-Critic reviewing a T3 Worker's output. Judge strictly against the stated goal.",
4274
+ maxTokens: 400,
4275
+ signal: this.signal,
4276
+ featureTag: assignment.sectionTitle,
4277
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
4034
4278
  });
4035
- const parsed = JSON.parse(/\{[\s\S]*\}/.exec(verdict.content)?.[0] ?? "{}");
4036
- if (parsed.sufficient !== false) break;
4279
+ const match = /\{[\s\S]*\}/.exec(verdictResult.content);
4280
+ const parsed = match ? JSON.parse(match[0]) : { sufficient: true };
4281
+ if (parsed.sufficient !== false) {
4282
+ this.log("T2-Critic approved output.");
4283
+ break;
4284
+ }
4285
+ this.log(`T2-Critic rejected output: ${parsed.notes}`);
4037
4286
  const improved = await this.router.generate("T3", {
4038
4287
  messages: [{
4039
4288
  role: "user",
@@ -4045,16 +4294,20 @@ Goal / expected: ${assignment.expectedOutput}
4045
4294
  Current output:
4046
4295
  ${current}`
4047
4296
  }],
4048
- systemPrompt: sys,
4049
- maxTokens: 4096
4297
+ systemPrompt: this.systemPromptOverride + (this.hierarchyContext ? `
4298
+
4299
+ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4300
+ maxTokens: 4096,
4301
+ featureTag: assignment.sectionTitle,
4302
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
4050
4303
  });
4051
4304
  const next = (improved.content ?? "").trim();
4052
4305
  if (!next) break;
4053
4306
  current = next;
4054
4307
  this.log("Reflection: revised output for better goal alignment.");
4055
- } catch {
4056
- break;
4057
4308
  }
4309
+ } catch (e) {
4310
+ this.log(`T2-Critic reflection failed: ${e}`);
4058
4311
  }
4059
4312
  return current;
4060
4313
  }
@@ -4075,7 +4328,9 @@ Reply with JSON: { "completeness": "pass"|"fail", "correctness": "pass"|"fail",
4075
4328
  maxTokens: 500,
4076
4329
  systemPrompt: this.systemPromptOverride + (this.hierarchyContext ? `
4077
4330
 
4078
- HIERARCHY CONTEXT: ${this.hierarchyContext}` : "")
4331
+ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4332
+ featureTag: assignment.sectionTitle,
4333
+ ...this.localOnlyMatch ? { forceLocal: true } : {}
4079
4334
  });
4080
4335
  try {
4081
4336
  const jsonMatch = /\{[\s\S]*\}/.exec(testResult.content);
@@ -4170,6 +4425,7 @@ Begin execution now.`;
4170
4425
  issues,
4171
4426
  peerSyncsUsed: this.peerSyncBuffer.map((m) => m.fromId),
4172
4427
  correctionAttempts,
4428
+ localOnly: this.localOnlyMatch || void 0,
4173
4429
  reinforcements: this.pendingReinforcements.length ? this.pendingReinforcements : void 0
4174
4430
  };
4175
4431
  }
@@ -4461,6 +4717,42 @@ var PeerBus = class extends EventEmitter {
4461
4717
  }
4462
4718
  };
4463
4719
 
4720
+ // src/core/audit/redaction.ts
4721
+ var RedactionLayer = class {
4722
+ // Regexes for common secrets/PII
4723
+ static RULES = [
4724
+ // IPv4 addresses (basic approximation)
4725
+ { pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g, replacement: "[REDACTED_IP]" },
4726
+ // Generic API keys/Secrets (looks like a long random hex or b64 string preceded by key/secret/token)
4727
+ { pattern: /(?:\b(?:api_key|apikey|secret|token|password|bearer|auth|authorization)\b[^a-zA-Z0-9_]{1,4})([a-zA-Z0-9_\-\.]{16,})/gi, replacement: "$1[REDACTED_SECRET]" },
4728
+ // Email addresses
4729
+ { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b/g, replacement: "[REDACTED_EMAIL]" },
4730
+ // Phone numbers (simplistic)
4731
+ { pattern: /\b(?:\+\d{1,3}[- ]?)?\(?\d{3}\)?[- ]?\d{3}[- ]?\d{4}\b/g, replacement: "[REDACTED_PHONE]" },
4732
+ // AWS Access Key ID
4733
+ { pattern: /\b(AKIA[0-9A-Z]{16})\b/g, replacement: "[REDACTED_AWS_AK]" }
4734
+ ];
4735
+ /**
4736
+ * Applies all redaction rules to the input string.
4737
+ */
4738
+ static redact(text) {
4739
+ if (!text) return text;
4740
+ let redacted = text;
4741
+ for (const rule of this.RULES) {
4742
+ if (rule.pattern.test(redacted)) {
4743
+ rule.pattern.lastIndex = 0;
4744
+ redacted = redacted.replace(rule.pattern, (match, p1, offset, str) => {
4745
+ if (p1 && match.includes(p1) && p1 !== match) {
4746
+ return match.replace(p1, rule.replacement.replace("$1", ""));
4747
+ }
4748
+ return rule.replacement;
4749
+ });
4750
+ }
4751
+ }
4752
+ return redacted;
4753
+ }
4754
+ };
4755
+
4464
4756
  // src/core/tiers/t2-manager.ts
4465
4757
  var T2_SYSTEM_PROMPT = `You are a T2 Manager agent in the Cascade AI system.
4466
4758
  Your role is to analyze a section of a task and decompose it into 2-5 discrete subtasks for T3 Workers.
@@ -4689,7 +4981,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4689
4981
  try {
4690
4982
  const jsonMatch = /\[[\s\S]*\]/.exec(result.content);
4691
4983
  if (!jsonMatch) throw new Error("No JSON array found");
4692
- return JSON.parse(jsonMatch[0]);
4984
+ const parsed = JSON.parse(jsonMatch[0]);
4985
+ return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle }));
4693
4986
  } catch {
4694
4987
  return [{
4695
4988
  subtaskId: randomUUID(),
@@ -4699,6 +4992,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4699
4992
  constraints: assignment.constraints,
4700
4993
  peerT3Ids: [],
4701
4994
  parentT2: this.id,
4995
+ sectionTitle: assignment.sectionTitle,
4996
+ dependsOn: [],
4702
4997
  executionMode: "parallel"
4703
4998
  }];
4704
4999
  }
@@ -4818,6 +5113,14 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4818
5113
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
4819
5114
  const worker = workerMap.get(id);
4820
5115
  const result = await worker.execute(assignment, taskId, waveSignal);
5116
+ if (result.localOnly) {
5117
+ result.output = `[local-only path \u2014 output withheld by privacy policy; status: ${result.status}; checks passed: ${result.testResults.passed.length}/${result.testResults.checksRun.length || 0}]`;
5118
+ } else {
5119
+ if (typeof result.output === "string" && result.output) {
5120
+ result.output = RedactionLayer.redact(result.output);
5121
+ }
5122
+ }
5123
+ if (result.issues) result.issues = result.issues.map((i) => RedactionLayer.redact(i));
4821
5124
  resultMap.set(id, result);
4822
5125
  return result;
4823
5126
  };
@@ -5033,6 +5336,7 @@ ${peerOutputs}` : "";
5033
5336
  chunkEnd++;
5034
5337
  }
5035
5338
  i = chunkEnd;
5339
+ const isLastChunk = chunkEnd >= completed.length;
5036
5340
  const prompt = `Summarize these T3 worker outputs for section "${assignment.sectionTitle}" in 2-3 sentences.
5037
5341
  ${currentSummary ? `
5038
5342
  PREVIOUS SUMMARY SO FAR:
@@ -5042,6 +5346,7 @@ NEW OUTPUTS TO INTEGRATE:
5042
5346
  ` : "\nOUTPUTS:\n"}${chunkText}${peerContext}`;
5043
5347
  const messages = [{ role: "user", content: prompt }];
5044
5348
  try {
5349
+ const streamFinal = isLastChunk && this.isPresenter ? (chunk) => this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: true }) : void 0;
5045
5350
  const result = await this.router.generate("T2", {
5046
5351
  messages,
5047
5352
  systemPrompt: this.systemPromptOverride + "You are a T2 Manager. Summarize the work of your T3 workers succinctly." + (this.hierarchyContext ? `
@@ -5049,7 +5354,7 @@ NEW OUTPUTS TO INTEGRATE:
5049
5354
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
5050
5355
  maxTokens: 500,
5051
5356
  ...this.sectionModel ? { model: this.sectionModel } : {}
5052
- });
5357
+ }, streamFinal);
5053
5358
  currentSummary = result.content;
5054
5359
  } catch (err) {
5055
5360
  this.log(`aggregateResults: LLM summarization failed at chunk \u2014 returning raw T3 outputs. Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -5101,14 +5406,11 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
5101
5406
  ...this.sectionModel ? { model: this.sectionModel } : {}
5102
5407
  });
5103
5408
  const answer = result.content.trim().toUpperCase();
5104
- if (answer.includes("YES")) {
5105
- return { requestId: req.id, approved: true, always: true, decidedBy: "T2", reasoning: "T2 LLM evaluated: consistent with section goal" };
5106
- }
5107
- if (answer.includes("NO")) {
5108
- return { requestId: req.id, approved: false, always: true, decidedBy: "T2", reasoning: "T2 LLM evaluated: inconsistent with section goal" };
5109
- }
5409
+ const verdict = answer.includes("YES") ? "approve" : answer.includes("NO") ? "deny" : "unsure";
5410
+ (req.trail ??= []).push({ tier: "T2", verdict, reason: `T2: ${verdict === "approve" ? "consistent with section goal" : verdict === "deny" ? "inconsistent with section goal" : "unsure"}` });
5110
5411
  return null;
5111
5412
  } catch {
5413
+ (req.trail ??= []).push({ tier: "T2", verdict: "unsure", reason: "T2 evaluation failed" });
5112
5414
  return null;
5113
5415
  }
5114
5416
  }
@@ -5447,10 +5749,15 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
5447
5749
  }
5448
5750
  }
5449
5751
  async decomposeTask(prompt, systemContext) {
5752
+ const db = this.router.getWorldStateDB?.();
5753
+ const worldStateContext = db ? `
5754
+
5755
+ PROJECT WORLD STATE (Current architecture and recent changes):
5756
+ ${db.getFormattedState()}` : "";
5450
5757
  const contextSection = systemContext ? `
5451
5758
  Project context:
5452
5759
  ${systemContext}` : "";
5453
- const decompositionPrompt = `Analyze this task and create an execution plan.${contextSection}
5760
+ const decompositionPrompt = `Analyze this task and create an execution plan.${contextSection}${worldStateContext}
5454
5761
 
5455
5762
  Task: ${prompt}
5456
5763
 
@@ -5782,7 +6089,7 @@ Instructions:
5782
6089
  systemPrompt: this.systemPromptOverride + "You are a final output compiler. Summarize and format the task results clearly.",
5783
6090
  maxTokens: 8e3
5784
6091
  }, (chunk) => {
5785
- this.emit("stream:token", { tierId: this.id, text: chunk.text });
6092
+ this.emit("stream:token", { tierId: this.id, text: chunk.text, primary: this.isPresenter });
5786
6093
  });
5787
6094
  return result.content;
5788
6095
  }
@@ -5811,14 +6118,11 @@ Reply with exactly one word: YES, NO, or UNSURE.
5811
6118
  temperature: 0
5812
6119
  });
5813
6120
  const answer = result.content.trim().toUpperCase();
5814
- if (answer.includes("YES")) {
5815
- return { requestId: req.id, approved: true, always: true, decidedBy: "T1", reasoning: "T1 evaluated: consistent with overall task goal" };
5816
- }
5817
- if (answer.includes("NO")) {
5818
- return { requestId: req.id, approved: false, always: true, decidedBy: "T1", reasoning: "T1 evaluated: not consistent with overall task goal" };
5819
- }
6121
+ const verdict = answer.includes("YES") ? "approve" : answer.includes("NO") ? "deny" : "unsure";
6122
+ (req.trail ??= []).push({ tier: "T1", verdict, reason: `T1: ${verdict === "approve" ? "consistent with overall task goal" : verdict === "deny" ? "not consistent with overall task goal" : "unsure"}` });
5820
6123
  return null;
5821
6124
  } catch {
6125
+ (req.trail ??= []).push({ tier: "T1", verdict: "unsure", reason: "T1 evaluation failed" });
5822
6126
  return null;
5823
6127
  }
5824
6128
  }
@@ -5932,16 +6236,16 @@ function resolveInWorkspace(workspaceRoot, input) {
5932
6236
  if (typeof input !== "string" || input.length === 0) {
5933
6237
  throw new WorkspaceSandboxError(String(input), workspaceRoot);
5934
6238
  }
5935
- const root = path18.resolve(workspaceRoot);
5936
- const abs = path18.isAbsolute(input) ? path18.resolve(input) : path18.resolve(root, input);
5937
- const rel = path18.relative(root, abs);
5938
- if (rel === "" || rel === ".") ; else if (rel.startsWith("..") || path18.isAbsolute(rel)) {
6239
+ const root = path20.resolve(workspaceRoot);
6240
+ const abs = path20.isAbsolute(input) ? path20.resolve(input) : path20.resolve(root, input);
6241
+ const rel = path20.relative(root, abs);
6242
+ if (rel === "" || rel === ".") ; else if (rel.startsWith("..") || path20.isAbsolute(rel)) {
5939
6243
  throw new WorkspaceSandboxError(input, root);
5940
6244
  }
5941
6245
  try {
5942
- const real = fs17.realpathSync(abs);
5943
- const realRel = path18.relative(root, real);
5944
- if (realRel !== "" && realRel !== "." && (realRel.startsWith("..") || path18.isAbsolute(realRel))) {
6246
+ const real = fs19.realpathSync(abs);
6247
+ const realRel = path20.relative(root, real);
6248
+ if (realRel !== "" && realRel !== "." && (realRel.startsWith("..") || path20.isAbsolute(realRel))) {
5945
6249
  throw new WorkspaceSandboxError(input, root);
5946
6250
  }
5947
6251
  } catch (e) {
@@ -6002,7 +6306,7 @@ var FileWriteTool = class extends BaseTool {
6002
6306
  } catch {
6003
6307
  }
6004
6308
  }
6005
- await fs4.mkdir(path18.dirname(absPath), { recursive: true });
6309
+ await fs4.mkdir(path20.dirname(absPath), { recursive: true });
6006
6310
  await fs4.writeFile(absPath, content, "utf-8");
6007
6311
  return `Written ${content.length} characters to ${filePath}`;
6008
6312
  }
@@ -6500,7 +6804,7 @@ var ImageAnalyzeTool = class extends BaseTool {
6500
6804
  };
6501
6805
  async function fileToImageAttachment(filePath) {
6502
6806
  const data = await fs4.readFile(filePath);
6503
- const ext = path18.extname(filePath).toLowerCase();
6807
+ const ext = path20.extname(filePath).toLowerCase();
6504
6808
  const mimeMap = {
6505
6809
  ".jpg": "image/jpeg",
6506
6810
  ".jpeg": "image/jpeg",
@@ -6534,14 +6838,14 @@ var PDFCreateTool = class extends BaseTool {
6534
6838
  const filePath = input["path"];
6535
6839
  const content = input["content"];
6536
6840
  const title = input["title"];
6537
- const dir = path18.dirname(filePath);
6538
- if (!fs17.existsSync(dir)) {
6539
- fs17.mkdirSync(dir, { recursive: true });
6841
+ const dir = path20.dirname(filePath);
6842
+ if (!fs19.existsSync(dir)) {
6843
+ fs19.mkdirSync(dir, { recursive: true });
6540
6844
  }
6541
6845
  return new Promise((resolve, reject) => {
6542
6846
  try {
6543
6847
  const doc = new PDFDocument({ margin: 50 });
6544
- const stream = fs17.createWriteStream(filePath);
6848
+ const stream = fs19.createWriteStream(filePath);
6545
6849
  doc.pipe(stream);
6546
6850
  if (title) {
6547
6851
  doc.info["Title"] = title;
@@ -6619,22 +6923,22 @@ var CodeInterpreterTool = class extends BaseTool {
6619
6923
  }
6620
6924
  cmdPrefix = NODE_CMD;
6621
6925
  }
6622
- const tmpDir = path18.join(this.workspaceRoot, ".cascade", "tmp");
6623
- if (!fs17.existsSync(tmpDir)) {
6624
- fs17.mkdirSync(tmpDir, { recursive: true });
6926
+ const tmpDir = path20.join(this.workspaceRoot, ".cascade", "tmp");
6927
+ if (!fs19.existsSync(tmpDir)) {
6928
+ fs19.mkdirSync(tmpDir, { recursive: true });
6625
6929
  }
6626
6930
  const extension = language === "python" ? "py" : "js";
6627
6931
  const fileName = `intp_${randomUUID().slice(0, 8)}.${extension}`;
6628
- const filePath = path18.join(tmpDir, fileName);
6629
- fs17.writeFileSync(filePath, code, "utf-8");
6932
+ const filePath = path20.join(tmpDir, fileName);
6933
+ fs19.writeFileSync(filePath, code, "utf-8");
6630
6934
  const execArgs = [filePath, ...args];
6631
6935
  return new Promise((resolve) => {
6632
6936
  const startMs = Date.now();
6633
6937
  execFile(cmdPrefix, execArgs, { cwd: this.workspaceRoot, timeout: 3e4 }, (error, stdout, stderr) => {
6634
6938
  const duration = Date.now() - startMs;
6635
6939
  try {
6636
- if (fs17.existsSync(filePath)) {
6637
- fs17.unlinkSync(filePath);
6940
+ if (fs19.existsSync(filePath)) {
6941
+ fs19.unlinkSync(filePath);
6638
6942
  }
6639
6943
  } catch (cleanupErr) {
6640
6944
  console.error(`Failed to cleanup interpreter script ${filePath}:`, cleanupErr);
@@ -6913,7 +7217,7 @@ var GlobTool = class extends BaseTool {
6913
7217
  };
6914
7218
  async execute(input, _options) {
6915
7219
  const pattern = input["pattern"];
6916
- const searchPath = input["path"] ? path18.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
7220
+ const searchPath = input["path"] ? path20.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
6917
7221
  const matches = await glob(pattern, {
6918
7222
  cwd: searchPath,
6919
7223
  ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
@@ -6926,7 +7230,7 @@ var GlobTool = class extends BaseTool {
6926
7230
  const withMtime = await Promise.all(
6927
7231
  matches.map(async (rel) => {
6928
7232
  try {
6929
- const stat = await fs4.stat(path18.join(searchPath, rel));
7233
+ const stat = await fs4.stat(path20.join(searchPath, rel));
6930
7234
  return { rel, mtime: stat.mtimeMs };
6931
7235
  } catch {
6932
7236
  return { rel, mtime: 0 };
@@ -6975,7 +7279,7 @@ var GrepTool = class extends BaseTool {
6975
7279
  };
6976
7280
  async execute(input, _options) {
6977
7281
  const pattern = input["pattern"];
6978
- const searchPath = input["path"] ? path18.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
7282
+ const searchPath = input["path"] ? path20.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
6979
7283
  const globPattern = input["glob"];
6980
7284
  const outputMode = input["output_mode"] ?? "content";
6981
7285
  const context = input["context"] ?? 0;
@@ -7029,12 +7333,12 @@ var GrepTool = class extends BaseTool {
7029
7333
  nodir: true
7030
7334
  });
7031
7335
  } catch {
7032
- files = [path18.relative(searchPath, searchPath) || "."];
7336
+ files = [path20.relative(searchPath, searchPath) || "."];
7033
7337
  }
7034
7338
  const results = [];
7035
7339
  let totalCount = 0;
7036
7340
  for (const rel of files) {
7037
- const abs = path18.join(searchPath, rel);
7341
+ const abs = path20.join(searchPath, rel);
7038
7342
  let content;
7039
7343
  try {
7040
7344
  content = await fs4.readFile(abs, "utf-8");
@@ -7396,10 +7700,10 @@ var ToolRegistry = class extends EventEmitter {
7396
7700
  }
7397
7701
  isIgnored(filePath) {
7398
7702
  if (!filePath) return false;
7399
- const abs = path18.resolve(this.workspaceRoot, filePath);
7400
- const rel = path18.relative(this.workspaceRoot, abs);
7401
- if (!rel || rel.startsWith("..") || path18.isAbsolute(rel)) return true;
7402
- const posixRel = rel.split(path18.sep).join("/");
7703
+ const abs = path20.resolve(this.workspaceRoot, filePath);
7704
+ const rel = path20.relative(this.workspaceRoot, abs);
7705
+ if (!rel || rel.startsWith("..") || path20.isAbsolute(rel)) return true;
7706
+ const posixRel = rel.split(path20.sep).join("/");
7403
7707
  return this.ignoreMatcher.ignores(posixRel);
7404
7708
  }
7405
7709
  };
@@ -7516,6 +7820,9 @@ var McpClient = class _McpClient {
7516
7820
  return this.clients.has(serverName);
7517
7821
  }
7518
7822
  };
7823
+
7824
+ // src/core/cascade.ts
7825
+ init_audit_logger();
7519
7826
  var SAFE_TOOLS = /* @__PURE__ */ new Set([
7520
7827
  "file_read",
7521
7828
  "file_list",
@@ -7768,7 +8075,8 @@ var WorkspaceConfigSchema = z.object({
7768
8075
  cascadeMdPath: z.string().default("CASCADE.md"),
7769
8076
  configPath: z.string().default(".cascade/config.json"),
7770
8077
  keystorePath: z.string().default(".cascade/keystore.enc"),
7771
- auditLogPath: z.string().default(".cascade/audit.log")
8078
+ auditLogPath: z.string().default(".cascade/audit.log"),
8079
+ debugWorldState: z.boolean().default(false)
7772
8080
  });
7773
8081
  var CascadeConfigSchema = z.object({
7774
8082
  version: z.literal("1.0").default("1.0"),
@@ -7913,6 +8221,16 @@ var CascadeConfigSchema = z.object({
7913
8221
  * 'parallel' / 'sequential' — force it.
7914
8222
  */
7915
8223
  t3Execution: z.enum(["auto", "parallel", "sequential"]).default("auto"),
8224
+ /**
8225
+ * Per-path privacy tiers. A subtask touching a `local-only` path is forced
8226
+ * onto LOCAL models (never cloud) and its raw output is withheld from the
8227
+ * tiers above. Patterns use .gitignore syntax, like .cascadeignore.
8228
+ */
8229
+ privacy: z.object({
8230
+ paths: z.array(z.object({ pattern: z.string().min(1), policy: z.enum(["local-only"]) })).default([])
8231
+ }).optional(),
8232
+ /** Routing controls — forceTier pins the root tier, bypassing the classifier. */
8233
+ routing: z.object({ forceTier: z.enum(["auto", "T1", "T2", "T3"]).default("auto") }).optional(),
7916
8234
  /**
7917
8235
  * T3→T2 reinforcement: when enabled, a worker that discovers its subtask should
7918
8236
  * fan out can call the `request_workers` tool to have its T2 manager spawn
@@ -8181,9 +8499,10 @@ var TaskAnalyzer = class {
8181
8499
  analysisCache.clear();
8182
8500
  }
8183
8501
  };
8184
- var DEFAULT_STATS_FILE = path18.join(os4.homedir(), ".cascade", "model-perf.json");
8502
+ var DEFAULT_STATS_FILE = path20.join(os6.homedir(), ".cascade", "model-perf.json");
8185
8503
  var ModelPerformanceTracker = class {
8186
8504
  stats = /* @__PURE__ */ new Map();
8505
+ featureStats = /* @__PURE__ */ new Map();
8187
8506
  statsFile;
8188
8507
  loaded = false;
8189
8508
  constructor(statsFile = DEFAULT_STATS_FILE) {
@@ -8195,18 +8514,29 @@ var ModelPerformanceTracker = class {
8195
8514
  try {
8196
8515
  const raw = await fs4.readFile(this.statsFile, "utf-8");
8197
8516
  const parsed = JSON.parse(raw);
8198
- for (const [key, stat] of Object.entries(parsed)) {
8199
- this.stats.set(key, stat);
8517
+ if (parsed.models) {
8518
+ for (const [key, stat] of Object.entries(parsed.models)) this.stats.set(key, stat);
8519
+ } else {
8520
+ for (const [key, stat] of Object.entries(parsed)) {
8521
+ if (stat && typeof stat === "object" && typeof stat.successCount === "number") {
8522
+ this.stats.set(key, stat);
8523
+ }
8524
+ }
8525
+ }
8526
+ if (parsed.features) {
8527
+ for (const [key, stat] of Object.entries(parsed.features)) this.featureStats.set(key, stat);
8200
8528
  }
8201
8529
  } catch {
8202
8530
  }
8203
8531
  }
8204
8532
  async save() {
8205
8533
  try {
8206
- await fs4.mkdir(path18.dirname(this.statsFile), { recursive: true });
8207
- const obj = {};
8208
- for (const [key, stat] of this.stats) obj[key] = stat;
8209
- await fs4.writeFile(this.statsFile, JSON.stringify(obj, null, 2), "utf-8");
8534
+ await fs4.mkdir(path20.dirname(this.statsFile), { recursive: true });
8535
+ const modelsObj = {};
8536
+ const featuresObj = {};
8537
+ for (const [key, stat] of this.stats) modelsObj[key] = stat;
8538
+ for (const [key, stat] of this.featureStats) featuresObj[key] = stat;
8539
+ await fs4.writeFile(this.statsFile, JSON.stringify({ models: modelsObj, features: featuresObj }, null, 2), "utf-8");
8210
8540
  } catch {
8211
8541
  }
8212
8542
  }
@@ -8227,6 +8557,13 @@ var ModelPerformanceTracker = class {
8227
8557
  sampleCount: s.sampleCount + 1
8228
8558
  });
8229
8559
  }
8560
+ recordFeatureCost(featureTag, costUsd) {
8561
+ const s = this.featureStats.get(featureTag) ?? { totalCostUsd: 0, runCount: 0 };
8562
+ this.featureStats.set(featureTag, {
8563
+ totalCostUsd: s.totalCostUsd + costUsd,
8564
+ runCount: s.runCount + 1
8565
+ });
8566
+ }
8230
8567
  /**
8231
8568
  * Record an explicit user rating (good/bad). Counts as 3 automatic samples
8232
8569
  * so user feedback carries significantly more weight than auto-detected outcomes.
@@ -8241,6 +8578,9 @@ var ModelPerformanceTracker = class {
8241
8578
  getAll() {
8242
8579
  return new Map(this.stats);
8243
8580
  }
8581
+ getAllFeatures() {
8582
+ return new Map(this.featureStats);
8583
+ }
8244
8584
  /**
8245
8585
  * Returns 0.05–1.0; defaults to 0.5 (neutral prior) when no history exists.
8246
8586
  * High retry counts penalise the score.
@@ -8605,7 +8945,7 @@ Required capability: ${description.slice(0, 300)}`;
8605
8945
  * any dangerous action, so a silently-reloaded tool can't act without approval. */
8606
8946
  async loadPersistedTools() {
8607
8947
  if (!this.workspacePath || !this.persistEnabled) return;
8608
- const file = path18.join(this.workspacePath, ".cascade", DYNAMIC_TOOLS_FILE);
8948
+ const file = path20.join(this.workspacePath, ".cascade", DYNAMIC_TOOLS_FILE);
8609
8949
  try {
8610
8950
  const raw = await fs4.readFile(file, "utf-8");
8611
8951
  const specs = JSON.parse(raw);
@@ -8629,8 +8969,8 @@ Required capability: ${description.slice(0, 300)}`;
8629
8969
  }
8630
8970
  async persist() {
8631
8971
  if (!this.workspacePath || !this.persistEnabled) return;
8632
- const dir = path18.join(this.workspacePath, ".cascade");
8633
- const file = path18.join(dir, DYNAMIC_TOOLS_FILE);
8972
+ const dir = path20.join(this.workspacePath, ".cascade");
8973
+ const file = path20.join(dir, DYNAMIC_TOOLS_FILE);
8634
8974
  try {
8635
8975
  await fs4.mkdir(dir, { recursive: true });
8636
8976
  await fs4.writeFile(file, JSON.stringify(Array.from(this.specs.values()), null, 2), "utf-8");
@@ -8643,6 +8983,166 @@ Required capability: ${description.slice(0, 300)}`;
8643
8983
  return Array.from(this.specs.keys());
8644
8984
  }
8645
8985
  };
8986
+ var WorldStateDB = class {
8987
+ constructor(workspacePath, debugMode = false) {
8988
+ this.workspacePath = workspacePath;
8989
+ this.debugMode = debugMode;
8990
+ const cascadeDir = path20.join(workspacePath, ".cascade");
8991
+ if (!fs19.existsSync(cascadeDir)) {
8992
+ fs19.mkdirSync(cascadeDir, { recursive: true });
8993
+ }
8994
+ this.keyPath = path20.join(cascadeDir, "world_state.key");
8995
+ this.dbPath = path20.join(cascadeDir, "world_state.db");
8996
+ this.initEncryptionKey();
8997
+ this.db = new Database(this.dbPath);
8998
+ this.db.pragma("journal_mode = WAL");
8999
+ this.db.exec(`
9000
+ CREATE TABLE IF NOT EXISTS world_state (
9001
+ id TEXT PRIMARY KEY,
9002
+ timestamp TEXT NOT NULL,
9003
+ worker_id TEXT NOT NULL,
9004
+ encrypted_payload TEXT NOT NULL
9005
+ )
9006
+ `);
9007
+ }
9008
+ workspacePath;
9009
+ debugMode;
9010
+ db;
9011
+ keyPath;
9012
+ dbPath;
9013
+ encryptionKey;
9014
+ initEncryptionKey() {
9015
+ if (fs19.existsSync(this.keyPath)) {
9016
+ this.encryptionKey = fs19.readFileSync(this.keyPath);
9017
+ } else {
9018
+ this.encryptionKey = crypto3.randomBytes(32);
9019
+ fs19.writeFileSync(this.keyPath, this.encryptionKey);
9020
+ }
9021
+ }
9022
+ encrypt(text) {
9023
+ const iv = crypto3.randomBytes(16);
9024
+ const cipher = crypto3.createCipheriv("aes-256-gcm", this.encryptionKey, iv);
9025
+ let encrypted = cipher.update(text, "utf8", "hex");
9026
+ encrypted += cipher.final("hex");
9027
+ const authTag = cipher.getAuthTag().toString("hex");
9028
+ return `${iv.toString("hex")}:${authTag}:${encrypted}`;
9029
+ }
9030
+ decrypt(text) {
9031
+ const parts = text.split(":");
9032
+ if (parts.length !== 3) throw new Error("Invalid encrypted payload format");
9033
+ const [ivHex, authTagHex, encryptedHex] = parts;
9034
+ const iv = Buffer.from(ivHex, "hex");
9035
+ const authTag = Buffer.from(authTagHex, "hex");
9036
+ const decipher = crypto3.createDecipheriv("aes-256-gcm", this.encryptionKey, iv);
9037
+ decipher.setAuthTag(authTag);
9038
+ let decrypted = decipher.update(encryptedHex, "hex", "utf8");
9039
+ decrypted += decipher.final("utf8");
9040
+ return decrypted;
9041
+ }
9042
+ addEntry(workerId, summary) {
9043
+ const id = crypto3.randomUUID();
9044
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
9045
+ const payload = JSON.stringify({ summary });
9046
+ const encryptedPayload = this.encrypt(payload);
9047
+ const stmt = this.db.prepare("INSERT INTO world_state (id, timestamp, worker_id, encrypted_payload) VALUES (?, ?, ?, ?)");
9048
+ stmt.run(id, timestamp, workerId, encryptedPayload);
9049
+ this.dumpDebugIfNeeded();
9050
+ return id;
9051
+ }
9052
+ getAllEntries() {
9053
+ const stmt = this.db.prepare("SELECT id, timestamp, worker_id, encrypted_payload FROM world_state ORDER BY timestamp ASC");
9054
+ const rows = stmt.all();
9055
+ return rows.map((row) => {
9056
+ try {
9057
+ const decrypted = this.decrypt(row.encrypted_payload);
9058
+ const parsed = JSON.parse(decrypted);
9059
+ return {
9060
+ id: row.id,
9061
+ timestamp: row.timestamp,
9062
+ workerId: row.worker_id,
9063
+ summary: parsed.summary
9064
+ };
9065
+ } catch (err) {
9066
+ return {
9067
+ id: row.id,
9068
+ timestamp: row.timestamp,
9069
+ workerId: row.worker_id,
9070
+ summary: "[Decryption Failed - Payload Corrupted]"
9071
+ };
9072
+ }
9073
+ });
9074
+ }
9075
+ getFormattedState() {
9076
+ const entries = this.getAllEntries();
9077
+ if (entries.length === 0) return "World State is currently empty.";
9078
+ return entries.map((e, idx) => `[${e.timestamp}] Step ${idx + 1} (${e.workerId}): ${e.summary}`).join("\n");
9079
+ }
9080
+ dumpDebugIfNeeded() {
9081
+ if (!this.debugMode) return;
9082
+ try {
9083
+ const dumpPath = path20.join(os6.tmpdir(), "cascade_world_state_debug.json");
9084
+ const entries = this.getAllEntries();
9085
+ fs19.writeFileSync(dumpPath, JSON.stringify(entries, null, 2), "utf-8");
9086
+ } catch (err) {
9087
+ console.error("Failed to dump debug world state", err);
9088
+ }
9089
+ }
9090
+ close() {
9091
+ this.db.close();
9092
+ }
9093
+ };
9094
+ var ignore2 = ignoreFactory.default ?? ignoreFactory;
9095
+ var PrivacyPaths = class {
9096
+ localOnly;
9097
+ hasRules;
9098
+ constructor(policies = []) {
9099
+ this.localOnly = ignore2();
9100
+ const patterns = policies.filter((p) => p.policy === "local-only").map((p) => p.pattern);
9101
+ if (patterns.length) this.localOnly.add(patterns);
9102
+ this.hasRules = patterns.length > 0;
9103
+ }
9104
+ /** True when any privacy rules are configured at all (cheap short-circuit). */
9105
+ hasPolicies() {
9106
+ return this.hasRules;
9107
+ }
9108
+ /** True when the given workspace-relative path falls under a local-only policy. */
9109
+ isLocalOnly(relativePath) {
9110
+ if (!this.hasRules || !relativePath) return false;
9111
+ try {
9112
+ return this.localOnly.ignores(relativePath.replace(/^\.?\//, ""));
9113
+ } catch {
9114
+ return false;
9115
+ }
9116
+ }
9117
+ /** True when ANY of the given paths falls under a local-only policy. */
9118
+ anyLocalOnly(relativePaths) {
9119
+ return relativePaths.some((p) => this.isLocalOnly(p));
9120
+ }
9121
+ };
9122
+
9123
+ // src/core/steering/guidance.ts
9124
+ var GuidanceQueue = class {
9125
+ entries = [];
9126
+ /** Per-consumer read cursor so a broadcast entry reaches each worker once. */
9127
+ cursors = /* @__PURE__ */ new Map();
9128
+ push(text, nodeId) {
9129
+ const entry = { text, nodeId, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
9130
+ this.entries.push(entry);
9131
+ return entry;
9132
+ }
9133
+ /**
9134
+ * New entries for this consumer since its last drain, filtered to entries
9135
+ * that target it (or target everyone). Advances the consumer's cursor.
9136
+ */
9137
+ drain(consumerId) {
9138
+ const from = this.cursors.get(consumerId) ?? 0;
9139
+ this.cursors.set(consumerId, this.entries.length);
9140
+ return this.entries.slice(from).filter((e) => !e.nodeId || consumerId === e.nodeId || consumerId.startsWith(e.nodeId));
9141
+ }
9142
+ get size() {
9143
+ return this.entries.length;
9144
+ }
9145
+ };
8646
9146
 
8647
9147
  // src/core/cascade.ts
8648
9148
  var Cascade = class _Cascade extends EventEmitter {
@@ -8662,6 +9162,9 @@ var Cascade = class _Cascade extends EventEmitter {
8662
9162
  taskAnalyzer;
8663
9163
  perfTracker;
8664
9164
  toolCreator;
9165
+ worldStateDB;
9166
+ encryptedAuditLogger;
9167
+ guidanceQueue;
8665
9168
  workspacePath;
8666
9169
  constructor(config, workspacePath, store) {
8667
9170
  super();
@@ -8683,6 +9186,23 @@ var Cascade = class _Cascade extends EventEmitter {
8683
9186
  });
8684
9187
  this.toolRegistry = new ToolRegistry(this.config.tools, workspacePath);
8685
9188
  this.telemetry = config.telemetry?.enabled ? new Telemetry(config.telemetry, config.telemetry.distinctId ?? "anonymous") : noopTelemetry;
9189
+ this.worldStateDB = new WorldStateDB(this.workspacePath, this.config.workspace?.debugWorldState ?? false);
9190
+ this.router.setWorldStateDB(this.worldStateDB);
9191
+ this.encryptedAuditLogger = new AuditLogger2(this.workspacePath, this.config.workspace?.debugWorldState ?? false);
9192
+ const privacyPolicies = this.config.privacy?.paths ?? [];
9193
+ if (privacyPolicies.length) this.router.setPrivacyPaths(new PrivacyPaths(privacyPolicies));
9194
+ this.guidanceQueue = new GuidanceQueue();
9195
+ this.router.setGuidanceQueue(this.guidanceQueue);
9196
+ }
9197
+ /**
9198
+ * Live intervention: inject a user correction into the running hierarchy.
9199
+ * Every active T3 worker (or only the one matching `nodeId`) picks it up at
9200
+ * the top of its next agent-loop iteration as a USER INTERVENTION message.
9201
+ */
9202
+ injectGuidance(text, nodeId) {
9203
+ const entry = this.guidanceQueue.push(text, nodeId);
9204
+ this.encryptedAuditLogger?.logEvent("user_guidance", nodeId ?? "*", { text });
9205
+ this.emit("guidance:injected", entry);
8686
9206
  }
8687
9207
  initOptionalFeatures() {
8688
9208
  if (this.config.cascadeAuto === true) {
@@ -8845,6 +9365,12 @@ ${last.partialOutput}` : "");
8845
9365
  if (!prompt) return null;
8846
9366
  return this.run({ prompt });
8847
9367
  }
9368
+ getWorkspacePath() {
9369
+ return this.workspacePath;
9370
+ }
9371
+ getWorldStateDB() {
9372
+ return this.worldStateDB;
9373
+ }
8848
9374
  /**
8849
9375
  * Record an explicit user rating for the last completed run.
8850
9376
  * Explicit ratings carry 3× the weight of auto-detected outcomes so user
@@ -8931,6 +9457,11 @@ ${last.partialOutput}` : "");
8931
9457
  }
8932
9458
  this.initOptionalFeatures();
8933
9459
  if (this.toolCreator) await this.toolCreator.loadPersistedTools();
9460
+ if (this.encryptedAuditLogger) {
9461
+ this.on("tool:call", (e) => this.encryptedAuditLogger.logEvent("tool_call", e.tierId || "unknown", e));
9462
+ this.on("tool:result", (e) => this.encryptedAuditLogger.logEvent("tool_result", e.tierId || "unknown", e));
9463
+ this.on("tier:status", (e) => this.encryptedAuditLogger.logEvent("tier_status", e.tierId || "unknown", e));
9464
+ }
8934
9465
  this.initialized = true;
8935
9466
  })();
8936
9467
  try {
@@ -8974,6 +9505,21 @@ ${last.partialOutput}` : "");
8974
9505
  const producesArtifact = /\b(?:create|build|implement|generate|write|refactor|rewrite|add|fix|deploy|install|migrate|scaffold|set up|save (?:a|the)|report|\.(?:pdf|md|txt|json|csv|py|js|ts|tsx|jsx|html|docx?))\b/i.test(p);
8975
9506
  return inquiry && !producesArtifact;
8976
9507
  }
9508
+ /**
9509
+ * Strong, explicit signals that a task needs the full hierarchy (planning +
9510
+ * multiple sections/workers). Deliberately conservative: requires a
9511
+ * build/implementation verb AND either an app/system-scale noun or an
9512
+ * explicit multi-part structure, so ordinary single-file asks (handled as
9513
+ * Simple/Moderate) don't get over-escalated.
9514
+ */
9515
+ looksClearlyComplex(prompt) {
9516
+ const p = prompt.trim();
9517
+ if (p.length < 24) return false;
9518
+ const buildVerb = /\b(?:build|implement|create|develop|design|scaffold|refactor|migrate|architect|set up|integrate)\b/i.test(p);
9519
+ const scaleNoun = /\b(?:app(?:lication)?|system|platform|service|api|backend|frontend|full[- ]?stack|website|dashboard|pipeline|microservices?|database schema|authentication|end[- ]to[- ]end|codebase|project|multiple files|several (?:files|modules|components)|test suite)\b/i.test(p);
9520
+ const multiPart = /(?:\b(?:and|then|also|plus|as well as)\b.*\b(?:and|then|also)\b)|(?:^|\n)\s*(?:[-*]|\d+[.)])\s+/i.test(p);
9521
+ return buildVerb && (scaleNoun || multiPart);
9522
+ }
8977
9523
  // Cache glob scan results per workspace path to avoid repeated I/O.
8978
9524
  static globCache = /* @__PURE__ */ new Map();
8979
9525
  async countWorkspaceFiles(workspacePath) {
@@ -9057,10 +9603,16 @@ ${prompt}` : prompt;
9057
9603
  let verdict;
9058
9604
  if (match) {
9059
9605
  verdict = match[1] === "simple" ? "Simple" : match[1] === "moderate" ? "Moderate" : "Complex";
9060
- this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
9606
+ if (verdict !== "Complex" && this.looksClearlyComplex(prompt)) {
9607
+ this.recordDecision("complexity", `Complex \u2014 heuristic floor over classifier "${verdict}": explicit multi-step build/implementation signals (T1 engaged)`);
9608
+ verdict = "Complex";
9609
+ } else {
9610
+ this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
9611
+ }
9061
9612
  } else {
9062
- verdict = prompt.trim().split(/\s+/).length <= 12 ? "Simple" : "Moderate";
9063
- this.recordDecision("complexity", `${verdict} \u2014 classifier output unparseable; defaulted by length`);
9613
+ const words = prompt.trim().split(/\s+/).length;
9614
+ verdict = words <= 12 ? "Simple" : words >= 40 || this.looksClearlyComplex(prompt) ? "Complex" : "Moderate";
9615
+ this.recordDecision("complexity", `${verdict} \u2014 classifier output unparseable; defaulted by length/signals`);
9064
9616
  }
9065
9617
  return verdict;
9066
9618
  } catch {
@@ -9112,7 +9664,15 @@ ${prompt}` : prompt;
9112
9664
  }
9113
9665
  escalator.resolveUserDecision(req.id, approved, always);
9114
9666
  });
9115
- const complexity = await this.determineComplexity(options.prompt, options.workspacePath || process.cwd(), options.conversationHistory);
9667
+ const forceTier = this.config.routing?.forceTier;
9668
+ const forced = forceTier === "T1" ? "Complex" : forceTier === "T2" ? "Moderate" : forceTier === "T3" ? "Simple" : void 0;
9669
+ let complexity;
9670
+ if (forced) {
9671
+ complexity = forced;
9672
+ this.recordDecision("complexity", `${forced} \u2014 manually forced via routing.forceTier=${forceTier}`);
9673
+ } else {
9674
+ complexity = await this.determineComplexity(options.prompt, options.workspacePath || process.cwd(), options.conversationHistory);
9675
+ }
9116
9676
  this.telemetry.capture("cascade:session_start", {
9117
9677
  complexity,
9118
9678
  providerCount: this.config.providers.length,
@@ -9192,6 +9752,7 @@ ${prompt}` : prompt;
9192
9752
  try {
9193
9753
  if (complexity === "Simple") {
9194
9754
  const t3 = new T3Worker(this.router, this.toolRegistry, "root");
9755
+ t3.setPresenter(true);
9195
9756
  t3.setHierarchyContext("You are the DIRECT worker for this task. There is no T1 Administrator or T2 Manager involved in this run.");
9196
9757
  if (identityPrompt) {
9197
9758
  t3.setSystemPromptOverride(identityPrompt);
@@ -9216,6 +9777,7 @@ ${prompt}` : prompt;
9216
9777
  this.emit("tier:status", { tierId: "t3-root", status: "COMPLETED", role: "T3" });
9217
9778
  } else if (complexity === "Moderate") {
9218
9779
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
9780
+ t2.setPresenter(true);
9219
9781
  t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
9220
9782
  if (identityPrompt) {
9221
9783
  t2.setSystemPromptOverride(identityPrompt);
@@ -9265,6 +9827,7 @@ ${prompt}` : prompt;
9265
9827
  }
9266
9828
  } else {
9267
9829
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
9830
+ t1.setPresenter(true);
9268
9831
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
9269
9832
  if (identityPrompt) {
9270
9833
  t1.setSystemPromptOverride(identityPrompt);
@@ -9356,6 +9919,7 @@ ${prompt}` : prompt;
9356
9919
  durationMs,
9357
9920
  costByTier: stats.costByTier,
9358
9921
  tokensByTier: stats.tokensByTier,
9922
+ costByFeature: stats.costByFeature,
9359
9923
  costPercentByTier: this.router.getTierCostPercentages()
9360
9924
  };
9361
9925
  }
@@ -9423,7 +9987,7 @@ var Keystore = class {
9423
9987
  const creds = await this.keytar.findCredentials(KEYTAR_SERVICE);
9424
9988
  this.cache = Object.fromEntries(creds.map((c) => [c.account, c.password]));
9425
9989
  this.backend = "keytar";
9426
- if (password && fs17.existsSync(this.storePath)) {
9990
+ if (password && fs19.existsSync(this.storePath)) {
9427
9991
  try {
9428
9992
  const fileEntries = this.decryptFile(password);
9429
9993
  for (const [k, v] of Object.entries(fileEntries)) {
@@ -9442,8 +10006,8 @@ var Keystore = class {
9442
10006
  "Keystore unlock requires a password because the OS keychain (keytar) is not available on this system."
9443
10007
  );
9444
10008
  }
9445
- if (!fs17.existsSync(this.storePath)) {
9446
- const salt = crypto.randomBytes(SALT_LEN);
10009
+ if (!fs19.existsSync(this.storePath)) {
10010
+ const salt = crypto3.randomBytes(SALT_LEN);
9447
10011
  this.masterKey = this.deriveKey(password, salt);
9448
10012
  this.writeWithSalt({}, salt);
9449
10013
  this.cache = {};
@@ -9456,8 +10020,8 @@ var Keystore = class {
9456
10020
  }
9457
10021
  /** Synchronous legacy unlock kept for AES-only environments. */
9458
10022
  unlockSync(password) {
9459
- if (!fs17.existsSync(this.storePath)) {
9460
- const salt = crypto.randomBytes(SALT_LEN);
10023
+ if (!fs19.existsSync(this.storePath)) {
10024
+ const salt = crypto3.randomBytes(SALT_LEN);
9461
10025
  this.masterKey = this.deriveKey(password, salt);
9462
10026
  this.writeWithSalt({}, salt);
9463
10027
  this.cache = {};
@@ -9514,12 +10078,12 @@ var Keystore = class {
9514
10078
  }
9515
10079
  }
9516
10080
  decryptFile(password, knownSalt) {
9517
- if (!fs17.existsSync(this.storePath)) return {};
10081
+ if (!fs19.existsSync(this.storePath)) return {};
9518
10082
  try {
9519
10083
  const { salt, ciphertext, iv, tag } = this.readRaw();
9520
10084
  const useSalt = knownSalt ?? salt;
9521
10085
  const key = this.masterKey ?? this.deriveKey(password, useSalt);
9522
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
10086
+ const decipher = crypto3.createDecipheriv(ALGORITHM, key, iv);
9523
10087
  decipher.setAuthTag(tag);
9524
10088
  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
9525
10089
  return JSON.parse(decrypted.toString("utf-8"));
@@ -9530,28 +10094,28 @@ var Keystore = class {
9530
10094
  saveAll(data) {
9531
10095
  if (!this.masterKey) return;
9532
10096
  const raw = this.readRaw();
9533
- const iv = crypto.randomBytes(IV_LEN);
9534
- const cipher = crypto.createCipheriv(ALGORITHM, this.masterKey, iv);
10097
+ const iv = crypto3.randomBytes(IV_LEN);
10098
+ const cipher = crypto3.createCipheriv(ALGORITHM, this.masterKey, iv);
9535
10099
  const plaintext = Buffer.from(JSON.stringify(data), "utf-8");
9536
10100
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
9537
10101
  const tag = cipher.getAuthTag();
9538
10102
  const out = Buffer.concat([raw.salt, iv, tag, ciphertext]);
9539
- fs17.mkdirSync(path18.dirname(this.storePath), { recursive: true });
9540
- fs17.writeFileSync(this.storePath, out, { mode: 384 });
10103
+ fs19.mkdirSync(path20.dirname(this.storePath), { recursive: true });
10104
+ fs19.writeFileSync(this.storePath, out, { mode: 384 });
9541
10105
  }
9542
10106
  writeWithSalt(data, salt) {
9543
10107
  if (!this.masterKey) throw new Error("writeWithSalt called before masterKey was set");
9544
- const iv = crypto.randomBytes(IV_LEN);
9545
- const cipher = crypto.createCipheriv(ALGORITHM, this.masterKey, iv);
10108
+ const iv = crypto3.randomBytes(IV_LEN);
10109
+ const cipher = crypto3.createCipheriv(ALGORITHM, this.masterKey, iv);
9546
10110
  const plaintext = Buffer.from(JSON.stringify(data), "utf-8");
9547
10111
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
9548
10112
  const tag = cipher.getAuthTag();
9549
10113
  const out = Buffer.concat([salt, iv, tag, ciphertext]);
9550
- fs17.mkdirSync(path18.dirname(this.storePath), { recursive: true });
9551
- fs17.writeFileSync(this.storePath, out, { mode: 384 });
10114
+ fs19.mkdirSync(path20.dirname(this.storePath), { recursive: true });
10115
+ fs19.writeFileSync(this.storePath, out, { mode: 384 });
9552
10116
  }
9553
10117
  readRaw() {
9554
- const buf = fs17.readFileSync(this.storePath);
10118
+ const buf = fs19.readFileSync(this.storePath);
9555
10119
  let offset = 0;
9556
10120
  const salt = buf.subarray(offset, offset + SALT_LEN);
9557
10121
  offset += SALT_LEN;
@@ -9563,15 +10127,15 @@ var Keystore = class {
9563
10127
  return { salt, iv, tag, ciphertext };
9564
10128
  }
9565
10129
  deriveKey(password, salt) {
9566
- return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, "sha256");
10130
+ return crypto3.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, "sha256");
9567
10131
  }
9568
10132
  };
9569
- var ignore2 = ignoreFactory.default ?? ignoreFactory;
10133
+ var ignore3 = ignoreFactory.default ?? ignoreFactory;
9570
10134
  var CascadeIgnore = class {
9571
10135
  ig;
9572
10136
  loaded = false;
9573
10137
  constructor() {
9574
- this.ig = ignore2();
10138
+ this.ig = ignore3();
9575
10139
  this.ig.add([
9576
10140
  ".cascade/keystore.enc",
9577
10141
  ".cascade/memory.db",
@@ -9584,7 +10148,7 @@ var CascadeIgnore = class {
9584
10148
  ]);
9585
10149
  }
9586
10150
  async load(workspacePath) {
9587
- const filePath = path18.join(workspacePath, ".cascadeignore");
10151
+ const filePath = path20.join(workspacePath, ".cascadeignore");
9588
10152
  try {
9589
10153
  const content = await fs4.readFile(filePath, "utf-8");
9590
10154
  const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
@@ -9595,7 +10159,7 @@ var CascadeIgnore = class {
9595
10159
  }
9596
10160
  isIgnored(filePath, workspacePath) {
9597
10161
  try {
9598
- const relative = workspacePath ? path18.relative(workspacePath, filePath) : filePath;
10162
+ const relative = workspacePath ? path20.relative(workspacePath, filePath) : filePath;
9599
10163
  return this.ig.ignores(relative);
9600
10164
  } catch {
9601
10165
  return false;
@@ -9606,7 +10170,7 @@ var CascadeIgnore = class {
9606
10170
  }
9607
10171
  };
9608
10172
  async function loadCascadeMd(workspacePath) {
9609
- const filePath = path18.join(workspacePath, "CASCADE.md");
10173
+ const filePath = path20.join(workspacePath, "CASCADE.md");
9610
10174
  try {
9611
10175
  const raw = await fs4.readFile(filePath, "utf-8");
9612
10176
  return parseCascadeMd(raw);
@@ -9637,7 +10201,7 @@ ${raw.trim()}`;
9637
10201
  var MemoryStore = class _MemoryStore {
9638
10202
  db;
9639
10203
  constructor(dbPath) {
9640
- fs17.mkdirSync(path18.dirname(dbPath), { recursive: true });
10204
+ fs19.mkdirSync(path20.dirname(dbPath), { recursive: true });
9641
10205
  try {
9642
10206
  this.db = new Database(dbPath, { timeout: 5e3 });
9643
10207
  this.db.pragma("journal_mode = WAL");
@@ -10395,15 +10959,15 @@ var ConfigManager = class {
10395
10959
  globalDir;
10396
10960
  constructor(workspacePath = process.cwd()) {
10397
10961
  this.workspacePath = workspacePath;
10398
- this.globalDir = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR);
10962
+ this.globalDir = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR);
10399
10963
  }
10400
10964
  async load() {
10401
10965
  this.config = await this.loadConfig();
10402
10966
  this.ignore = new CascadeIgnore();
10403
10967
  await this.ignore.load(this.workspacePath);
10404
10968
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
10405
- this.keystore = new Keystore(path18.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
10406
- this.store = new MemoryStore(path18.join(this.workspacePath, CASCADE_DB_FILE));
10969
+ this.keystore = new Keystore(path20.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
10970
+ this.store = new MemoryStore(path20.join(this.workspacePath, CASCADE_DB_FILE));
10407
10971
  await this.injectEnvKeys();
10408
10972
  await this.ensureDefaultIdentity();
10409
10973
  }
@@ -10426,8 +10990,8 @@ var ConfigManager = class {
10426
10990
  return this.workspacePath;
10427
10991
  }
10428
10992
  async save() {
10429
- const configPath = path18.join(this.workspacePath, CASCADE_CONFIG_FILE);
10430
- await fs4.mkdir(path18.dirname(configPath), { recursive: true });
10993
+ const configPath = path20.join(this.workspacePath, CASCADE_CONFIG_FILE);
10994
+ await fs4.mkdir(path20.dirname(configPath), { recursive: true });
10431
10995
  await fs4.writeFile(configPath, JSON.stringify(this.config, null, 2), "utf-8");
10432
10996
  }
10433
10997
  async updateConfig(updates) {
@@ -10451,7 +11015,7 @@ var ConfigManager = class {
10451
11015
  return configProvider?.apiKey;
10452
11016
  }
10453
11017
  async loadConfig() {
10454
- const configPath = path18.join(this.workspacePath, CASCADE_CONFIG_FILE);
11018
+ const configPath = path20.join(this.workspacePath, CASCADE_CONFIG_FILE);
10455
11019
  try {
10456
11020
  const raw = await fs4.readFile(configPath, "utf-8");
10457
11021
  return validateConfig(JSON.parse(raw));
@@ -10681,6 +11245,24 @@ var DashboardSocket = class {
10681
11245
  });
10682
11246
  });
10683
11247
  }
11248
+ onSessionHalt(callback) {
11249
+ this.io.on("connection", (socket) => {
11250
+ socket.on("session:halt", (payload) => {
11251
+ if (typeof payload?.sessionId === "string") {
11252
+ callback(payload.sessionId);
11253
+ }
11254
+ });
11255
+ });
11256
+ }
11257
+ onSessionSteer(callback) {
11258
+ this.io.on("connection", (socket) => {
11259
+ socket.on("session:steer", (payload) => {
11260
+ if (typeof payload?.message === "string" && payload.message.trim()) {
11261
+ callback(payload.message.trim(), payload.sessionId, payload.nodeId);
11262
+ }
11263
+ });
11264
+ });
11265
+ }
10684
11266
  onConfigUpdate(callback) {
10685
11267
  this.io.on("connection", (socket) => {
10686
11268
  socket.on("config:update", (payload) => {
@@ -10703,7 +11285,8 @@ var DashboardSocket = class {
10703
11285
  socket.on("cascade:run", (payload) => {
10704
11286
  if (typeof payload?.prompt === "string" && payload.prompt.trim()) {
10705
11287
  const sessionId = typeof payload.sessionId === "string" && payload.sessionId.trim() ? payload.sessionId.trim() : void 0;
10706
- callback(payload.prompt.trim(), payload.model ?? "auto", socket.id, sessionId);
11288
+ const forceTier = ["T1", "T2", "T3"].includes(payload.forceTier) ? payload.forceTier : void 0;
11289
+ callback(payload.prompt.trim(), payload.model ?? "auto", socket.id, sessionId, forceTier);
10707
11290
  }
10708
11291
  });
10709
11292
  });
@@ -10712,7 +11295,7 @@ var DashboardSocket = class {
10712
11295
  this.io.close();
10713
11296
  }
10714
11297
  };
10715
- var __dirname$1 = path18.dirname(fileURLToPath(import.meta.url));
11298
+ var __dirname$1 = path20.dirname(fileURLToPath(import.meta.url));
10716
11299
  var DashboardServer = class {
10717
11300
  app;
10718
11301
  httpServer;
@@ -10723,6 +11306,20 @@ var DashboardServer = class {
10723
11306
  globalStore = null;
10724
11307
  broadcastTimer = null;
10725
11308
  activeSessions = /* @__PURE__ */ new Map();
11309
+ activeControllers = /* @__PURE__ */ new Map();
11310
+ /**
11311
+ * Run taskIds per chat session — file snapshots are keyed by the run's
11312
+ * taskId (see t3-worker saveSnapshot), so session rollback needs the list
11313
+ * of runs the session performed in this server's lifetime.
11314
+ */
11315
+ sessionTaskIds = /* @__PURE__ */ new Map();
11316
+ /**
11317
+ * Tool-approval requests awaiting a user decision from a connected client,
11318
+ * keyed by the request's uuid. The desktop shows a modal on
11319
+ * `permission:user-required` and answers with `permission:decision`; this
11320
+ * map is how that answer reaches the run that's blocked on it.
11321
+ */
11322
+ pendingApprovals = /* @__PURE__ */ new Map();
10726
11323
  port;
10727
11324
  host;
10728
11325
  workspacePath;
@@ -10781,15 +11378,18 @@ var DashboardServer = class {
10781
11378
  }
10782
11379
  this.persistConfig();
10783
11380
  });
10784
- this.socket.onCascadeRun(async (prompt, model, socketId, requestedSessionId) => {
11381
+ this.socket.onCascadeRun(async (prompt, model, socketId, requestedSessionId, forceTier) => {
10785
11382
  const sessionId = requestedSessionId ?? randomUUID();
11383
+ const abortController = new AbortController();
11384
+ this.activeControllers.set(sessionId, abortController);
10786
11385
  const runPrompt = requestedSessionId ? this.buildContinuationPrompt(sessionId, prompt) : prompt;
10787
11386
  const title = this.persistRunStart(sessionId, prompt);
10788
- const cfg = model !== "auto" ? { ...this.config, models: { ...this.config.models, t1: model } } : this.config;
11387
+ let cfg = model !== "auto" ? { ...this.config, models: { ...this.config.models, t1: model } } : this.config;
11388
+ if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
10789
11389
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
10790
11390
  this.activeSessions.set(sessionId, cascade);
10791
11391
  cascade.on("stream:token", (e) => {
10792
- this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text });
11392
+ this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
10793
11393
  });
10794
11394
  cascade.on("tier:status", (e) => {
10795
11395
  this.socket.emitToSocket(socketId, "tier:status", { sessionId, ...e });
@@ -10801,7 +11401,12 @@ var DashboardServer = class {
10801
11401
  this.socket.emitPeerMessage(e);
10802
11402
  });
10803
11403
  try {
10804
- const result = await cascade.run({ prompt: runPrompt });
11404
+ const result = await cascade.run({
11405
+ prompt: runPrompt,
11406
+ signal: abortController.signal,
11407
+ approvalCallback: this.makeApprovalCallback(sessionId)
11408
+ });
11409
+ this.recordSessionTask(sessionId, result.taskId);
10805
11410
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
10806
11411
  this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
10807
11412
  this.socket.broadcast("cost:update", {
@@ -10818,6 +11423,24 @@ var DashboardServer = class {
10818
11423
  });
10819
11424
  } finally {
10820
11425
  this.activeSessions.delete(sessionId);
11426
+ this.activeControllers.delete(sessionId);
11427
+ this.denyPendingApprovals(sessionId);
11428
+ }
11429
+ });
11430
+ this.socket.onSessionHalt((sessionId) => {
11431
+ this.activeControllers.get(sessionId)?.abort();
11432
+ this.denyPendingApprovals(sessionId);
11433
+ });
11434
+ this.socket.onApprovalResponse(({ requestId, approved, always }) => {
11435
+ const pending = this.pendingApprovals.get(requestId);
11436
+ if (!pending) return;
11437
+ this.pendingApprovals.delete(requestId);
11438
+ pending.resolve({ approved: !!approved, always: !!always });
11439
+ });
11440
+ this.socket.onSessionSteer((message, sessionId, nodeId) => {
11441
+ const steered = this.steerSessions(message, sessionId, nodeId);
11442
+ if (steered > 0) {
11443
+ this.socket.broadcast("session:message-injected", { sessionId, nodeId, message, steered, requestedAt: (/* @__PURE__ */ new Date()).toISOString() });
10821
11444
  }
10822
11445
  });
10823
11446
  }
@@ -10865,9 +11488,9 @@ var DashboardServer = class {
10865
11488
  */
10866
11489
  persistConfig() {
10867
11490
  try {
10868
- const configPath = path18.join(this.workspacePath, CASCADE_CONFIG_FILE);
10869
- fs17.mkdirSync(path18.dirname(configPath), { recursive: true });
10870
- fs17.writeFileSync(configPath, JSON.stringify(this.config, null, 2), "utf-8");
11491
+ const configPath = path20.join(this.workspacePath, CASCADE_CONFIG_FILE);
11492
+ fs19.mkdirSync(path20.dirname(configPath), { recursive: true });
11493
+ fs19.writeFileSync(configPath, JSON.stringify(this.config, null, 2), "utf-8");
10871
11494
  } catch (err) {
10872
11495
  console.warn(`[dashboard] Failed to persist config: ${err instanceof Error ? err.message : String(err)}`);
10873
11496
  }
@@ -10882,15 +11505,15 @@ var DashboardServer = class {
10882
11505
  resolveDashboardSecret() {
10883
11506
  const fromConfig = this.config.dashboard.secret ?? process.env["CASCADE_DASHBOARD_SECRET"];
10884
11507
  if (fromConfig) return fromConfig;
10885
- const secretPath = path18.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
11508
+ const secretPath = path20.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
10886
11509
  try {
10887
- if (fs17.existsSync(secretPath)) {
10888
- const existing = fs17.readFileSync(secretPath, "utf-8").trim();
11510
+ if (fs19.existsSync(secretPath)) {
11511
+ const existing = fs19.readFileSync(secretPath, "utf-8").trim();
10889
11512
  if (existing.length >= 16) return existing;
10890
11513
  }
10891
11514
  const generated = randomUUID();
10892
- fs17.mkdirSync(path18.dirname(secretPath), { recursive: true });
10893
- fs17.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
11515
+ fs19.mkdirSync(path20.dirname(secretPath), { recursive: true });
11516
+ fs19.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
10894
11517
  if (this.config.dashboard.auth) {
10895
11518
  console.warn(
10896
11519
  `Dashboard auth enabled with no secret configured; persisted a generated secret to ${secretPath}. Set CASCADE_DASHBOARD_SECRET or config.dashboard.secret to override.`
@@ -10917,7 +11540,7 @@ var DashboardServer = class {
10917
11540
  // ── Setup ─────────────────────────────────────
10918
11541
  getGlobalStore() {
10919
11542
  if (!this.globalStore) {
10920
- const globalDbPath = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11543
+ const globalDbPath = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10921
11544
  this.globalStore = new MemoryStore(globalDbPath);
10922
11545
  }
10923
11546
  return this.globalStore;
@@ -10965,6 +11588,44 @@ var DashboardServer = class {
10965
11588
  }
10966
11589
  this.persistRuntimeRow(sessionId, title, status, latestPrompt);
10967
11590
  }
11591
+ /**
11592
+ * Route steering text into running Cascade instances. Targets the given
11593
+ * session, or every active session when none is specified (the desktop
11594
+ * usually has exactly one run in flight). Returns how many were reached.
11595
+ */
11596
+ steerSessions(message, sessionId, nodeId) {
11597
+ const targets = sessionId ? [this.activeSessions.get(sessionId)].filter((c) => !!c) : [...this.activeSessions.values()];
11598
+ for (const cascade of targets) cascade.injectGuidance(message, nodeId);
11599
+ return targets.length;
11600
+ }
11601
+ recordSessionTask(sessionId, taskId) {
11602
+ const list = this.sessionTaskIds.get(sessionId) ?? [];
11603
+ list.push(taskId);
11604
+ this.sessionTaskIds.set(sessionId, list);
11605
+ }
11606
+ /**
11607
+ * Approval bridge: cascade calls this when a dangerous tool escalates to the
11608
+ * user. The request itself was already pushed to the client via the
11609
+ * `permission:user-required` forward; here we just park a resolver keyed by
11610
+ * the request id and wait for the client's `permission:decision` (handled in
11611
+ * onApprovalResponse). Never auto-approves — an unanswered request stays
11612
+ * pending until the client answers, the run ends, or the escalator's own
11613
+ * timeout denies it.
11614
+ */
11615
+ makeApprovalCallback(sessionId) {
11616
+ return (request) => new Promise((resolve) => {
11617
+ this.pendingApprovals.set(request.id, { resolve, sessionId });
11618
+ });
11619
+ }
11620
+ /** Deny + clear any approvals still pending for a session (run end / abort). */
11621
+ denyPendingApprovals(sessionId) {
11622
+ for (const [id, pending] of this.pendingApprovals) {
11623
+ if (pending.sessionId === sessionId) {
11624
+ this.pendingApprovals.delete(id);
11625
+ pending.resolve({ approved: false, always: false });
11626
+ }
11627
+ }
11628
+ }
10968
11629
  persistRuntimeRow(sessionId, title, status, latestPrompt) {
10969
11630
  const now = (/* @__PURE__ */ new Date()).toISOString();
10970
11631
  const row = { sessionId, title, workspacePath: this.workspacePath, status, startedAt: now, updatedAt: now, latestPrompt, isGlobal: false };
@@ -11058,12 +11719,12 @@ ${prompt}`;
11058
11719
  }
11059
11720
  }
11060
11721
  watchRuntimeChanges() {
11061
- const workspaceDbPath = path18.join(this.workspacePath, CASCADE_DB_FILE);
11062
- const globalDbPath = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11722
+ const workspaceDbPath = path20.join(this.workspacePath, CASCADE_DB_FILE);
11723
+ const globalDbPath = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11063
11724
  const watchPaths = [workspaceDbPath, globalDbPath].filter((p, index, arr) => arr.indexOf(p) === index);
11064
11725
  for (const watchPath of watchPaths) {
11065
- if (!fs17.existsSync(watchPath)) continue;
11066
- fs17.watchFile(watchPath, { interval: 3e3 }, () => {
11726
+ if (!fs19.existsSync(watchPath)) continue;
11727
+ fs19.watchFile(watchPath, { interval: 3e3 }, () => {
11067
11728
  this.throttledBroadcast(watchPath === globalDbPath ? "global" : "workspace");
11068
11729
  });
11069
11730
  }
@@ -11153,15 +11814,6 @@ ${prompt}`;
11153
11814
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:halt", payload);
11154
11815
  res.json({ success: true, ...payload });
11155
11816
  });
11156
- this.app.post("/api/approve", auth, mutationLimiter, (req, res) => {
11157
- const body = req.body;
11158
- const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
11159
- const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
11160
- const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
11161
- this.socket.broadcast("session:approve", payload);
11162
- if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:approve", payload);
11163
- res.json({ success: true, ...payload });
11164
- });
11165
11817
  this.app.post("/api/inject", auth, mutationLimiter, (req, res) => {
11166
11818
  const body = req.body;
11167
11819
  const message = typeof body["message"] === "string" ? body["message"] : void 0;
@@ -11171,7 +11823,8 @@ ${prompt}`;
11171
11823
  res.status(400).json({ error: "message is required and must be a string" });
11172
11824
  return;
11173
11825
  }
11174
- const payload = { sessionId, nodeId, message, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
11826
+ const steered = this.steerSessions(message, sessionId, nodeId);
11827
+ const payload = { sessionId, nodeId, message, steered, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
11175
11828
  this.socket.broadcast("session:message-injected", payload);
11176
11829
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:message-injected", payload);
11177
11830
  res.json({ success: true, ...payload });
@@ -11193,7 +11846,7 @@ ${prompt}`;
11193
11846
  const sessionId = req.params.id;
11194
11847
  this.store.deleteSession(sessionId);
11195
11848
  this.store.deleteRuntimeSession(sessionId);
11196
- const globalDbPath = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11849
+ const globalDbPath = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11197
11850
  const globalStore = new MemoryStore(globalDbPath);
11198
11851
  try {
11199
11852
  globalStore.deleteRuntimeSession(sessionId);
@@ -11205,9 +11858,34 @@ ${prompt}`;
11205
11858
  this.socket.broadcast("runtime:refresh", { scope: "global" });
11206
11859
  res.json({ ok: true });
11207
11860
  });
11861
+ this.app.post("/api/sessions/:id/rollback", auth, mutationLimiter, async (req, res) => {
11862
+ const sessionId = req.params.id;
11863
+ const taskIds = this.sessionTaskIds.get(sessionId) ?? [];
11864
+ if (!taskIds.length) {
11865
+ res.json({ ok: true, restored: 0, message: "No file snapshots recorded for this session in the current app run." });
11866
+ return;
11867
+ }
11868
+ const toRestore = /* @__PURE__ */ new Map();
11869
+ for (const taskId of taskIds) {
11870
+ for (const { filePath, content } of this.store.getLatestFileSnapshots(taskId)) {
11871
+ if (!toRestore.has(filePath)) toRestore.set(filePath, content);
11872
+ }
11873
+ }
11874
+ const { writeFile } = await import('fs/promises');
11875
+ let restored = 0;
11876
+ for (const [filePath, content] of toRestore) {
11877
+ try {
11878
+ await writeFile(filePath, content, "utf-8");
11879
+ restored++;
11880
+ } catch (err) {
11881
+ console.warn(`[dashboard] rollback restore failed: ${filePath}`, err);
11882
+ }
11883
+ }
11884
+ res.json({ ok: true, restored });
11885
+ });
11208
11886
  this.app.delete("/api/sessions", auth, (req, res) => {
11209
11887
  const body = req.body;
11210
- const globalDbPath = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11888
+ const globalDbPath = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11211
11889
  if (body?.ids && Array.isArray(body.ids) && body.ids.length > 0) {
11212
11890
  const globalStore = new MemoryStore(globalDbPath);
11213
11891
  try {
@@ -11230,7 +11908,7 @@ ${prompt}`;
11230
11908
  });
11231
11909
  this.app.delete("/api/runtime", auth, (_req, res) => {
11232
11910
  this.store.deleteAllRuntimeNodes();
11233
- const globalDbPath = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11911
+ const globalDbPath = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11234
11912
  const globalStore = new MemoryStore(globalDbPath);
11235
11913
  try {
11236
11914
  globalStore.deleteAllRuntimeNodes();
@@ -11281,6 +11959,19 @@ ${prompt}`;
11281
11959
  this.store.deleteIdentity(req.params.id);
11282
11960
  res.json({ ok: true });
11283
11961
  });
11962
+ this.app.get("/api/audit/verify", auth, async (_req, res) => {
11963
+ try {
11964
+ const { AuditLogger: AuditLogger3 } = await Promise.resolve().then(() => (init_audit_logger(), audit_logger_exports));
11965
+ const logger = new AuditLogger3(this.workspacePath);
11966
+ try {
11967
+ res.json(logger.verifyChain());
11968
+ } finally {
11969
+ logger.close();
11970
+ }
11971
+ } catch (err) {
11972
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
11973
+ }
11974
+ });
11284
11975
  this.app.get("/api/audit/:sessionId", auth, (req, res) => {
11285
11976
  const log = this.store.getAuditLog(req.params.sessionId);
11286
11977
  res.json(log);
@@ -11303,12 +11994,12 @@ ${prompt}`;
11303
11994
  if (body["tierLimits"]) this.config.tierLimits = { ...this.config.tierLimits, ...body["tierLimits"] };
11304
11995
  if (body["budget"]) this.config.budget = { ...this.config.budget, ...body["budget"] };
11305
11996
  try {
11306
- const configPath = path18.join(this.workspacePath, CASCADE_CONFIG_FILE);
11307
- const existing = fs17.existsSync(configPath) ? JSON.parse(fs17.readFileSync(configPath, "utf-8")) : {};
11997
+ const configPath = path20.join(this.workspacePath, CASCADE_CONFIG_FILE);
11998
+ const existing = fs19.existsSync(configPath) ? JSON.parse(fs19.readFileSync(configPath, "utf-8")) : {};
11308
11999
  const updated = { ...existing, tierLimits: this.config.tierLimits, budget: this.config.budget };
11309
12000
  const tmp = configPath + ".tmp";
11310
- fs17.writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
11311
- fs17.renameSync(tmp, configPath);
12001
+ fs19.writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
12002
+ fs19.renameSync(tmp, configPath);
11312
12003
  res.json({ ok: true });
11313
12004
  } catch (err) {
11314
12005
  res.status(500).json({ error: `Failed to save config: ${err instanceof Error ? err.message : String(err)}` });
@@ -11336,7 +12027,7 @@ ${prompt}`;
11336
12027
  this.app.get("/api/runtime", auth, (req, res) => {
11337
12028
  const scope = req.query["scope"] ?? "workspace";
11338
12029
  if (scope === "global") {
11339
- const globalDbPath = path18.join(os4.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
12030
+ const globalDbPath = path20.join(os6.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
11340
12031
  const globalStore = new MemoryStore(globalDbPath);
11341
12032
  try {
11342
12033
  res.json({
@@ -11373,7 +12064,7 @@ ${prompt}`;
11373
12064
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
11374
12065
  this.activeSessions.set(sessionId, cascade);
11375
12066
  cascade.on("stream:token", (e) => {
11376
- this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text });
12067
+ this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
11377
12068
  });
11378
12069
  cascade.on("tier:status", (e) => {
11379
12070
  this.socket.broadcastToRoom(`session:${sessionId}`, "tier:status", { sessionId, ...e });
@@ -11385,7 +12076,12 @@ ${prompt}`;
11385
12076
  this.socket.emitPeerMessage(e);
11386
12077
  });
11387
12078
  try {
11388
- const result = await cascade.run({ prompt: runPrompt, identityId: body.identityId });
12079
+ const result = await cascade.run({
12080
+ prompt: runPrompt,
12081
+ identityId: body.identityId,
12082
+ approvalCallback: this.makeApprovalCallback(sessionId)
12083
+ });
12084
+ this.recordSessionTask(sessionId, result.taskId);
11389
12085
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED");
11390
12086
  this.socket.broadcast("cost:update", {
11391
12087
  sessionId,
@@ -11402,6 +12098,7 @@ ${prompt}`;
11402
12098
  });
11403
12099
  } finally {
11404
12100
  this.activeSessions.delete(sessionId);
12101
+ this.denyPendingApprovals(sessionId);
11405
12102
  }
11406
12103
  })();
11407
12104
  });
@@ -11416,13 +12113,13 @@ ${prompt}`;
11416
12113
  }))
11417
12114
  });
11418
12115
  });
11419
- const prodPath = path18.resolve(__dirname$1, "../web/dist");
11420
- const devPath = path18.resolve(__dirname$1, "../../web/dist");
11421
- const webDistPath = fs17.existsSync(prodPath) ? prodPath : devPath;
11422
- if (fs17.existsSync(webDistPath)) {
12116
+ const prodPath = path20.resolve(__dirname$1, "../web/dist");
12117
+ const devPath = path20.resolve(__dirname$1, "../../web/dist");
12118
+ const webDistPath = fs19.existsSync(prodPath) ? prodPath : devPath;
12119
+ if (fs19.existsSync(webDistPath)) {
11423
12120
  this.app.use(express.static(webDistPath));
11424
12121
  this.app.get("*", (_req, res) => {
11425
- res.sendFile(path18.join(webDistPath, "index.html"));
12122
+ res.sendFile(path20.join(webDistPath, "index.html"));
11426
12123
  });
11427
12124
  } else {
11428
12125
  this.app.get("/", (_req, res) => {